mirror of
https://github.com/github/spec-kit.git
synced 2026-07-10 01:33:05 +08:00
Compare commits
3 Commits
update-doc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d075b27360 | ||
|
|
eedf73f714 | ||
|
|
292eaa6c98 |
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-07T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -854,10 +854,7 @@
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.5",
|
||||
"tools": [
|
||||
{
|
||||
"name": "python3",
|
||||
"required": false
|
||||
}
|
||||
{ "name": "python3", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
@@ -1111,8 +1108,8 @@
|
||||
"id": "docguard",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"author": "raccioly",
|
||||
"version": "0.31.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.31.0/spec-kit-docguard-v0.31.0.zip",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1148,7 +1145,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1667,31 +1664,12 @@
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "bash",
|
||||
"version": ">=4.4",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "curl",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "jq",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "gitleaks",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "trufflehog",
|
||||
"required": false
|
||||
}
|
||||
{ "name": "bash", "version": ">=4.4", "required": true },
|
||||
{ "name": "git", "required": true },
|
||||
{ "name": "curl", "required": true },
|
||||
{ "name": "jq", "required": true },
|
||||
{ "name": "gitleaks", "required": false },
|
||||
{ "name": "trufflehog", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
@@ -3886,14 +3864,8 @@
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "gh",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "python3",
|
||||
"required": true
|
||||
}
|
||||
{ "name": "gh", "required": true },
|
||||
{ "name": "python3", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
@@ -4187,27 +4159,11 @@
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "rtk",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "headroom",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "token-router",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "ollama",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "python",
|
||||
"version": ">=3.10",
|
||||
"required": false
|
||||
}
|
||||
{ "name": "rtk", "required": false },
|
||||
{ "name": "headroom", "required": false },
|
||||
{ "name": "token-router", "required": false },
|
||||
{ "name": "ollama", "required": false },
|
||||
{ "name": "python", "version": ">=3.10", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
|
||||
@@ -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