mirror of
https://github.com/github/spec-kit.git
synced 2026-07-10 17:59:32 +08:00
Compare commits
4 Commits
update-doc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7392ae32 | ||
|
|
d075b27360 | ||
|
|
eedf73f714 | ||
|
|
292eaa6c98 |
@@ -14,3 +14,5 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
|
||||
|
||||
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
|
||||
|
||||
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
|
||||
|
||||
@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
|
||||
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
|
||||
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/**/plan.md`, any depth).
|
||||
|
||||
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
|
||||
|
||||
@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
|
||||
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (any depth, so scoped layouts like `specs/<scope>/<feature>/plan.md` are found).
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -307,14 +307,14 @@ import sys
|
||||
from pathlib import Path
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
specs = root / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
plan = max(
|
||||
specs.glob("**/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
default=None,
|
||||
)
|
||||
if plans:
|
||||
if plan:
|
||||
try:
|
||||
print(plans[0].relative_to(root).as_posix())
|
||||
print(plan.relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
print("")
|
||||
else:
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -426,9 +426,7 @@ if (-not $PlanPath) {
|
||||
if (-not $PlanPath) {
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
$candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
|
||||
@@ -25,6 +25,14 @@ class ShellStep(StepBase):
|
||||
run_cmd = str(run_cmd)
|
||||
|
||||
cwd = context.project_root or "."
|
||||
# Defensive: the engine does not auto-validate step config, so an
|
||||
# invalid ``timeout`` (string, None, ...) would otherwise raise a
|
||||
# TypeError from subprocess.run() and crash the whole run. Mirror
|
||||
# the engine's handling of unvalidated ``continue_on_error`` by
|
||||
# only honoring well-formed values and falling back to the default.
|
||||
timeout = config.get("timeout", 300)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
||||
timeout = 300
|
||||
|
||||
# NOTE: shell=True is required to support pipes, redirects, and
|
||||
# multi-command expressions in workflow YAML. Workflow authors
|
||||
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=300,
|
||||
timeout=timeout,
|
||||
)
|
||||
output = {
|
||||
"exit_code": proc.returncode,
|
||||
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
|
||||
except subprocess.TimeoutExpired:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error="Shell command timed out after 300 seconds.",
|
||||
error=f"Shell command timed out after {timeout} seconds.",
|
||||
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
|
||||
)
|
||||
except OSError as exc:
|
||||
@@ -106,4 +114,16 @@ class ShellStep(StepBase):
|
||||
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||
f"be 'json' when present, got {output_format!r}."
|
||||
)
|
||||
if "timeout" in config:
|
||||
timeout = config["timeout"]
|
||||
# bool is an int subclass, so reject it explicitly.
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, int)
|
||||
or timeout <= 0
|
||||
):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive integer (seconds) when present, got {timeout!r}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -688,6 +688,62 @@ class TestExtensionSelfSeed:
|
||||
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
||||
|
||||
|
||||
class TestPlanDiscovery:
|
||||
"""Mtime fallback must find plans in nested spec layouts (#3024).
|
||||
|
||||
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
|
||||
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
|
||||
glob never matches those.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_plans(project: Path) -> Path:
|
||||
# Older flat plan plus a newer nested plan: recursive discovery
|
||||
# must pick the nested one by mtime.
|
||||
flat = project / "specs" / "old-feature" / "plan.md"
|
||||
flat.parent.mkdir(parents=True)
|
||||
flat.write_text("flat plan\n", encoding="utf-8")
|
||||
os.utime(flat, (1_000_000_000, 1_000_000_000))
|
||||
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text("nested plan\n", encoding="utf-8")
|
||||
return nested
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
|
||||
class TestMdcFrontmatter:
|
||||
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
|
||||
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""
|
||||
|
||||
@@ -39,6 +39,17 @@ def _pin_interpreter(monkeypatch):
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
# On Windows, ``resolve_python_interpreter`` guards the ``which`` result
|
||||
# with a real ``_interpreter_runs`` subprocess probe (#3304). The mocked
|
||||
# ``/usr/bin/python3`` path does not exist on a Windows runner, so the
|
||||
# probe would fail and the resolver would fall back to ``sys.executable``
|
||||
# (a ``...python.exe`` path), breaking the ``python3``-anchored assertion.
|
||||
# Pin the probe to True so the interpreter token stays ``python3`` on all
|
||||
# platforms.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
|
||||
staticmethod(lambda path: True),
|
||||
)
|
||||
|
||||
|
||||
def test_py_templates_discovered():
|
||||
|
||||
@@ -1353,6 +1353,106 @@ class TestShellStep:
|
||||
assert step.validate({"id": "s", "run": "echo hi"}) == []
|
||||
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
|
||||
|
||||
def test_timeout_is_configurable(self, monkeypatch):
|
||||
"""A 'timeout' field overrides the 300s default (#3327)."""
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
step = ShellStep()
|
||||
result = step.execute(
|
||||
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 1800
|
||||
|
||||
def test_timeout_defaults_to_300(self, monkeypatch):
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 300
|
||||
|
||||
def test_timeout_error_reports_configured_value(self, monkeypatch):
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def raise_timeout(*args, **kwargs):
|
||||
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
|
||||
)
|
||||
result = ShellStep().execute(
|
||||
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "7 seconds" in result.error
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
|
||||
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
|
||||
"""execute() falls back to 300 when config skipped validation (#3327)."""
|
||||
import subprocess as sp
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
seen = {}
|
||||
real_run = sp.run
|
||||
|
||||
def spy_run(*args, **kwargs):
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return real_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
|
||||
)
|
||||
result = ShellStep().execute(
|
||||
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert seen["timeout"] == 300
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
|
||||
def test_validate_rejects_bad_timeout(self, bad):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout'" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_positive_int_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
assert (
|
||||
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
|
||||
)
|
||||
|
||||
|
||||
def test_output_format_json_exposes_data(self, tmp_path):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
Reference in New Issue
Block a user