diff --git a/extensions/pipeline/.gitignore b/extensions/pipeline/.gitignore new file mode 100644 index 0000000000..10b940d843 --- /dev/null +++ b/extensions/pipeline/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.DS_Store diff --git a/extensions/pipeline/CHANGELOG.md b/extensions/pipeline/CHANGELOG.md new file mode 100644 index 0000000000..d88a6a96fa --- /dev/null +++ b/extensions/pipeline/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to the Pipeline extension are documented here. Format based on +[Keep a Changelog](https://keepachangelog.com/); this project adheres to Semantic Versioning. + +## [1.0.0] - 2026-07-05 + +### Added + +- Initial release. +- `speckit.pipeline.run` — chains `specify → clarify → plan → tasks → analyze → implement` + into one guided invocation with a single interactive clarify gate and an + analyze → fix → re-analyze loop (≤3 cycles) before implement. +- `speckit.pipeline.preview` — dry-run printer for the resolved phase plan. +- `--skip` / `--add` flags with a deterministic phase resolver (`scripts/resolve_phases.py`), + strict validation, and dedicated exit codes (10–14). +- Insertable phases: `constitution` (before specify), `checklist` (after tasks). +- `--yes` unattended mode: answers the clarify gate in-place, halting before `plan` + on a question too consequential to answer without a human. +- Bash + PowerShell wrappers over the Python resolver. +- Stdlib `unittest` suite covering default order, permutation invariance, and every exit code. diff --git a/extensions/pipeline/LICENSE b/extensions/pipeline/LICENSE new file mode 100644 index 0000000000..1f2f1cb34b --- /dev/null +++ b/extensions/pipeline/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dominik Mattioli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/pipeline/README.md b/extensions/pipeline/README.md new file mode 100644 index 0000000000..14e99d89ab --- /dev/null +++ b/extensions/pipeline/README.md @@ -0,0 +1,98 @@ +# Pipeline — a Spec Kit extension + +Chain the [Spec Kit](https://github.com/github/spec-kit) phases into **one guided, single-invocation pipeline** instead of hand-running seven commands. + +``` +specify → clarify → plan → tasks → analyze → implement +``` + +`/speckit.pipeline.run` drives each phase in order, pausing exactly once — at an interactive **clarify** gate — then advances unattended through the rest, re-running `analyze` until findings are resolved (up to 3 cycles) before it implements. The phase order is produced by a small, deterministic resolver, so a tailored pipeline (`--skip`, `--add`) always lands in the same canonical order. + +## Why + +Taking a feature from description to implemented change means issuing `/speckit.specify`, `/speckit.clarify`, `/speckit.plan`, `/speckit.tasks`, `/speckit.analyze`, `/speckit.implement` by hand — and hand-resolving each analyze finding in between. This extension collapses that to a single invocation with one human checkpoint, while keeping every phase's own command as the source of truth (it *drives* the stock commands, it does not reimplement them). + +## Commands + +| Command | What it does | +|---|---| +| `speckit.pipeline.run` | Run the full pipeline from one feature description, with a single clarify checkpoint. | +| `speckit.pipeline.preview` | Print the resolved phase plan for the given flags without running anything (dry run). | + +### Flags + +- `--skip ` — drop default phases (e.g. `--skip clarify,analyze`). `specify` and `implement` cannot be skipped. +- `--add ` — insert optional phases: `constitution` (before specify), `checklist` (after tasks). +- `--yes` — unattended run. Answers the clarify gate itself (grounded in the spec/repo) rather than pausing for a human, and **halts before `plan`** if a question is too consequential to answer unattended. `--yes` never means "no clarification" — use `--skip clarify` for that. + +## Examples + +``` +# Full default run +/speckit.pipeline.run Add rate limiting to the public API + +# See the plan first, then run a tailored pipeline +/speckit.pipeline.preview --add checklist --skip clarify +/speckit.pipeline.run --add checklist Add a healthcheck endpoint + +# Unattended (CI / routine), clarify answered in-place, halts on a consequential question +/speckit.pipeline.run --yes Migrate config loading to env vars +``` + +## How the resolver works + +`scripts/resolve_phases.py` (pure Python, stdlib only) is the deterministic core. It takes the requested skip/add sets, validates them, and returns the phase list in a fixed canonical order. Ordering is a pure function of the *effective set*, so flag ordering never changes the result. Validation is strict, with dedicated exit codes: + +| Exit | Meaning | +|---|---| +| 0 | resolved OK | +| 10 | unknown phase name | +| 11 | a phase is in both `--skip` and `--add` | +| 12 | `--add` names a non-insertable phase | +| 13 | `--skip` targets a required phase (`specify`/`implement`) | +| 14 | dependency break — a retained phase's dependency was skipped | + +`run` and `preview` both call the resolver, so a bad flag combination is caught before any phase runs. `scripts/bash/resolve-phases.sh` and `scripts/powershell/resolve-phases.ps1` are thin wrappers over the same Python, matching Spec Kit's `scripts.sh` / `scripts.ps` command convention. + +## Agent-neutral + +The extension ships no dependency on any specific AI agent, model, or vendor tooling — it drives only stock `/speckit.*` commands (or their skills-mode equivalents `speckit-plan`, `speckit-tasks`, …). It works in any Spec Kit-initialized project regardless of which of the 30+ supported agents you use. + +## Layout + +``` +pipeline/ +├── extension.yml # manifest +├── commands/ +│ ├── run.md # the orchestrator command +│ └── preview.md # dry-run phase-plan printer +├── scripts/ +│ ├── phase_registry.py # deterministic resolver core (pure, no I/O) +│ ├── resolve_phases.py # CLI over the resolver (exit codes 10–14) +│ ├── bash/resolve-phases.sh +│ └── powershell/resolve-phases.ps1 +├── config-template.yml # optional per-project defaults +├── tests/test_phase_registry.py # stdlib unittest — determinism + exit codes +├── README.md +├── CHANGELOG.md +└── LICENSE +``` + +## Install + +Until it is listed in the community catalog, install by copying `pipeline/` into your project's Spec Kit extensions location (or your fork's `extensions/pipeline/`) so the two commands register on init. See the catalog submission notes in the parent directory's contribution guide. + +## Tests + +``` +cd pipeline +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +## License + +MIT — see [LICENSE](LICENSE). + +## Provenance + +Ported from the `speckit-pipeline` orchestration skill (formerly `speckit-workflow`), decoupled from its origin repo's internal tooling into a portable, agent-neutral Spec Kit extension. diff --git a/extensions/pipeline/commands/preview.md b/extensions/pipeline/commands/preview.md new file mode 100644 index 0000000000..4b303867de --- /dev/null +++ b/extensions/pipeline/commands/preview.md @@ -0,0 +1,29 @@ +--- +description: "Print the resolved Spec Kit pipeline phase plan for the given --skip/--add flags without running anything — a dry run of the deterministic phase resolver." +scripts: + sh: scripts/bash/resolve-phases.sh + ps: scripts/powershell/resolve-phases.ps1 +--- + +# Pipeline: preview + +Show the phase plan `/speckit.pipeline.run` *would* execute for a given set of flags, without running any phase. Use it to confirm a tailored pipeline (skips/adds) resolves the way you expect before committing to a full run. + +## Input + +`$ARGUMENTS` — optional flags only (no feature description needed): + +- `--skip ` — default phases to drop. +- `--add ` — insertable phases to add (`constitution`, `checklist`). + +## Behavior + +Run the resolver and print its output: + +``` +{SCRIPT} --skip "" --add "" +``` + +Each line is `order phase command gate description`, in the exact order `run` would execute. `--list` (no flags) dumps the full registry of orderable phases. + +Report the resolver's exit code and, on any non-zero code (`10`–`14`), its stderr message verbatim — the same validation `run` performs, so a bad flag combination is caught here first. This command never edits files and never invokes a `/speckit.*` phase. diff --git a/extensions/pipeline/commands/run.md b/extensions/pipeline/commands/run.md new file mode 100644 index 0000000000..424551ed1a --- /dev/null +++ b/extensions/pipeline/commands/run.md @@ -0,0 +1,87 @@ +--- +description: "Run the full Spec Kit pipeline (specify → clarify → plan → tasks → analyze → implement) from one feature description, with a single interactive clarify checkpoint and a deterministic, tailorable phase order." +scripts: + sh: scripts/bash/resolve-phases.sh + ps: scripts/powershell/resolve-phases.ps1 +--- + +# Pipeline: run + +Carry one feature from a plain-language description to an implemented change by chaining the existing Spec Kit phase commands into a single guided run. You (the agent) **are** the orchestrator: you follow this procedure turn by turn, invoking each `/speckit.*` command in order and verifying its artifact landed before moving on. This replaces hand-running `/speckit.specify`, `/speckit.clarify`, `/speckit.plan`, `/speckit.tasks`, `/speckit.analyze`, and `/speckit.implement` one at a time and hand-resolving analyze findings. + +## Input + +`$ARGUMENTS` — the feature description, optionally followed by flags: + +- `--skip ` — drop default phases (e.g. `--skip clarify,analyze`). Cannot drop `specify` or `implement`. +- `--add ` — insert optional phases: `constitution` (before specify), `checklist` (after tasks). +- `--yes` — unattended run. Does **not** skip clarification; instead you answer the clarify questions yourself, grounded in the spec and repo conventions, and halt before `plan` if a question is too consequential to answer without a human (see Step 4). Use `--skip clarify` if you genuinely want zero clarification. + +If the feature description is empty, report the missing input and stop — do not start. + +## Step 1 — Resolve the phase plan (deterministic) + +Run the resolver once and branch on its exit code: + +``` +{SCRIPT} --skip "" --add "" --json +``` + +(`{SCRIPT}` is this command's configured `scripts.sh` / `scripts.ps`.) + +Exit codes: `0` OK · `10` unknown phase name · `11` skip/add name conflict · `12` add-name not insertable · `13` skip targets a required phase · `14` dependency break. On any non-zero code, report the resolver's stderr message verbatim and stop — do not run a partial or incoherent pipeline. The order is deterministic: the resolver consumes skip/add as sets and derives order from one fixed key, so flag ordering never changes the plan. + +## Step 2 — Preflight + +Confirm the project is Spec Kit-initialized (a `.specify/` directory exists) and that every `/speckit.*` command named in the resolved plan is available in this agent's command set. Report anything missing now — never mid-pipeline. If `--add constitution` or `--add checklist` was requested but that command isn't installed, report and stop. + +## Step 3 — Execute each phase in order + +For each phase in the resolved plan, invoke its `/speckit.*` command (the `command` field from the resolver output). After each phase: + +- **Verify the artifact.** Confirm the expected file was written (`spec.md`, `plan.md`, `tasks.md`, etc.) before proceeding. A phase reporting success is not enough — check the artifact is on disk. +- **Halt on failure.** If a phase fails and the failure is not something you can safely resolve, stop and name the phase and reason. Never proceed past a broken artifact. + +Keep a short running ledger (which phases ran, which were skipped, outcome of each) so the run survives a context reset. + +## Step 4 — The clarify gate + +`clarify` is the single interactive checkpoint; everything after it runs unattended. + +**Default (no `--yes`)** — run `/speckit.clarify`, present its questions to the human, and **end your turn to wait** for answers. This is the one human checkpoint. + +**`--yes` set** — no human is present, so `--yes` does not mean "skip clarification", it means "answer it responsibly yourself": + +1. Run `/speckit.clarify`'s question generation as normal, but don't present the questions to a human. +2. Answer each question as the operator plausibly would, grounded strictly in the spec's own content, the project's constitution/conventions, and established repo patterns. Integrate each answer into the spec exactly as `/speckit.clarify` integrates a human's answer. +3. Emit one line noting that clarify was answered unattended, not by a human, so the audit trail is honest. +4. **If any question is too consequential to answer without a human** — insufficient grounding, security/scope/privacy stakes, or a materially shape-changing decision the spec leaves open — do **not** guess. Halt before `plan`, name the unresolved question and why. This is a correct outcome, not a failure: it means the run recognized it should not proceed unattended past this point. + +## Step 5 — The analyze → resolve loop + +After `/speckit.analyze` reports findings, fix each finding (edit the spec/plan/tasks as needed), then re-run `/speckit.analyze`. Repeat **up to 3 cycles**. If findings remain unresolved after the third cycle, halt and report them rather than implementing against a known-inconsistent spec. + +## Step 6 — Unattended discipline (clarify-satisfied → implement) + +From the moment clarify is satisfied through `implement`, run without acknowledgment chatter between phases, but: + +- **Log every skip and every fallback.** Silent deviation from the plan is not allowed — if you skip or work around something, say so in the ledger. +- **Halt on destructive or irreversible actions** during `implement` (deleting data, force-pushing, rewriting shared history) and ask, rather than proceeding blindly. +- Any phase failure that isn't auto-resolvable, or is unsafe to continue past, halts **before** `implement`, naming the phase and reason. + +## Hard stops + +| Condition | Behavior | +|---|---| +| Empty feature description | Report missing input; do not start. | +| Resolver returns non-zero | Report its message; run no phase. | +| `.specify/` or a required `/speckit.*` command missing at preflight | Report; do not start. | +| Expected artifact absent after a phase | Halt, name the phase; do not proceed. | +| Analyze findings unresolved after 3 cycles | Halt; report the unresolved findings. | +| A clarify question too consequential to answer unattended (`--yes`) | Halt before `plan`, name the question and why; never guess. | +| Destructive/irreversible action during implement | Halt and ask. | + +## Notes + +- **Agent-neutral.** This command drives only stock `/speckit.*` commands and ships no dependency on any specific AI agent, model, or vendor tooling. In an agent that renders Spec Kit commands as skills, the same phases appear as `speckit-plan`, `speckit-tasks`, etc. — invoke whichever form your agent exposes. +- **Preview first.** Run `/speckit.pipeline.preview` with the same flags to see the resolved plan before committing to a full run. diff --git a/extensions/pipeline/config-template.yml b/extensions/pipeline/config-template.yml new file mode 100644 index 0000000000..2139c703ca --- /dev/null +++ b/extensions/pipeline/config-template.yml @@ -0,0 +1,21 @@ +# Pipeline extension configuration. +# Copy to `pipeline-config.yml` in your project's Spec Kit config location and edit. +# Every key is optional; the values below are the built-in defaults. + +pipeline: + # Default phases to skip on every `/speckit.pipeline.run` unless overridden by --skip. + # Cannot include the required phases `specify` or `implement`. + default_skip: [] + + # Default insertable phases to add on every run unless overridden by --add. + # Valid values: constitution, checklist. + default_add: [] + + # Maximum analyze → fix → re-analyze cycles before the run halts and reports + # remaining findings instead of implementing against an inconsistent spec. + analyze_max_cycles: 3 + + # When true, `--yes` runs answer the clarify gate unattended (grounded in the + # spec/repo) instead of pausing for a human. A question too consequential to + # answer without a human still halts the run before `plan`. + answer_clarify_unattended: false diff --git a/extensions/pipeline/extension.yml b/extensions/pipeline/extension.yml new file mode 100644 index 0000000000..fea7dcdc33 --- /dev/null +++ b/extensions/pipeline/extension.yml @@ -0,0 +1,37 @@ +schema_version: "1.0" + +extension: + id: "pipeline" + name: "Pipeline" + version: "1.0.0" + description: "Chain the Spec Kit phases (specify → clarify → plan → tasks → analyze → implement) into one guided, single-invocation pipeline with a deterministic phase resolver and one interactive clarify gate." + author: "Dominik Mattioli" + repository: "https://github.com/domattioli/spec-kit-pipeline" + license: "MIT" + homepage: "https://github.com/domattioli/spec-kit-pipeline" + +requires: + speckit_version: ">=0.2.0" + +provides: + commands: + - name: "speckit.pipeline.run" + file: "commands/run.md" + description: "Run the full spec → implement pipeline from one feature description, with a single interactive clarify checkpoint." + - name: "speckit.pipeline.preview" + file: "commands/preview.md" + description: "Print the resolved phase plan for the given --skip/--add flags without running anything (dry run)." + + config: + - name: "pipeline-config.yml" + template: "config-template.yml" + description: "Pipeline defaults — auto-resolve cap for analyze findings, default skip/add sets." + required: false + +tags: + - "workflow" + - "orchestration" + - "pipeline" + - "automation" + - "sdd" + - "spec-driven-development" diff --git a/extensions/pipeline/scripts/bash/resolve-phases.sh b/extensions/pipeline/scripts/bash/resolve-phases.sh new file mode 100755 index 0000000000..d6f8dd34c9 --- /dev/null +++ b/extensions/pipeline/scripts/bash/resolve-phases.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Thin POSIX wrapper: resolve the Spec Kit pipeline phase plan. +# Delegates to the pure-Python resolver so there is one source of truth. +# Usage: resolve-phases.sh [--skip a,b] [--add x,y] [--json|--list] +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${HERE}/../resolve_phases.py" "$@" diff --git a/extensions/pipeline/scripts/phase_registry.py b/extensions/pipeline/scripts/phase_registry.py new file mode 100755 index 0000000000..14a7f07686 --- /dev/null +++ b/extensions/pipeline/scripts/phase_registry.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Phase registry and resolver for the Spec Kit `pipeline` extension. + +Pure function, zero I/O. Encodes every orderable Spec Kit phase with its +dependencies and the `/speckit.*` command it maps to, and resolves a +requested skip/add set into a single deterministic ordered plan. + +This is the reusable core of the extension: the command prose calls +`resolve_phases.py` (a thin CLI over `resolve()` below) to decide which +phases run and in what order, then drives each `/speckit.*` command in turn. + +No agent/model/vendor coupling lives here — the registry is portable to any +Spec Kit project and any AI coding agent. +""" + +from collections import namedtuple + +# Phase registry entry. `command` is the slash command the phase drives +# (agent-neutral: Spec Kit renders it as `/speckit.plan`, or the skills-mode +# equivalent `speckit-plan`, per the active integration). +Phase = namedtuple( + "Phase", + ["order", "default", "insertable", "required", "interactive", "deps", "command", "description"], +) + +# Complete phase registry: id -> Phase. +REGISTRY = { + "constitution": Phase( + order=5, + default=False, + insertable=True, + required=False, + interactive=False, + deps=[], + command="speckit.constitution", + description="Establish or update the project constitution before specifying.", + ), + "specify": Phase( + order=10, + default=True, + insertable=False, + required=True, + interactive=False, + deps=[], + command="speckit.specify", + description="Turn the feature description into a specification.", + ), + "clarify": Phase( + order=20, + default=True, + insertable=False, + required=False, + interactive=True, + deps=["specify"], + command="speckit.clarify", + description="Resolve underspecified areas. The single interactive human gate.", + ), + "plan": Phase( + order=30, + default=True, + insertable=False, + required=False, + interactive=False, + deps=["specify"], + command="speckit.plan", + description="Produce the implementation plan and design artifacts.", + ), + "tasks": Phase( + order=40, + default=True, + insertable=False, + required=False, + interactive=False, + deps=["plan"], + command="speckit.tasks", + description="Generate the dependency-ordered task list.", + ), + "checklist": Phase( + order=45, + default=False, + insertable=True, + required=False, + interactive=False, + deps=["tasks"], + command="speckit.checklist", + description="Generate a quality checklist for the feature.", + ), + "analyze": Phase( + order=60, + default=True, + insertable=False, + required=False, + interactive=False, + deps=["specify", "plan", "tasks"], + command="speckit.analyze", + description="Cross-artifact consistency and quality analysis before implementing.", + ), + "implement": Phase( + order=70, + default=True, + insertable=False, + required=True, + interactive=False, + deps=["plan", "tasks"], + command="speckit.implement", + description="Execute the plan and produce the change.", + ), +} + +# Orderable partitions. +DEFAULTS = {"specify", "clarify", "plan", "tasks", "analyze", "implement"} +INSERTABLE = {"constitution", "checklist"} +REQUIRED = {"specify", "implement"} + + +class UnknownPhase(Exception): + """Exit code 10: unrecognized phase name(s).""" + + code = 10 + + def __init__(self, names, valid): + self.names = names + self.valid = valid + super().__init__(f"Unknown phase name(s): {', '.join(names)}. Valid: {', '.join(valid)}") + + +class SkipAddConflict(Exception): + """Exit code 11: phase appears in both --skip and --add.""" + + code = 11 + + def __init__(self, names): + self.names = names + super().__init__(f"Phase(s) appear in both --skip and --add: {', '.join(names)}") + + +class NotInsertable(Exception): + """Exit code 12: attempted to add a non-insertable phase.""" + + code = 12 + + def __init__(self, names, insertable_set): + self.names = names + self.insertable_set = insertable_set + super().__init__( + f"Cannot add non-insertable phase(s): {', '.join(names)}. " + f"Insertable phases: {', '.join(sorted(insertable_set))}" + ) + + +class RequiredPhaseSkip(Exception): + """Exit code 13: attempted to skip a required phase.""" + + code = 13 + + def __init__(self, names): + self.names = names + super().__init__(f"Cannot skip required phase(s): {', '.join(names)}") + + +class DepBreak(Exception): + """Exit code 14: unmet dependencies in the effective set.""" + + code = 14 + + def __init__(self, violations): + self.violations = violations # list of (phase, missing_dep) tuples + super().__init__( + "Dependency broken. Phase(s) missing required input:\n" + + "\n".join(f" {phase} requires {dep}" for phase, dep in violations) + ) + + +def resolve(skip, add): + """Deterministic phase resolver. + + Args: + skip: set of phase names to exclude from the default set. + add: set of insertable phase names to include. + + Returns: + list of phase names in canonical order. + + Raises: + UnknownPhase (10), SkipAddConflict (11), NotInsertable (12), + RequiredPhaseSkip (13), DepBreak (14) — checked in that order. + + Ordering is a pure function of the effective set (skip/add consumed as + sets, order derived from the single fixed `order` key), so the output is + invariant to the input ordering of --skip / --add. + """ + orderable = set(DEFAULTS) | set(INSERTABLE) + + unknown = (skip | add) - orderable + if unknown: + raise UnknownPhase(sorted(unknown), valid=sorted(orderable)) + + conflict = skip & add + if conflict: + raise SkipAddConflict(sorted(conflict)) + + not_insertable = add - INSERTABLE + if not_insertable: + raise NotInsertable(sorted(not_insertable), INSERTABLE) + + required_hit = skip & REQUIRED + if required_hit: + raise RequiredPhaseSkip(sorted(required_hit)) + + effective = (DEFAULTS - skip) | add + + violations = [ + (phase, dep) + for phase in sorted(effective, key=lambda p: REGISTRY[p].order) + for dep in REGISTRY[phase].deps + if dep not in effective + ] + if violations: + raise DepBreak(violations) + + return sorted(effective, key=lambda p: REGISTRY[p].order) diff --git a/extensions/pipeline/scripts/powershell/resolve-phases.ps1 b/extensions/pipeline/scripts/powershell/resolve-phases.ps1 new file mode 100644 index 0000000000..2b9deea1aa --- /dev/null +++ b/extensions/pipeline/scripts/powershell/resolve-phases.ps1 @@ -0,0 +1,8 @@ +# Thin PowerShell wrapper: resolve the Spec Kit pipeline phase plan. +# Delegates to the pure-Python resolver so there is one source of truth. +# Usage: resolve-phases.ps1 [--skip a,b] [--add x,y] [--json|--list] +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$resolver = Join-Path $here "..\resolve_phases.py" +python3 $resolver @args +exit $LASTEXITCODE diff --git a/extensions/pipeline/scripts/resolve_phases.py b/extensions/pipeline/scripts/resolve_phases.py new file mode 100755 index 0000000000..36039090d8 --- /dev/null +++ b/extensions/pipeline/scripts/resolve_phases.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""CLI wrapper over the phase resolver for the Spec Kit `pipeline` extension. + +Usage: + resolve_phases.py [--skip a,b] [--add x,y] [--json] + resolve_phases.py --list + +Exit codes: + 0 success (ordered phase plan on stdout) + 2 usage error + 10 unknown phase name + 11 skip/add conflict + 12 add not insertable + 13 skip required phase + 14 dependency break +""" + +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from phase_registry import ( # noqa: E402 + REGISTRY, + DEFAULTS, + INSERTABLE, + DepBreak, + NotInsertable, + RequiredPhaseSkip, + SkipAddConflict, + UnknownPhase, + resolve, +) + + +def parse_csv(csv_str): + if not csv_str: + return set() + return {item.strip() for item in csv_str.split(",") if item.strip()} + + +def _row(phase_id): + p = REGISTRY[phase_id] + gate = "gate" if p.interactive else "-" + return f"{p.order}\t{phase_id}\t{p.command}\t{gate}\t{p.description}" + + +def main(): + parser = argparse.ArgumentParser( + prog="resolve_phases.py", + description="Deterministic phase resolver for the Spec Kit pipeline extension.", + ) + parser.add_argument("--skip", type=str, default="", help="CSV list of phases to skip") + parser.add_argument("--add", type=str, default="", help="CSV list of insertable phases to add") + parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument("--list", action="store_true", help="List all orderable phases and exit") + + try: + args = parser.parse_args() + except SystemExit as e: + if e.code != 0: + sys.exit(2) + raise + + if args.list: + orderable = sorted( + (set(DEFAULTS) | set(INSERTABLE)), key=lambda p: REGISTRY[p].order + ) + for phase_id in orderable: + print(_row(phase_id)) + sys.exit(0) + + skip_set = parse_csv(args.skip) + add_set = parse_csv(args.add) + + try: + effective = resolve(skip_set, add_set) + except (UnknownPhase, SkipAddConflict, NotInsertable, RequiredPhaseSkip, DepBreak) as e: + print(str(e), file=sys.stderr) + sys.exit(e.code) + + if args.json: + output = [ + { + "phase": phase_id, + "order": REGISTRY[phase_id].order, + "command": REGISTRY[phase_id].command, + "interactive": REGISTRY[phase_id].interactive, + "description": REGISTRY[phase_id].description, + } + for phase_id in effective + ] + print(json.dumps(output)) + else: + for phase_id in effective: + print(_row(phase_id)) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/extensions/pipeline/tests/test_phase_registry.py b/extensions/pipeline/tests/test_phase_registry.py new file mode 100644 index 0000000000..e58d1824aa --- /dev/null +++ b/extensions/pipeline/tests/test_phase_registry.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Unit tests for the pipeline phase resolver. Stdlib unittest only; no pytest. + +Run from the extension root: + python3 -m unittest discover -s tests -p 'test_*.py' +""" + +import json +import subprocess +import sys +import unittest +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +from phase_registry import ( # noqa: E402 + REGISTRY, + DEFAULTS, + INSERTABLE, + DepBreak, + NotInsertable, + RequiredPhaseSkip, + SkipAddConflict, + UnknownPhase, + resolve, +) + + +class TestPhaseRegistry(unittest.TestCase): + def test_default_order(self): + self.assertEqual( + resolve(set(), set()), + ["specify", "clarify", "plan", "tasks", "analyze", "implement"], + ) + + def test_skip_two_defaults_preserves_relative_order(self): + self.assertEqual( + resolve({"clarify", "analyze"}, set()), + ["specify", "plan", "tasks", "implement"], + ) + + def test_add_out_of_order_lands_canonical(self): + expected = ["constitution", "specify", "clarify", "plan", "tasks", "checklist", "analyze", "implement"] + self.assertEqual(resolve(set(), {"constitution", "checklist"}), expected) + self.assertEqual(resolve(set(), {"checklist", "constitution"}), expected) + + def test_permutation_invariance(self): + outputs = [] + for order in ("clarify,analyze", "analyze,clarify"): + cmd = [sys.executable, str(SCRIPTS_DIR / "resolve_phases.py"), "--skip", order, "--json"] + r = subprocess.run(cmd, capture_output=True, text=True, cwd=str(SCRIPTS_DIR)) + self.assertEqual(r.returncode, 0, r.stderr) + outputs.append(r.stdout) + self.assertEqual(len(set(outputs)), 1) + + def test_unknown_name(self): + with self.assertRaises(UnknownPhase) as cm: + resolve(set(), {"nonexistent"}) + self.assertIn("nonexistent", cm.exception.names) + self.assertEqual(set(cm.exception.valid), set(DEFAULTS) | set(INSERTABLE)) + + def test_skip_add_conflict_first(self): + with self.assertRaises(SkipAddConflict) as cm: + resolve({"clarify"}, {"clarify"}) + self.assertIn("clarify", cm.exception.names) + + def test_add_non_insertable_rejected(self): + with self.assertRaises(NotInsertable) as cm: + resolve(set(), {"plan"}) + self.assertIn("plan", cm.exception.names) + + def test_skip_required_specify(self): + with self.assertRaises(RequiredPhaseSkip): + resolve({"specify"}, set()) + + def test_skip_required_implement(self): + with self.assertRaises(RequiredPhaseSkip): + resolve({"implement"}, set()) + + def test_skip_plan_collects_dep_breaks(self): + with self.assertRaises(DepBreak) as cm: + resolve({"plan"}, set()) + broken = {phase for phase, _ in cm.exception.violations} + self.assertIn("tasks", broken) + self.assertIn("analyze", broken) + self.assertIn("implement", broken) + ordered = [phase for phase, _ in cm.exception.violations] + self.assertEqual(ordered, sorted(ordered, key=lambda p: REGISTRY[p].order)) + + def test_skip_non_default_is_noop(self): + self.assertEqual(resolve({"checklist"}, set()), resolve(set(), set())) + + def test_add_checklist_after_tasks(self): + result = resolve(set(), {"checklist"}) + self.assertLess(result.index("tasks"), result.index("checklist")) + self.assertLess(result.index("checklist"), result.index("analyze")) + + def test_cli_exit_codes(self): + cases = [ + (["--skip", "nonexistent"], 10), + (["--skip", "clarify", "--add", "clarify"], 11), + (["--add", "plan"], 12), + (["--skip", "specify"], 13), + (["--skip", "plan"], 14), + ([], 0), + ] + for args, code in cases: + cmd = [sys.executable, str(SCRIPTS_DIR / "resolve_phases.py")] + args + r = subprocess.run(cmd, capture_output=True, text=True, cwd=str(SCRIPTS_DIR)) + self.assertEqual(r.returncode, code, f"{args}: {r.stderr}") + + def test_json_shape(self): + cmd = [sys.executable, str(SCRIPTS_DIR / "resolve_phases.py"), "--json"] + r = subprocess.run(cmd, capture_output=True, text=True, cwd=str(SCRIPTS_DIR)) + self.assertEqual(r.returncode, 0) + out = json.loads(r.stdout) + self.assertIsInstance(out, list) + for item in out: + for key in ("phase", "order", "command", "interactive", "description"): + self.assertIn(key, item) + orders = [item["order"] for item in out] + self.assertEqual(orders, sorted(orders)) + + def test_list_short_circuits(self): + base = subprocess.run( + [sys.executable, str(SCRIPTS_DIR / "resolve_phases.py"), "--list"], + capture_output=True, text=True, cwd=str(SCRIPTS_DIR), + ) + self.assertEqual(base.returncode, 0) + base_lines = base.stdout.strip().split("\n") + self.assertEqual(len(base_lines), len(set(DEFAULTS) | set(INSERTABLE))) + withargs = subprocess.run( + [sys.executable, str(SCRIPTS_DIR / "resolve_phases.py"), "--list", "--skip", "clarify", "--add", "constitution"], + capture_output=True, text=True, cwd=str(SCRIPTS_DIR), + ) + self.assertEqual(base_lines, withargs.stdout.strip().split("\n")) + + +if __name__ == "__main__": + unittest.main(verbosity=2)