* chore: bump version to 0.16.4
* chore: begin 0.16.5.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(taskstoissues): widen task-ID regex to match IDs longer than 3 digits
/speckit.converge assigns new IDs with T{M+1:03d}, where :03d is a
floor not a cap, so IDs already exceed three digits once a tasks.md
passes 999 entries. The dedup regex `\bT\d{3}\b` cannot match those
titles because the trailing \b can't fall between two digits, so
affected tasks are silently skipped instead of deduped or created.
* fix(taskstoissues): use command placeholder for the converge reference
The literal `/speckit.converge` added to the dedup step is not rewritten
by the dot-to-hyphen pass, so every skills-mode integration emitted a
SKILL.md containing dot notation and 19 integration tests failed. Use the
`__SPECKIT_COMMAND_CONVERGE__` placeholder, which resolves to
`/speckit.converge` or `/speckit-converge` per the agent's separator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Return an actionable validation error when a workflow step type is a YAML list or mapping instead of raising during registry membership checks.
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* Harden community submission workflow outputs
Restrict extension and preset submission PRs to the expected catalog and docs files.
* test: check community allowlists pairwise
---------
Co-authored-by: root <kinsonnee@gmail.com>
* fix(integrations): dispatch goose commands via `goose run` (#2416)
`YamlIntegration` never overrode `build_exec_args()`, so `GooseIntegration`
inherited the `IntegrationBase` no-op that returns `None`. Callers read `None`
as "this CLI is unavailable", so every workflow command/prompt step targeting
Goose reported `CLI not found or not installed` even with `goose` on PATH.
Reproduced with the agent CLI present on PATH (shutil.which stubbed to a real
path, subprocess.run stubbed):
amp -> completed argv=['amp', '-p', '/speckit.specify']
opencode -> completed argv=['opencode', 'run', '--command', 'speckit.specify']
goose -> FAILED "integration 'goose' CLI not found or not installed"
Implement `build_exec_args()` for Goose. Per the goose CLI docs there is no
`-p` flag; the non-interactive entry point is `goose run`, which takes
`-t/--text` for free-form text, `--recipe` for a stored recipe,
`--params KEY=VALUE` for recipe parameters, plus `--model` and
`--output-format`. Spec Kit installs its commands as Goose *recipes* under
`.goose/recipes/`, each declaring an optional `args` parameter (already
enforced by test_setup_declares_args_parameter_for_args_prompt), so a
`/speckit.<name> <rest>` invocation maps exactly onto
`--recipe <path> --params args=<rest>`. This mirrors `OpencodeIntegration`,
which maps the same leading slash-command onto opencode's `--command`.
The recipe path is derived from the same two sources `setup()` uses --
`config["folder"]` + `config["commands_subdir"]` and `command_filename()` --
so the dispatch target cannot drift from the installed file; a test asserts the
resolved `--recipe` path exists after `setup()`. Dotted extension commands
(`speckit.git.commit`) round-trip. Extra args are applied before the canonical
flags so Spec Kit's selection stays authoritative, matching opencode.
No behaviour change for other integrations, and `requires_cli` is untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(goose): only map the speckit. namespace onto --recipe
build_exec_args() treated every prompt starting with "/" as a Spec Kit
recipe. Because command_filename() unconditionally re-adds the "speckit."
prefix, a free-form slash prompt was silently promoted into a recipe run
against a file that was never installed:
/help -> --recipe .goose/recipes/speckit.help.yaml
/plan the sprint -> --recipe .goose/recipes/speckit.plan.yaml
/speckit. -> --recipe .goose/recipes/speckit..yaml
PromptStep passes arbitrary prompt: strings to build_exec_args, and both
/help and /plan are Goose's own session commands, so this is reachable.
Unlike opencode's --command or hermes' -s, which hand a bare name to the
agent's own resolver, --recipe is a path Spec Kit synthesizes -- so only
the namespace it can actually spell may take that branch.
Gate the branch on "/speckit." and fall through to -t otherwise. A bare
"/speckit." leaves no stem and also falls through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(goose): stop asserting an argv that goose would reject
test_goose_extra_args_cannot_clobber_prompt_derived_recipe asserted that a
duplicated --recipe is merely reordered, on a "last value wins" premise.
That premise is wrong for goose: `goose run` is clap-derive based and
--recipe/--model/--output-format are single-value args without
args_override_self, so a duplicate makes goose exit with "cannot be used
multiple times" whichever side comes first. The test passed in pytest
while pinning a command line that cannot run.
Replace it with an ordering-parity test that asserts only what Spec Kit
actually controls: extra args precede the canonical flags (matching
opencode/codex/cursor-agent), and Spec Kit never emits a duplicate
single-value flag itself. Verified non-vacuous -- it fails if the
extra-args hook is moved after the canonical flags.
The ordering comment claimed precedence it cannot deliver; corrected to
state positional parity only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test-FileExists / Test-DirHasFiles report their line with Write-Output and
ALSO return $true/$false — both on the Success stream. The callers piped
the whole call to `| Out-Null` to discard the boolean, which discarded the
report line with it, so text mode printed the header and nothing under it:
BEFORE (measured, powershell.exe -NoProfile -File ... -IncludeTasks):
FEATURE_DIR:...\specs\001-f
AVAILABLE_DOCS:
(2 lines)
AFTER:
FEATURE_DIR:...\specs\001-f
AVAILABLE_DOCS:
[OK] research.md
[FAIL] data-model.md
[FAIL] contracts/
[FAIL] quickstart.md
[FAIL] tasks.md
(7 lines)
The bash and Python twins both list every document under that header, so
the PowerShell variant silently returned less information for the same
inputs.
Filter out only the boolean, keeping the report lines. Adds the first
PowerShell text-mode test in this file (every existing PS test is -Json).
File stays ASCII-only (verified 0 non-ASCII bytes).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remove exists() check before open() and catch FileNotFoundError directly.
This prevents a race where the file is deleted between check and open,
while preserving the descriptive error message.
Review follow-up: feeding PyYAML the byte stream let its Reader honour
a UTF-16 BOM and accept a manifest yamlio.load_yaml rejects, so zip and
directory sources diverged. Decode raw as UTF-8 (UnicodeError ->
BundlerError 'Could not read ...') then parse, and cover a well-formed
UTF-16 manifest in the regression tests.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: log progress tracker refresh errors instead of silently swallowing
The bare 'except Exception: pass' in StepTracker._maybe_refresh()
completely hid rendering bugs in the Rich progress display. Now logs
at DEBUG level with full traceback for diagnostics.
* test: add regression test for StepTracker refresh error logging
- Test that _maybe_refresh logs exceptions instead of silently swallowing
- Verify diagnostic message and traceback are recorded in DEBUG logs
- Confirm tracker update completes normally despite refresh callback failure
Requested by Copilot in PR #3975
* fix(bundler): read the authoritative default_integration field
`active_integration()` resolves a project's integration with
data.get("integration") or data.get("id") or data.get("active")
and never looks at `default_integration` — which is the key the CLI
actually writes. `integration_state.set_default_integration` persists
`data["default_integration"] = integration_key`, and the canonical
reader in that module orders it the other way round:
key = state.get("default_integration") or state.get("integration")
So a project initialised by any current version of the CLI looks to the
bundler as though it has no active integration:
{"default_integration": "copilot"} -> None (expected "copilot")
{"integration": "copilot"} -> "copilot" (legacy alias)
That silently changes bundler behaviour that keys off the active
integration, including the FR-019 clash guard, which treats an
undeterminable integration differently from a known one.
Read `default_integration` first and keep the three legacy aliases as
fallbacks for projects initialised by older versions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(bundler): correct the justification for reading default_integration
Review catch: the comment cited a nonexistent
`integration_state.set_default_integration` and overstated the impact.
The real writer is `write_integration_json`, which persists BOTH
`integration` and `default_integration` (integration_state.py:248-250), so
a marker produced by the current CLI already resolved through the
`integration` alias. Measured:
{"integration": "copilot", "default_integration": "copilot"} -> 'copilot'
{"default_integration": "copilot"} -> 'copilot' (after fix)
Reword both the source comment and the test docstring: this is about which
field is authoritative when they disagree, plus resolving a marker that
carries only `default_integration` — not about every current project being
undetectable. The precedence itself still has its precedent, the canonical
reader at integration_state.py:199.
Behaviour unchanged; comments and docstrings only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: add Mistral Vibe integration with Claude parity
- Add VibeIntegration class with ARGUMENT_HINTS, user-invocable, disable-model-invocation
- Add comprehensive test suite matching Claude integration
- Support all Spec Kit workflows (py/sh/ps script types)
* fix: address Vibe integration issues and test cleanup
- Fix Vibe to use .vibe/hooks.toml with toml-vibe format instead of
ignored .vibe/settings.json, adding toml-vibe event handler
- Remove unsupported argument-hint injection (Vibe schema doesn't support it)
- Restructure test file to inherit from SkillsIntegrationTests mixin
- Remove all unused imports to pass Ruff F401 checks
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
* fix: add name field to Vibe hooks and fix toml regex patterns
- Add required 'name' field for each Vibe hook in hooks.toml
- Fix regex patterns in _merge_vibe_toml_fragment and _remove_vibe_toml_entries
to correctly match [[hooks]] blocks instead of [} characters
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
* fix: align Vibe hooks with HookConfig schema and drop stray devcontainer lock
- use Vibe's 'match' field (re:-prefixed regex translation) instead of
unsupported 'matcher'; emit only on tool hooks (rejected on post_agent)
- limit CANONICAL_TO_NATIVE to Vibe's three hook types (pre_tool,
post_tool, post_agent); unsupported events skip with a warning
- deduplicate generated hook names (Vibe drops duplicates by name)
- add behavioral tests for toml-vibe generation, merging, and teardown
- remove accidentally committed .devcontainer/devcontainer-lock.json
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: wrap Vibe hook stdout in structured JSON response envelope
Vibe parses any non-empty hook stdout as a JSON HookStructuredResponse;
plain text is reported as a hook failure and its output dropped. Add a
hook_specific_output envelope to the dispatcher (template and runtime)
that emits {"decision": "allow", "hook_specific_output":
{"additional_context": ...}} and declare it for all Vibe events:
post_tool injects the context, pre_tool/post_agent parse cleanly and
ignore it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: quote Vibe hook commands for cmd.exe on Windows hosts
Vibe launches hooks via asyncio.create_subprocess_shell, which is
%COMSPEC% (cmd.exe) on Windows — POSIX single-quoting is not quoting
there, so an interpreter or dispatcher path containing spaces made every
hook fail to start. Add a 'cmd' quoting target to _shell_quote
(double-quote when needed, embedded quotes doubled per MSVCRT argv
rules), resolve it host-side like 'host', and select it for Vibe when
generating on a Windows host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pin POSIX quoting target in Vibe test for Windows CI runners
test_posix_host_keeps_shlex_quoting asserts host (shlex) quoting, but on
a Windows runner _vibe_target_os() resolves to 'cmd' and the command is
double-quoted. Monkeypatch the target so the test exercises the POSIX
path on every platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version to 0.16.3
* chore: begin 0.16.4.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: narrow bare except Exception in VS Code settings merge
Replace overly broad except Exception with (OSError, ValueError, KeyError)
to let programming errors like TypeError or AttributeError propagate while
still handling expected I/O and parse errors gracefully.
* test: verify programming errors propagate through handle_vscode_settings
The narrow exception change from 'except Exception' to
'except (OSError, ValueError, KeyError)' was not covered by a
regression test. Add a test that monkeypatches merge_json_files to
raise TypeError and verifies it propagates rather than being swallowed.
`specify preset list` now sorts installed presets by (priority, id) so the
printed order matches the actual resolution/composition order used by
PresetRegistry.list_by_priority(). Lower priority number = higher precedence;
ties are broken alphabetically by preset id. Adds a header and footer note
clarifying the ordering, updates the presets reference docs, and adds tests.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Fix: scaffold self-contained namespaced preset commands (#4076)
Preset command templates named `speckit.<ns>.<cmd>` were silently dropped
whenever `.specify/extensions/<ns>/` was absent, while `speckit.<cmd>`
always scaffolded. The `_extension_installed_for_command` guard filtered
purely on name shape, conflating "override of an installed extension's
command" with "a preset shipping its own namespaced command." Because a
`type: command` template always ships its own body, such a command is
self-contained and must scaffold like any short-named command.
Remove the name-shape guard at all four call sites (registration, both
reconciliation passes, and skills). The reconciliation loop already skips
names that resolve to no layers (`if not layers: continue`), and the
composed-None branch still cleans up commands whose base layer disappeared.
Convert the command-mode "no base layer to compose onto" hard error into a
warn + skip, matching the existing behavior in _reconcile_composed_commands
so command-mode install and reconciliation stay consistent.
Update the two tests that encoded the old drop behavior to assert the new
consistent-scaffold contract, and add coverage proving 2-part and 3-part
preset commands scaffold identically with no extension installed.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb
* Skip uncomposable commands in skills mode too (PR #4082 review)
When _register_commands skips an uncomposable composition command (a
wrap/prepend/append with no base layer to compose onto — e.g. the command
it wraps comes from an uninstalled extension), install still passed the
full manifest to _register_skills. For a command-backed integration in
skills mode, _register_skills created the missing skill and fell back to
the raw preset body because no `.composed` file existed, materializing a
broken SKILL.md — a literal `{CORE_TEMPLATE}` for wrap, or just the
preset's own fragment for prepend/append. Previously the raise in
_register_commands aborted before skills ran, so this never surfaced.
Make _register_skills apply the same skip: for a composition-strategy
command with no `.composed` file, resolve the stack and skip when no base
exists (resolve_content is None). The skip is silent because
_register_commands already warned for the same command in the same pass.
Add a regression test proving an uncomposable wrap command renders no skill
and never leaks a literal {CORE_TEMPLATE} in skills mode.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb
`resolve_install_plan`'s two FR-019 guards are a truthiness test and an
`is None` test:
if active_integration and required != active_integration: # clash
if active_integration is None and not integration_explicit: # indeterminate
An empty string satisfies neither, so it falls through to
`effective_integration = required` and the bundle's pinned integration is
silently adopted — the exact outcome the docstring says the guard prevents
("resolution fails instead of silently adopting the bundle's required
integration").
active=None -> BundlerError: ... could not be determined
active='' (blank) -> effective_integration='copilot' <-- silent adopt
active='claude' -> BundlerError: ... targets integration 'copilot'
Normalise a blank value to None before the guards, and strip first to
match the writer (`integration_state.clean_integration_key`, which returns
`None` for empty/whitespace and strips otherwise) so a padded value is not
reported as clashing with its own unpadded form.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Junie integration with dot-to-hyphen behavior, command formatting, and file transformations. Based on Cline Integration.
* Fix references to Cline in Junie integration and update class/test names for consistency.
* Fix references to Cline in Junie integration and update class/test names for consistency.
* Modified to generate correct formatting in junie
* fix: use bounded read for bundle download HTTP responses
The bundle download used unbounded resp.read() to read HTTP responses
into memory. A malicious or misconfigured catalog server could return
an arbitrarily large payload causing OOM.
Replace with read_response_limited() capped at MAX_DOWNLOAD_BYTES
(50 MiB), consistent with how other download paths in the codebase
enforce bounded reads.
Add regression test that monkeypatches MAX_DOWNLOAD_BYTES to 100 bytes
and verifies oversized responses are rejected.
* fix: remove duplicate import of MAX_DOWNLOAD_BYTES and read_response_limited
* fix(claude): make argument-hint injection fold-aware for long descriptions
ClaudeIntegration.inject_argument_hint spliced argument-hint: "..." as a
raw text line right after the first line starting with "description:".
When a description is long enough for the YAML dumper to fold it across
indented continuation lines, that splice landed inside the scalar,
producing invalid YAML (plain scalar) or silently absorbing the hint
into the description string (quoted scalar). This reproduces #3991 for
the case #3996 didn't cover: bundled core commands have no argument-hint
in their source frontmatter, so the structural apply_argument_hint path
is a no-op and this raw-text fallback is what actually runs.
Skip every continuation line of the description scalar (anything more
indented than the key itself) before inserting, so the new key always
lands after the whole scalar ends rather than in the middle of it.
Fixes#4044
* fix(claude): also skip unindented blank lines in description scalar
PyYAML serializes an embedded paragraph break ("\n\n") inside a quoted
description as unindented blank lines, not indented continuation
lines. inject_argument_hint only skipped indented lines, so it still
inserted argument-hint mid-scalar for multi-paragraph descriptions,
reproducing the #4044 failure modes. Skip blank lines too, and add a
regression test for the multi-paragraph case.
Catalog submission issues no longer auto-assign mnriem. The workflow now
only posts the team notification comment (cc @github/spec-kit-maintainers),
since GitHub issues cannot be assigned to a team. Renamed the workflow and
job to reflect its notification-only purpose.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63ae7e23-13e3-4a83-96b3-1178422aaa6e
Community extension, preset, and bundle submissions are validated by
label-triggered agentic workflows that only run once the corresponding
`*-submission` label is applied. On this public repo contributors cannot
apply that label themselves, so a maintainer applies it during issue
triage. Document this in the three submission issue templates, the preset
publishing guide, and correct the extension guide's inaccurate claim that
issues are "automatically labeled and assigned".
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3b6f6854-9be6-4b67-b5b9-34b19ededcb8
* chore: bump version to 0.16.2
* chore: begin 0.16.3.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* Add Command Code integration to spec-kit
Adds `command-code` as a built-in skills-based integration so Spec Kit can
be installed into Command Code. Command Code loads agent skills from
`.commandcode/skills/speckit-<name>/SKILL.md` and invokes them in chat as
`$speckit-<command>`.
- New `CommandCodeIntegration` (SkillsIntegration) writing to
`.commandcode/skills/`; declared multi-install safe (static, isolated
agent root).
- Register in `_register_builtins()` and the integration catalog.
- Add `command-code` to `DOLLAR_SKILLS_AGENTS` so next-steps guidance
renders `$speckit-*` invocations.
- Tests: reuse `SkillsIntegrationTests` mixin plus a dollar-invocation
next-steps test; registry completeness updated.
- Docs: README and docs/reference/integrations.md (supported agents +
multi-install-safe table).
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Assisted-by: Command Code (autonomous)
* Fix issue template agent lists to include command-code
The runtime AGENT_CONFIG now includes command-code, but the GitHub issue
templates and the consistency test's expected key list were not updated,
failing test_issue_template_agent_lists_match_runtime_integrations.
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Assisted-by: Command Code (autonomous)
---------
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
evaluate_condition() special-cases the strings "false"/"true" so that
`condition: "false"` behaves as a boolean, but it matches with
`result.lower()` and never strips.
The most common way a *string* reaches a condition is captured command
output, and the shell step stores stdout verbatim
(steps/shell/__init__.py:67 `"stdout": proc.stdout`). So `run: echo false`
resolves to "false\n", which matches neither branch and falls through to
`bool("false\n")` -> True:
'false' -> False
'false\n' -> True <-- bug
'false\r\n' -> True <-- bug
' false' -> True <-- bug
An `if` step therefore takes its `then` branch on a step that printed
"false", and `while`/`do-while` keep dispatching their body.
A workflow author cannot work around it: the registered filters are
default/join/map/contains/from_json — there is no `trim`.
`InitStep._resolve_bool` and both catalog readers already strip before
matching boolean text. `bool(result)` still sees the raw string, so no
non-boolean text changes truthiness.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_parse_edit` reads `operation` straight from hand-edited YAML and then
does `if operation not in VALID_OPERATIONS`. `VALID_OPERATIONS` is a
frozenset, so that membership test hashes the value — and an unhashable
one raises:
operation={'insert_after': 'a'} -> TypeError: unhashable type: 'dict'
operation=['insert_after'] -> TypeError: unhashable type: 'list'
`validate_overlay_yaml`'s docstring promises "validation never raises",
and nothing upstream catches TypeError (layer_sources wraps only
YAMLError/OSError/UnicodeDecodeError; _commands catches only ValueError),
so the CLI dies with a raw traceback instead of reporting the error.
The trigger is an ordinary authoring mistake: nesting the recommended
shorthand form under the explicit key.
Every other field in the same function is isinstance-guarded first
(`anchor`, `step`, `step["id"]`); `operation` was the outlier. Check the
type first and return the message the function already uses for
`operation: None` / `operation: 7`.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: provision Python test deps for bug-test workflow
* test: anchor bug-test workflow domain assertions
Address CodeQL py/incomplete-url-substring-sanitization alerts (14-17)
by anchoring the PyPI domain assertions to their structural context:
the `network.allowed` YAML list items in the source and the quoted JSON
entries in the compiled lock. This defeats the incomplete-URL-substring
pattern and strengthens the test to confirm the domains are real
allowlist entries rather than incidental substrings.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
* fix: provision test deps without creating a project lock
Replace `uv sync --extra test` with `uv pip install --system -e ".[test]"`
in the bug-test provisioning step.
`uv sync` writes a root `uv.lock` (and `.venv`) into the working tree.
This repository intentionally has no `uv.lock`/`[tool.uv]` (uv.lock is
gitignored), so the sync produced an untracked lockfile before the agent
checks out the fix ref in Step 2. `uv pip install` installs the test
extra into the runner's Python without generating a project lock, keeping
the working tree clean before the fix checkout. The editable install
means the agent's `python3 -m pytest` runs against the checked-out fix
code. Recompiled the lock and updated the assertions accordingly.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
* chore(workflows): sync gh-aw action-pin metadata to latest across all workflows
Dependabot bumps the third-party action `uses:` pins (and header comments)
directly, but does not update gh-aw's own metadata: the per-file
`gh-aw-manifest` JSON blob and the shared `.github/aw/actions-lock.json`
pin cache. As a result the executing pins were already uniform and current
(checkout v7.0.1, setup-node v7.0.0) while the manifest/cache metadata still
recorded checkout v6.0.3 / setup-node v6.4.0.
This is a latent downgrade hazard: a plain `gh aw compile` reads the stale
cache and can silently revert the `uses:` lines back to the older pins,
undoing Dependabot's bumps and breaking lockstep.
Sync all four pin surfaces (uses / header comment / manifest / cache) to the
current pins so every workflow agrees and a future recompile is a no-op:
- actions-lock.json: checkout v6.0.3 -> v7.0.1, setup-node v6.4.0 -> v7.0.0,
and add the setup-python v7.0.0 + setup-uv v9.0.0 entries now used by
bug-test.
- gh-aw-manifest blobs in the 5 non-bug-test lock files: checkout + setup-node
bumped to match their own uses lines (bug-test was already current).
No workflow body changes; only pin metadata. `uses:` pins are unchanged.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
---------
Co-authored-by: root <kinsonnee@gmail.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
* fix(bundle): escape Rich markup in bundle CLI error and status output
`specify bundle`'s `_fail` helper interpolated its message straight into
`err_console.print`, which has Rich markup enabled. Every caller passes
`str(exc)` from a `BundlerError`, and those messages embed untrusted data
-- including the command's own argument -- so a `[...]` in it was parsed
as a style tag.
Balanced tags were silently swallowed; an unbalanced closer raised
`MarkupError`, which replaced the error message with a traceback and left
the output completely empty. Three commands crashed on user input alone,
with no project state required:
specify bundle catalog add 'ssh://ex[/red]ample.com/c.json'
specify bundle catalog remove 'no[/red]such'
specify bundle update 'no[/red]such'
`bundle validate` had the same failure on both branches: its errors echo
`requires.speckit_version`, and its warnings echo component ids, which are
not charset-validated -- so a structurally *valid* manifest crashed on the
success path too.
Fixed centrally in `_fail`, plus the remaining raw interpolations: the
`validate` warning/error/success lines, the install overlap and plan
warnings, the install/update/remove/catalog-add confirmations, the
`catalog list` id/url, and the `bundle init` project path.
Regression tests cover the four crashing error paths (parametrized) and
both `validate` branches; all six fail without this change.
Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundle): escape markup in `bundle list` records and `bundle build` output path
Review follow-up: two raw interpolations the first sweep missed, both on
success paths rather than error paths.
`bundle_list` rendered `record.bundle_id`, `record.version` and
`record.installed_at` unescaped. `InstalledBundleRecord.from_dict` only
requires non-empty strings for the first two and applies no charset check to
any of them, so a records file that *loads cleanly* still crashed the command
that displays it — confirmed as
`MarkupError: closing tag '[/red]' at position 12 doesn't match any open tag`.
`bundle_build` echoed `result.artifact_path` twice in its success line.
Brackets are legal in a directory name, so a bracketed `--output` built the
artifact and then misreported it: the work is already on disk when the
markup is consumed, so the line names a path that does not exist.
Re-scanned every `{...}` interpolation in the module to confirm nothing else
remains: the rest are either `BundlerError` messages that funnel through the
already-escaped `_fail`, `_format_component` output escaped at its call site
(:293), ints, or hardcoded enum `.value`s.
Two regression tests. The list case uses the unbalanced-closer form that
raises outright. The build case deliberately uses `[bold]` instead: `/` is a
path separator on Windows, so `dist[/red]out` becomes the directory
`dist[\red]out` and the fixture stops testing what it claims — the
silent-swallow form keeps it portable while still asserting the reported path
matches what was written. Verified both fail against 1d2184d.
tests/contract/test_bundle_cli.py -> 42 passed. tests/contract
tests/integration tests/unit -> 364 passed, 6 skipped, 5 failed; the 5 are
the pre-existing `*_refuses_symlinked_*` tests needing symlink privileges on
Windows, unchanged from main. ruff check passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(presets): skip an unreadable restore source in `preset remove`
`_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core
command template or an extension source. Both of those reads were bare
`read_text(encoding="utf-8")` calls, so a project-owned override in
`.specify/templates/commands/` that exists but cannot be read or decoded
raised a raw `UnicodeDecodeError`/`OSError` straight out of
`PresetManager.remove()`, which has no handler for it — `specify preset
remove` dies with a traceback.
Every other failure in this loop degrades with `continue`: an unsafe
registry name, a missing skill subdirectory, a foreign owner. Sibling reads
of the very same directory are already guarded — `_infer_legacy_skill_
provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read
in `except (OSError, UnicodeDecodeError): continue`, and the read inside
`_substitute_core_template` was just given the same boundary in #3961. The
two restore reads were the remaining gap.
`continue` is the right recovery here rather than falling through: the
`else` branch below removes the skill outright, so treating an unreadable
source as "no source" would delete a user's skill at exactly the moment its
replacement cannot be generated. Skipping leaves the skill in place and
keeps it out of the returned `mutated_names`, so callers don't record a
restore that never happened.
Two regression tests, one per exception arm: a non-UTF-8 core template, and
a mocked `PermissionError` so the `OSError` half is also covered under
privileged CI where permission bits aren't enforced. Both assert the skill
survives untouched and is not reported as mutated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(presets): warn when a skill keeps preset content after a failed restore
Review follow-up on two points.
Surface the skipped restore. Skipping is still the correct recovery — the
alternative branch deletes the skill — but it was silent, and it is a
partial removal: `remove()` goes on to delete the preset directory and the
registry entry, while this `SKILL.md` keeps the removed preset's content,
and leaving the name out of `mutated_names` also keeps it out of
reconciliation, so nothing retries it. Both arms now emit a warning naming
the skill, the unreadable source, and the exception, and pointing at the
re-run that refreshes it once the file is fixed. `warnings.warn` matches
how the surrounding code reports non-fatal degradation (the reconciliation
failures in `remove()`/`install_from_directory`, the unreadable core
template in `_substitute_core_template` from #3961).
Cover the extension arm. A skill backed by an installed extension never
reaches the core-template read, so the two branches can regress
independently and both prior tests exercised only the core one.
`test_unregister_skills_in_dir_unreadable_extension_source_skips` installs
an extension whose command file is non-UTF-8 and asserts the skill survives
byte-for-byte and is absent from `mutated_names`. Verified it raises the
raw `UnicodeDecodeError` against unpatched source. The two existing tests
now assert the warning via `pytest.warns` so dropping it fails the suite.
pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are
the pre-existing Windows symlink tests that need elevation, unchanged from
main. ruff check passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture and display the exception message when reading preset-catalogs.yml
fails, instead of swallowing the error details. Matches the pattern used
in preset_catalog_add 54 lines earlier.
Replace check-then-act pattern (exists()+unlink()) with unlink(missing_ok=True)
to eliminate TOCTOU race condition. Matches the pattern already used for
per-URL cache files in the same method.
The resolver returns the first entry matching a declared name, so a
later duplicate within provides.templates or provides.scripts was
silently unreachable while still counted by ExtensionManifest
properties. Reject duplicates at manifest-validation time instead.
Also clarify EXTENSION-DEVELOPMENT-GUIDE.md's provides section: hooks
and events are top-level manifest fields, not provides sub-fields, so
the "at least one of ..." wording doesn't imply they can be nested
under provides.
* feat(presets): resolve constitutions at command time
Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
* fix(presets): emit composed template content
Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
* fix(presets): unify runtime template composition
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
* fix(presets): secure runtime template resolution
Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(presets): align runtime priority semantics
Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(presets): stop at effective template base
Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(presets): align extension template resolution
Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(presets): resolve dotted command identifiers
Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Avoid orphan feature directories on template errors
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Align malformed preset manifest handling
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* Complete runtime resolver parity
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* Fail closed on resolver input errors
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* Force UTF-8 and full manifest validation
Force UTF-8 decoding for registry and manifest reads in the Bash and
PowerShell embedded-Python parsers so resolution no longer depends on the
process locale, and validate every manifest template entry's required
fields, type, and strategy consistent with the canonical PresetManifest.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* Fail closed on empty manifests and corrupt registries
Reject manifests missing the provides/templates sections or declaring an
empty template list in all three runtime resolvers, matching the canonical
PresetManifest which treats those as invalid instead of silently degrading a
composing layer to a convention `replace` lookup.
Make a corrupt or unreadable extension registry fail closed in Bash,
PowerShell, and Python instead of swallowing the error and treating every
on-disk extension directory as unregistered-and-enabled, which could activate
a disabled extension.
Read the preset and extension registries as explicit UTF-8 in the PowerShell
resolver so priority/enabled-state decoding no longer depends on the process
code page under Windows PowerShell 5.1.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* fix(presets): fail closed when extension registry is not a regular file
The Bash and Python resolvers used is_file()/`-f` to gate reading the
extension `.registry`, which returns false for a directory or a broken
symlink at that path. In those cases the resolvers treated the registry
as absent and scanned every on-disk extension directory as unregistered
and enabled — a fail-open path. Detect any filesystem entry at the
registry path (including broken symlinks) and reject unless it is a
readable regular file. PowerShell now rejects a non-leaf entry explicitly
for parity. Adds directory- and broken-symlink parity regressions.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell
Two remaining fail-open paths for an invalid extension registry:
- The canonical PresetResolver enumerated extensions through
ExtensionRegistry, whose _load() normalizes a corrupt or unreadable
registry to an empty mapping. The directory scan then admitted every
on-disk extension directory as unregistered-and-enabled, so a corrupt
registry could still supply constitution content at init and through
constitution-sync materialization. Add a non-invasive is_corrupt()
probe (recovery behavior for install/enable/disable is unchanged) and
raise from _get_all_extensions_by_priority() when the registry exists
but is invalid. _load() now also recovers from OSError/UnicodeDecodeError
so a directory or unreadable registry no longer crashes construction.
- The PowerShell resolver gated the registry read with Test-Path, which
returns false for a dangling symlink on Windows, letting a broken
.registry symlink bypass the guard and enable every on-disk extension.
Detect the entry via directory enumeration (which observes a broken
symlink) and reject it unless it is a readable regular file.
Adds canonical corrupt/directory-registry regressions and extends the
broken-symlink parity test to PowerShell.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt
is_corrupt() gated on Path.exists(), which follows symlinks and returns
False for a dangling .registry symlink — so the canonical PresetResolver
treated it as an absent registry and fell back to scanning every on-disk
extension directory as unregistered-and-enabled, reopening the fail-open
path this guard closes. Detect lexical existence with os.path.lexists and
require a regular file before parsing, so a broken symlink (or directory)
is reported corrupt and resolution fails closed. Adds a canonical
broken-symlink regression alongside the directory case.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
* Fix preset-wrap-drops-argument-hint: inherit argument-hint from core
Apply the remediation from the bug assessment on issue #3991.
Extend the inheritance allowlist in _register_skills and _compose_layers
to include 'argument-hint', so wrap-strategy presets that omit this key
will inherit it from the core template rather than silently dropping it
and risking its value being leaked into description.
Refs #3991
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test(presets): guard wrap argument-hint inheritance for unmapped command
The existing regression test for #3991 wraps `speckit.specify`, whose stem
is in Claude's ARGUMENT_HINTS map. The string-injection fallback in
post_process_skill_content re-adds argument-hint even when wrap composition
drops it, so that test passes with or without the inheritance fix and does
not actually guard the regression.
Add a parallel test that wraps an extension-like command
(`speckit.myfeature`) absent from ARGUMENT_HINTS, so the wrap-composition
inheritance is the only path that can carry argument-hint into the SKILL.md.
This test fails without the fix and passes with it.
Refs #3991
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce
* docs: document installing specify-cli from a custom package index
Add a generic section to the PyPI install guide covering how to point
uv, pipx, and pip at a non-default package index (env var and flags),
with a placeholder URL, plus notes on pins/upgrades and authentication.
Link to it from the main installation guide.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad
* feat(extensions): accept provides.templates and provides.scripts in manifest
Extensions could only formally declare commands under `provides`
(plus config/hooks/events); templates and scripts shipped by an
extension were picked up purely by filename convention, with no id,
description, or metadata. Add optional `provides.templates` and
`provides.scripts` sections to the extension manifest schema, mirroring
the preset template shape minus an authorable `strategy` (extension
artifacts always resolve as replace, so a present `strategy` key is
now a validation error rather than a silently accepted no-op).
ExtensionManifest gains `templates`/`scripts` properties so tooling can
enumerate an extension's declared artifacts directly from the
manifest. An extension may now satisfy the "must provide something"
rule with only a template or script, not just a command/hook/event.
Addresses the manifest-schema portion of #4010; resolver
authoritative-vs-convention precedence for these new sections is left
for a follow-up.
* fix(presets): wire extension-declared templates/scripts into resolver
collect_all_layers only consulted ExtensionManifest for command
resolution, leaving provides.templates/.scripts purely decorative --
a declared entry whose file didn't sit at the conventional path was
validated but never resolved. Extend the existing manifest-fallback
branch to cover template_type "template" and "script" the same way
it already does "command": convention lookup first, manifest lookup
as fallback so undeclared on-disk files keep resolving unchanged.
* fix(presets): make extension manifest lookup authoritative over convention
Copilot review on #4012 found the manifest-declared template/script lookup
was gated on convention lookup missing first, so a stale conventional file
could shadow a declared entry at a non-conventional path, and resolve()
never consulted the manifest at all (only collect_all_layers() did). Add a
shared _extension_manifest_declared_template() helper and check it before
convention-based lookup in both resolve() and collect_all_layers(), mirroring
the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md,
which still claimed provides only supports commands and required a command
or hook.
* fix(presets): stop resolving symlinks in extension manifest candidate path
_extension_manifest_declared_template() resolved ext_dir/rel_path before
returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's
symlinked tmp dir) and diverges from the unresolved paths convention-based
lookup returns for the same directory. Resolve only for the traversal
containment check; return the unresolved candidate.
Fixes the 4 CI test failures across all OS/Python matrix jobs on #4012.
* fix(presets): treat an unreadable core template as missing
_substitute_core_template() read the resolved core template with a bare
read_text(), so one corrupted project-owned override in
.specify/templates/commands/ crashed the whole wrap-strategy command
registration with a raw UnicodeDecodeError. Both callers
(CommandRegistrar.register_pack and _register_commands) are unguarded
here, even though register_pack already skips an unreadable preset
source with a warning a few lines above the call.
Treat an unreadable core template like a missing one — warn and return
the body unchanged with empty frontmatter — matching the function's
documented no-core contract.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: assert the unreadable-core warning instead of suppressing it
Review follow-up: use pytest.warns so removing or changing the promised
warning fails the test.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: bump version to 0.16.1
* chore: begin 0.16.2.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(integrations): wrap a non-UTF-8 catalog response
`_fetch_single_catalog` decodes the response body with `.decode("utf-8")`
before handing it to `json.loads`. A non-UTF-8 body therefore raises
`UnicodeDecodeError`, which is a sibling of `json.JSONDecodeError` under
`ValueError` rather than a subclass of it, so neither the `URLError` nor the
`JSONDecodeError` handler catches it.
The raw exception escapes `_get_merged_integrations`, whose
`except IntegrationCatalogError` is specifically designed to warn and skip a
bad catalog and carry on with the remaining ones. One catalog served over a
misconfigured proxy or truncated mid-multibyte-sequence thus takes down
`specify integration search` entirely instead of degrading to a warning.
Wrap it in `IntegrationCatalogError`, matching the convention already used
for the same decode in `authentication/azure_devops.py`, which lists
`UnicodeDecodeError` alongside `JSONDecodeError`.
Note that the cache-read path in this same method already tolerates this via
its `UnicodeError` clause; only the network path was unguarded.
Two regression tests: one pins the wrapped-error contract on the fetch, and
one covers the behaviour that actually motivates it — a broken catalog is
skipped with a warning while a healthy sibling catalog still resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(integrations): use the shared urlopen routing fixture
The raw-bytes helper patched `open_url` wholesale, which skipped the real
URL validation and redirect handling inside it. This module already imports
`route_opener_open_through_urlopen`, the repo's shared fixture that routes
`build_opener().open()` back through `urlopen` for exactly this reason, so
patching `urlopen` instead keeps the stub effective while still exercising
`open_url` itself.
Renamed to `_patch_urlopen_bytes` to sit alongside the existing
`_patch_urlopen`, whose signature it now mirrors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(integrations): restore the non-UTF-8 handler
The previous commit reverted the source change by accident while reworking
the tests, leaving the regression tests passing against an unfixed module.
Restores the `except UnicodeDecodeError` clause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(events): skip an unreadable command template
_render_command_template() read the resolved template with a bare
read_text(), so a template file that exists but cannot be read or
decoded (permission error, non-UTF-8 bytes) crashed event dispatch with
a raw OSError/UnicodeDecodeError. Every sibling failure in this path
(missing template, unresolvable command) already returns None so the
dispatcher falls back cleanly.
Wrap the read and return None on OSError/UnicodeDecodeError, matching
the sibling contract.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: cover the OSError half of the unreadable-template boundary
Review follow-up: add a mocked PermissionError case so both promised
exception paths are protected under privileged CI.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`_local_manifest_source` handles three local bundle sources. The directory
and `bundle.yml` branches both go through `BundleManifest.from_file` ->
`load_yaml`, which converts a parse failure into a `BundlerError`. The `.zip`
branch instead parses inline with a bare `_yaml.safe_load`.
`yaml.YAMLError` derives directly from `Exception` -- it is neither a
`ValueError` nor an `OSError` -- so it escapes `bundle_install`'s
`except BundlerError` and reaches the user as a raw
`yaml.parser.ParserError` traceback.
The remote counterpart of this same call, `_download_manifest`, already
guards it and even names `_yaml.YAMLError` explicitly. Only the local zip
path was missed, so the same corrupt manifest is reported cleanly when
fetched from a catalog but crashes when installed from disk.
Before, for the identical malformed bundle.yml:
specify bundle install ./bundle-dir -> Error: Invalid YAML in ... (exit 1)
specify bundle install ./bundle.yml -> Error: Invalid YAML in ... (exit 1)
specify bundle install ./bundle.zip -> ParserError traceback
Two regression tests: one pins the `BundlerError` contract on the zip
branch, and one drives all three local sources through the CLI to assert
they now fail alike.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-context): recurse for nested plans in Python mtime fallback
The Python port's mtime fallback discovered plans with a one-level
specs/*/plan.md glob, so a scoped layout created via
SPECIFY_FEATURE_DIRECTORY (specs/<scope>/<feature>/plan.md) was missed when
feature.json is absent — the fallback returned no plan and the managed
context section omitted the 'at <plan>' line. The bash and PowerShell twins
were already fixed to recurse (#3024); the Python twin was left behind.
Switch to specs.rglob('plan.md') with the same symlink-safe containment check
the bash twin uses (resolve each candidate and confirm it stays within the
project root before ranking by mtime), so a plan reached through a specs/
symlink pointing outside the project is not selected.
Adds parity regression tests (vs bash and vs PowerShell) covering a nested
specs/<scope>/<feature>/plan.md; both fail on the pre-fix one-level glob.
Fixes#3733
* test(agent-context): cover symlink containment in the mtime fallback
The recursive fallback resolves each candidate before the relative_to()
containment check, but nothing exercised that path. Add a parity test for a
plan reachable only through a specs/ symlink pointing outside the project:
relative_to() is lexical and would accept it, emitting an in-project-looking
path for an out-of-project file. Both the bash twin and the Python port skip
it, so the "at <plan>" line is omitted.
Also correct the module docstring, which still described the fallback as
scanning specs/*/plan.md one level deep.
Both extension and preset registry read/write calls used platform-default
encoding, which on Windows (cp1252/UTF-16) would corrupt UTF-8 JSON data
or raise UnicodeDecodeError. Explicitly specify encoding='utf-8' to match
the JSON contract.
Assisted-by: opencode (autonomous)
* fix(init): escape user-supplied values in `specify init` output
commands/init.py interpolated the project name, --integration/--script values
and paths straight into Rich markup f-strings. It was the only CLI command
module without escaping -- extensions, presets, workflows and integrations all
wrap user-controlled display values already.
Two consequences, both reproduced end-to-end through the real CLI:
1. SILENT WRONG OUTPUT. `specify init "proj [v2]"` exits 0 and creates the
directory, but the Next Steps panel prints
1. Go to the project folder: cd proj
Rich ate `[v2]` as a style tag, so the command the user copy-pastes fails.
2. CRASH AFTER SUCCESS. `specify init "app[/red]x"` creates the project and
then dies with MarkupError("closing tag '[/red]' ... doesn't match any open
tag") -> exit 1 with a traceback for work that actually completed.
Wrap the user-controlled display values in rich.markup.escape: project name
(error/warning/conflict/next-steps), project and working paths, the echoed
--integration and --script values, and the agent folder in the gitignore hint.
Display only -- no control flow, exit codes or messages change, and escape is a
no-op for any value without a tag-shaped bracket run.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(init): shell-quote the project name in the Next Steps cd line
Rich-escaping stopped the brackets being swallowed, but the printed
command was still unusable for any name containing whitespace: `cd proj
v2` is two arguments in every shell.
$ cd proj v2 -> /bin/bash: line 1: cd: too many arguments (rc=1)
$ cd "proj v2" -> rc=0, lands in "proj v2"
Quote it for the host the same way _version._render_argv renders its
copy-pasteable installer command: subprocess.list2cmdline on Windows,
shlex.quote elsewhere. Windows must use double quotes -- cd 'my project'
is a path-not-found in cmd.exe, while cd "my project" is accepted by
cmd.exe, PowerShell and Git Bash alike. Names needing no quoting are
returned unchanged, so the common case is byte-identical.
Shell-quote inner, Rich-escape outer.
Tests execute the printed command through a real shell rather than only
inspecting the string, and pin that an ordinary name stays unquoted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(init): drop the now-redundant local escape imports that shadowed the module one
Rebasing onto main brought in three new extension-install helpers, and two
of them carry a function-local
from rich.markup import escape as _escape_markup
inside `register > init`. This PR adds the same import at module level, so
the locals made `_escape_markup` a local variable for the whole `init`
function — every use *before* those import lines then raised
UnboundLocalError: cannot access local variable '_escape_markup'
where it is not associated with a value
which broke `specify init` outright (7 of 8 tests in this file failed after
the rebase, all with exit_code 1).
The locals are redundant now that the module-level import exists, so remove
them. Verified with an AST scope walk that the only remaining
`_escape_markup` imports are the module-level one and the one inside
`_confirm_extension_url_trust`, which has no module-level use to shadow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(agent-context): recurse for nested plans in Python mtime fallback
The Python port's mtime fallback discovered plans with a one-level
specs/*/plan.md glob, so a scoped layout created via
SPECIFY_FEATURE_DIRECTORY (specs/<scope>/<feature>/plan.md) was missed when
feature.json is absent — the fallback returned no plan and the managed
context section omitted the 'at <plan>' line. The bash and PowerShell twins
were already fixed to recurse (#3024); the Python twin was left behind.
Switch to specs.rglob('plan.md') with the same symlink-safe containment check
the bash twin uses (resolve each candidate and confirm it stays within the
project root before ranking by mtime), so a plan reached through a specs/
symlink pointing outside the project is not selected.
Adds parity regression tests (vs bash and vs PowerShell) covering a nested
specs/<scope>/<feature>/plan.md; both fail on the pre-fix one-level glob.
Fixes#3733
* test(agent-context): cover symlink containment in the mtime fallback
The recursive fallback resolves each candidate before the relative_to()
containment check, but nothing exercised that path. Add a parity test for a
plan reachable only through a specs/ symlink pointing outside the project:
relative_to() is lexical and would accept it, emitting an in-project-looking
path for an out-of-project file. Both the bash twin and the Python port skip
it, so the "at <plan>" line is omitted.
Also correct the module docstring, which still described the fallback as
scanning specs/*/plan.md one level deep.
* fix: use missing_ok for temp file cleanup to avoid masking errors
`workflow status <run_id>` and `workflow resume <run_id>` both call
`RunState.load()`, and a prior fix aligned them on the FileNotFoundError
and ValueError boundaries. `resume` also handles OSError; `status` never
gained that handler.
So an unreadable `state.json` -- wrong permissions, an I/O error, or a
directory sitting where the file belongs -- escapes as a raw traceback
with no output at all, while `resume` on the same run prints a clean
`Error:` line and exits 1. `state_path.exists()` is True for a directory,
so the existing guard passes and `open()` raises.
Add the missing `except OSError` next to its siblings, using the same
`_escape_markup` + `typer.Exit(1)` shape, and routing through `err` so
the message lands on stderr under `--json` and the stdout JSON stream
stays parseable.
Two regression tests: the end-to-end CLI path (a directory in place of
state.json) and the `--json` stderr-routing path. Both fail without the
source change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(init): scaffold managed .specify/.gitignore
Write a manifest-tracked `.specify/.gitignore` during shared-infra install
so machine-local Spec Kit state stays out of version control while everything
else under `.specify/` remains shareable:
- `feature.json` — the current-feature pointer, rewritten on every feature
switch (per-checkout state, not something to share).
- `extensions/*/local-config.yml` — per-machine extension config overrides.
The file is routed through the same overwrite/skip/preserve policy as shared
templates: `--force` refreshes it, user edits are preserved on re-init, and
uninstall removes it via the manifest. Addresses github/spec-kit#2304.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
* docs: correct .specify/.gitignore uninstall claim
The file is tracked in the shared-infra manifest (speckit.manifest.json),
not the per-integration manifest that `specify integration uninstall` loads.
Shared infrastructure is deliberately preserved on uninstall
(see test_uninstall_preserves_shared_infra), so `.specify/.gitignore` is
left in place rather than removed. Reword the code comment and core.md note
to state the actual behavior; keep the true benefits (force-refresh and
preserve-on-edit).
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
* revert: drop manual CHANGELOG.md edit
CHANGELOG.md is auto-generated; do not hand-edit it.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
* test: add .specify/.gitignore to integration file inventories
The complete-file-inventory tests assert an exact match of every file
produced by `specify init`. Now that shared infra scaffolds a managed
`.specify/.gitignore`, add it to the expected inventories so the exact-match
assertions pass on both sh and ps script types.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
* fix(scripts): stop check-prerequisites text mode crashing on a legacy code page
_check_file/_check_dir hard-code U+2713/U+2717 and print() them to
sys.stdout. On Windows sys.stdout falls back to the ANSI code page whenever
stdout is not a console — which is every time an agent or a workflow step
captures the output — and U+2713 is unencodable in cp1252:
stdout encoding: cp1252
UnicodeEncodeError: 'charmap' codec can't encode character '✓'
So text mode aborted right after printing "AVAILABLE_DOCS:", losing every
per-document line.
Fall back to ASCII when stdout cannot encode the glyph. "[OK]"/"[FAIL]" is
the rendering these markers already have in-tree: Test-FileExists in
scripts/powershell/common.ps1 emits exactly those, and
normalize_status_text in tests/parity_helpers.py maps the glyphs onto them,
so the twins already treat the two forms as equivalent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(scripts): cover both status markers in the cp1252 regression
Review catch: the fixture left every reported document absent (the empty
contracts/ also reports missing), so the test only ever called
_status_marker(False). The assertion was `"[OK]" in out or "[FAIL]" in out`,
which "[FAIL]" alone satisfied.
Proved the hole by mutation: replacing the fallback body with a bare
`return "[FAIL]"` — deleting the success branch outright — left the test
GREEN.
Add research.md so one document is present, and assert both markers
explicitly. The strengthened test now kills all three mutations:
fallback always "[FAIL]" -> FAILS (was passing)
fallback always "[OK]" -> FAILS
no fallback at all -> FAILS (the original bug)
unmutated -> 12 passed, 8 skipped
Missing documents are still present in the fixture, so the failure path
stays covered too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): restore the _status_marker ASCII fallback
The previous commit on this branch unintentionally reverted the source fix
while adding the strengthened test, so the branch carried the test without
the implementation it tests.
Cause: my local verification script reverted the file for its red run with
`git checkout upstream/main -- <file>`, which writes the INDEX as well as
the working tree. Restoring the working-tree copy afterwards left main's
version staged, and the next commit captured it.
Restores the fix from 275663b. Verified: 12 passed / 8 skipped, and the red
run (source reverted) produces 1 new-vs-baseline failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(presets): return None for an unreadable layer in resolve_content
PresetResolver.resolve_content() reads the winning layer (and each
composition layer) with a bare read_text(), so a layer file that cannot
be read or decoded crashed command registration with a raw
OSError/UnicodeDecodeError. The docstring already promises 'Composed
content string, or None if not found', and since #3896
collect_all_layers() deliberately tolerates a non-UTF-8 legacy layer —
moving the crash here, where both callers (_register_commands and
_reconcile_composed_commands) are unguarded.
Return None when the winning or base layer cannot be read, treating an
unreadable layer like a missing one per the documented contract.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: cover the base guard and composing-layer read
Review follow-up: add an unreadable replace base beneath a valid
composing layer, and a mocked-PermissionError composing layer over a
valid base, so every new boundary and both exception types are covered.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ExtensionRegistry._load() catches json.JSONDecodeError and
FileNotFoundError to start fresh on a corrupted or missing registry, but
a .registry file with invalid UTF-8 bytes raised UnicodeDecodeError from
the text-mode read before JSON parsing began. Because the registry is
loaded in __init__, that bare traceback broke every extension command --
`specify extension list` on such a project exits with a raw
UnicodeDecodeError instead of the module's clean path.
Catch UnicodeDecodeError in the same clause: undecodable bytes are the
same corruption class as unparseable JSON, only the exception type
differs. OSError stays uncaught on purpose -- the data may be intact on
disk, and starting fresh would let a later _save() wipe it.
This is the exact twin of the PresetRegistry._load() fix in #3955; the
two registries are parallel implementations and only the preset side was
corrected. _get_installed_sibling_ids() already worked around this gap
locally by catching UnicodeError at its own call site; its comment is
updated to reflect that _load() now handles the case itself, with the
local catch kept as belt-and-braces against regression.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply the remediation from the bug assessment on issue #3990.
After integration setup() and manifest.save(), when --force is used
(re-initializing an existing project), call _register_presets_for_agent
and _register_extensions_for_agent so that previously-installed presets
and extensions are recomposed on top of the freshly-regenerated core
files. Without this, preset-composed files reverted to pure core while
the preset registry continued to report them as installed.
This mirrors the same pattern already present in integration_upgrade()
(added in PR #3853 / issue #3849 for the upgrade path).
Refs #3990
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hermes overrides SkillsIntegration.setup() with its own copy of the
frontmatter parse and body strip, and Kimi's _is_speckit_generated_skill()
parses frontmatter independently, so all three carried the same
split("---", 2) bug the base class just fixed. A description such as
"Separate sections with --- markers" truncates the parsed frontmatter at the
embedded marker, dropping later keys and spilling the remainder into the
body; for Kimi that means a Speckit-generated skill is no longer recognized
on teardown and gets left behind.
Scan for a closing "---" on its own line instead. The body slice keeps
whatever trails the marker so output stays byte-for-byte identical for
well-formed templates.
* fix(archives): wrap the bare EOFError a truncated tar.gz raises
`tarfile` wraps most decompression failures in `TarError`, but a gzip
stream that ends before its end-of-stream marker escapes as a bare
`EOFError` from the gzip layer. `EOFError` derives from neither
`TarError` nor `OSError`, so it bypassed all three of the tar handlers
added with tar archive support (#3874):
- the format probe in `detect_archive_format`, which caught only
`tarfile.TarError`;
- `tarfile.open` in `safe_extract_tar`;
- member iteration in `safe_extract_tar`.
A truncated `.tar.gz` — an interrupted download, a partially written
file — therefore raised a raw `EOFError` straight through the caller's
`error_type`, so callers catching `ValueError`/`ExtensionError`/
`PresetError` never saw it. In `specify workflow add` the effect is worse
than a traceback: Typer treats a bare `EOFError` as a Ctrl-D abort, so
the command printed only "Aborted." with no diagnostic at all. The ZIP
twin reports "Invalid workflow archive: Invalid ZIP archive: <path>".
Route all three sites through a shared `_TAR_DECOMPRESSION_ERRORS`
tuple so they stay in sync. `zlib.error` is included alongside
`EOFError`: it is likewise neither a `TarError` nor an `OSError` and can
surface from a corrupt deflate block. `OSError` is kept only on the two
`safe_extract_tar` sites, which report genuine I/O failures; adding it
to the probe would silently swallow them instead.
Truncated tar.gz now reports the same clean, domain-typed error as the
ZIP path. Tests cover both the short prefix that fails in
`tarfile.open` and the longer ones that fail during member iteration —
`tarfile` decompresses lazily, so the leak surfaced at different sites
depending on how much of the stream survived.
Assisted-by: Claude Opus 5 (1M context)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(archives): cover the bare zlib.error a corrupt deflate block raises
Review feedback: the `zlib.error` arm of `_TAR_DECOMPRESSION_ERRORS` was
not exercised. Every regression added with the fix truncates a valid
deflate stream, which raises `EOFError`, so `zlib.error` could regress
independently of the EOF handling.
It is genuinely reachable, but only under a narrower condition than the
truncation cases. `tarfile` converts `zlib.error` to `ReadError` while
reading a member *header*, but the forward seek it performs to skip
member *data* (`tarfile.next`) sits outside that conversion, so a corrupt
region past the first header escapes raw. Reaching that seek needs
members larger than the gzip read buffer: with small members the whole
stream is decompressed during the first header read and the error is
wrapped. The new fixture therefore uses two 256 KiB members at
`compresslevel=1` — a ~7 KiB archive — corrupted past the midpoint so
the first header still reads clean.
Adds four tests: the two `safe_extract_tar` sites (plain and with a
caller-supplied `error_type`), the `safe_extract_archive` entry point
with a caller-supplied `error_type`, and a guard asserting the fixture
still reaches the module as a bare `zlib.error` — so if a future Python
wraps it, that fails loudly instead of the coverage silently decaying
into a duplicate of the `EOFError` cases.
Verified test-the-test: the three wrapping tests fail against the
unmodified `_download_security.py` with a raw
`zlib.error: Error -3 while decompressing data: invalid distance code`,
and pass with the fix.
Also corrects the scope claimed for the probe site. Fuzzing 2800 corrupt
archives never produced a bare `zlib.error` from `tarfile.open` alone,
because the only read it performs is the header read that `tarfile`
already converts. The probe's `zlib.error` arm is defensive, not
load-bearing; the tuple comment and a detection test now say so rather
than implying coverage that cannot exist.
Assisted-by: Claude Opus 5 (1M context)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(archives): make the corrupt-deflate fixture zlib-version independent
CI failure on macos-latest/3.13: `test_corrupt_deflate_fixture_raises_bare_zlib_error`
failed with `gzip.BadGzipFile: CRC check failed`. The other five pytest jobs
were fail-fast cancellations, not real failures, and ruff was already green.
The fixture built its corruption by XOR-ing 64 arbitrary bytes mid-stream.
Whether that produces a *structural* deflate error is zlib-version dependent:
on the macOS runner the mangled bytes still decoded, so the stream instead
failed the trailing gzip CRC check and raised `BadGzipFile` -- an `OSError`,
which the pre-fix `(TarError, OSError)` handler already caught. The guard test
exists precisely to catch that degradation, and it did its job.
Replaces the XOR with a deflate block header whose `BTYPE` is the reserved
value `0b11`. Every zlib rejects that identically as "invalid block type", and
it fails during decompression rather than at the CRC check, so no version can
turn it into a `TarError` or `OSError`. The stream is assembled by hand
(`compressobj(-15)` + explicit gzip header/trailer) so the invalid block lands
a controlled 256 KiB into the first member's data -- past the gzip read buffer,
so the first header still reads clean and the failure surfaces from the forward
seek in `tarfile.next`, which is the site the raw `zlib.error` escapes from.
A sweep over clean-prefix sizes confirms a wide margin: with 512 KiB members
every prefix from 160 KiB up yields a bare `zlib.error`, versus the transition
below ~131 KiB where `tarfile` still wraps it as `ReadError`. The hand-built
gzip header also zeroes the mtime field, so the fixture is now byte-identical
across builds instead of embedding a timestamp.
Strengthens the guard to assert what the fix actually depends on -- that the
exception is neither a `TarError` nor an `OSError` -- so the fixture cannot
silently decay into an already-caught type again.
Production code is unchanged from ef49acc; this is test-only.
Verified test-the-test by dropping the `zlib.error` arm from
`_TAR_DECOMPRESSION_ERRORS`: the three wrapping tests fail with the raw
`zlib.error: Error -3 while decompressing data: invalid block type`, and pass
with it restored. `tests/test_download_security.py`: 193 passed.
`ruff check src tests` (the exact CI command): all checks passed.
Assisted-by: Claude Opus 4.8 (1M context)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add regression tests for SkillsIntegration mixin that verify:
- Multiline (block-scalar) description round-trips byte-for-byte
- C0/DEL control characters in description survive YAML escaping
Tests properly isolate Path.home() for Hermes to prevent overwriting
a developer's real global skill directory.
Refs: #3392
_check_file/_check_dir hard-code U+2713/U+2717 and print() them to
sys.stdout. On Windows sys.stdout falls back to the ANSI code page whenever
stdout is not a console — which is every time an agent or a workflow step
captures the output — and U+2713 is unencodable in cp1252, so the document
listing aborted mid-report with UnicodeEncodeError.
This is the byte-identical twin of the block in
scripts/python/check_prerequisites.py, which I flagged in the PR for that
file rather than widening its scope.
Fall back to ASCII when stdout cannot encode the glyph. "[OK]"/"[FAIL]" is
the rendering these markers already have in-tree: Test-FileExists in
scripts/powershell/common.ps1 emits exactly those, and
normalize_status_text in tests/parity_helpers.py maps the glyphs onto them.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: bump version to 0.16.0
* chore: begin 0.16.1.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
`CommandRegistrar.render_frontmatter` calls `yaml.dump()` without `width=`,
so PyYAML applies its default ~80-column wrap and folds any long scalar onto
a continuation line.
A `description` longer than roughly 80 characters is therefore rendered as:
---
name: speckit-implement
description: Execute the implementation plan by processing and executing all tasks
defined in tasks.md
---
The YAML remains valid and round-trips faithfully through `yaml.safe_load`,
so this is not data loss. It is a shape inconsistency with real consequences:
- Hand-written core command templates always keep `description` on one line,
so preset- and extension-rendered commands do not match the files they sit
beside in the same directory.
- Consumers that read frontmatter line-wise rather than with a YAML parser
see the description truncated at the fold, followed by a stray line. Spec
Kit itself hand-builds SKILL.md frontmatter in the skills path (see #3391),
so this is not a hypothetical class of consumer.
- `speckit.implement`'s own description is 89 characters, so a preset that
overrides it hits this immediately.
`width=float("inf")` disables the line-wrapping only; escaping, quoting and
the handling of genuinely multi-line values are unchanged, since PyYAML
selects the scalar style before applying width.
Adds a regression test that fails without the change.
Verified against the repo's own suite: 6354 passed. Four failures in
tests/integrations/test_integration_subcommand.py are present on a clean
checkout too (ANSI escapes in captured output) and are unrelated.
* fix: skip corrupted run state files in list_runs
* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests
- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files
Fixes#3817
* fix: skip corrupted run state files in list_runs
* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests
- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files
Fixes#3814
* Add July 2026 newsletter
* docs(newsletters): remove internal press-index figures from earlier editions
Replace article counts, volume superlatives, and discovery-methodology
references (derived from an internal press index) with qualitative phrasing
in the April, May, and June editions, keeping only publicly verifiable data.
PresetRegistry._load() catches json.JSONDecodeError and
FileNotFoundError to start fresh on a corrupted or missing registry, but
a registry file with invalid UTF-8 bytes raised UnicodeDecodeError
before JSON parsing began, crashing every preset command.
Catch UnicodeDecodeError in the same clause: undecodable bytes are the
same corruption class as unparseable JSON. OSError stays uncaught on
purpose — the data may be intact on disk, and starting fresh would let a
later _save() wipe it (same fail-closed reasoning as the workflow
catalog cache loader).
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_merge_toml_fragment() and _remove_toml_entries() read the user's
config.toml with bare read_text() calls, so a non-UTF-8 (or otherwise
unreadable) file crashed install_integration_events() and
remove_integration_events() with a raw UnicodeDecodeError — and the
merge path regenerates the file from what it read, so it would have
discarded the user's bytes had it not crashed first. Every JSON
merge/remove path already goes through _load_user_json(), which skips
on an unreadable file to preserve user content (#22).
Abort the merge (returning False so the caller skips tracking, S5) and
skip the teardown cleanup with a warning, leaving the user's bytes
untouched in both directions.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The rescue-retry loop in install_from_directory() reads each staged
backup with bare stat()/read_bytes() calls, so a staged config that
cannot be read crashed the reinstall with a raw OSError. Every sibling
read in this path — the live twin four lines below, the packaged
baseline check, the mode sidecar — already catches OSError.
Treat an unreadable staged file like an uncomparable live config:
add it to the conflict set so both copies are preserved and the retry
aborts with the existing resolution guidance while dest_dir is still
untouched.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`requires.speckit_version` was presence-checked but never type-checked in
both the extension and preset manifest validators, so an unquoted YAML
`speckit_version: 1.0` (a float) passed validation and reached
`SpecifierSet(required)` in `check_compatibility()`.
That call is guarded by `except InvalidSpecifier` alone, which a
non-string escapes two different ways:
- a float/int/bool/None raises `TypeError: 'float' object is not
iterable` from the `SpecifierSet` constructor;
- a list or dict is an *iterable*, so `SpecifierSet` accepts it and the
failure surfaces much later as `AttributeError: 'str' object has no
attribute 'filter'` from inside `.contains()`.
Neither is a `CompatibilityError`/`PresetCompatibilityError`, so both
bypass the CLI's "Compatibility Error" handler in `_commands.py` and exit
1 with a raw traceback that names no field, leaving the author with no
hint which manifest key is wrong.
Type-check the field in both validators, requiring a non-empty string,
and additionally guard `check_compatibility()` in both managers since
each is public and reachable with a hand-built or mutated manifest. This
mirrors the sibling `IntegrationDescriptor`, which already requires a
non-empty string for the same key, and completes the type-checking pass
started in #3943 for the neighbouring `extension`/`preset` fields.
Adds 33 regression tests across both modules covering every escape path;
26 of them fail without this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Code (model: Claude Opus 5, supervised)
The keep-config rescue branch of install_from_directory() reads each
preserved config with bare read_bytes()/stat() calls, so a kept config
that cannot be read (permission or I/O error) crashed the reinstall
with a raw OSError. The sibling symlink guard four lines above already
rejects with a ValidationError and resolution guidance for the same
reason: bytes that cannot be safely rescued must not reach the rmtree
below.
Wrap the read and raise ValidationError with guidance, while dest_dir
is still untouched so the preserved bytes are never lost.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_script_command() split the configured command with a bare
shlex.split(), so a command string with unbalanced quotes crashed event
dispatch with a raw ValueError. The dispatcher-template twin a few
lines up already wraps the same call in try/except ValueError and
returns None so dispatch falls back cleanly.
Wrap the split the same way and return None, restoring parity between
the two paths.
Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(events): context injection for opencode and JSON-envelope agent hooks
Adds first-class context injection to agent runtime events:
1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes.
2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument:
- gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise)
- copilot: top-level additionalContext on session_start
- cursor: top-level additional_context on session_start; suppress elsewhere
- claude, codex: plain stdout passthrough (already injected)
3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly.
Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass.
* fix(events): address code review on #3934
- Qwen/Gemini/Tabnine/Devin: include native hookEventName inside
hookSpecificOutput envelope (required by Qwen's hooks spec). Thread
the native event name from the integration's CANONICAL_TO_NATIVE
through _dispatcher_command as a 6th dispatcher argument, through
the dispatcher template's main()/_run_inline()/_emit(), and through
resolve_and_run_event_command()/_emit_event_stdout().
- Copilot: map user_prompt_submit to additionalContext (previously
unmapped, breaking per-prompt context injection despite Copilot CLI
supporting it via userPromptSubmitted).
- OpenCode: guard experimental.chat.system.transform so canonical
session_start handlers only run when input.sessionID is present —
OpenCode fires this hook for non-session operations (e.g. agent
generation) with no sessionID.
Assisted-by: opencode (model: glm-5.2, supervised)
* fix(events): address second Copilot review round on #3934
- Positional arg alignment: always emit default timeout (60s) as the
4th dispatcher argument even when timeout_seconds is omitted, so the
envelope (5th) and native_event (6th) land in the correct argv slots.
Previously, omitting timeout_seconds caused the envelope to be parsed
as an invalid timeout, silently falling back to plain stdout.
- OpenCode session_start caching: cache handler output per sessionID in
the generated TS plugin so non-idempotent handlers (setup, telemetry,
file-mutating scripts) run once per session instead of on every LLM
request. Cache is evicted on session.deleted.
- Updated PR description to reflect Copilot user_prompt_submit now maps
to additionalContext (was documented as plain/unprocessed).
Assisted-by: opencode (model: glm-5.2, supervised)
* fix: cap stdin read at 1 MiB to prevent DoS
Unbounded sys.stdin.read() allowed a malicious caller to exhaust memory
by sending a multi-gigabyte payload. Cap at 1 MiB and raise typer.Exit
if truncated.
* fix: improve stdin payload limit error handling in event.py
- Rename _MAX_PAYLOAD to MAX_STDIN_BYTES (clearer constant naming)
- Improve error message to suggest truncation or smaller payload
- Better code formatting for readability
Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
* chore: bump version to 0.15.2
* chore: begin 0.15.3.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(presets): restore core skills instead of deleting them on preset remove
Skill restoration looked for core command templates under
.specify/templates/commands, a directory specify init never populates
in real projects. Since that lookup always missed, presets overriding
a core command (e.g. speckit.plan) had their skill deleted outright on
removal instead of restored — the actual core templates live in the
bundled core_pack (wheel install) or the repo-root templates/ tree.
Restoration now falls back to that bundled location, gated behind a
restore_from_bundled_core flag so the existing "retire a stale skill
superseded by a command-mode winner" path keeps deleting rather than
resurrecting a duplicate skill.
Fixes#3928
* fix(tests): use explicit utf-8 encoding reading restored skill content
read_text() defaults to the platform locale encoding, which is cp1252
("charmap") on Windows. The bundled specify.md core template contains a
UTF-8 multi-byte emoji whose bytes aren't valid cp1252, so the Windows CI
job failed decoding the restored SKILL.md with UnicodeDecodeError.
* fix(presets): keep extension restore priority over bundled-core fallback
The bundled-core fallback added for #3928 ran before the
extension_restore_index lookup, so a skill an installed extension owns
could be silently replaced by lower-priority bundled core content on
preset removal instead of preserving the extension's winning layer.
* fix(manifests): reject non-string metadata instead of crashing on it
`ExtensionManifest` and `PresetManifest` checked only key PRESENCE for
`id`/`name`/`version`/`description`, then fed the values straight to
`re.match()` and `packaging.Version()`. Both raise a bare `TypeError` on a
non-string, which is neither `ValidationError` nor `PresetValidationError`,
so it escaped every caller that already handles a malformed manifest.
YAML makes this an easy authoring slip rather than a contrived one: an
unquoted `version: 1.0` parses as a float and `id: 2` as an int.
The user-visible symptom is the one the in-tree comment above the section
guards was written to prevent (#3898 for presets, and its extension twin):
`list_installed()` degrades a bad manifest to "⚠️ Corrupted extension" but
catches only the domain error, so a single bad manifest made
`specify extension list` / `specify preset list` exit 1 with a raw
traceback and *no output at all* — hiding every healthy extension/preset
too, not just the broken one.
Also unguarded on the same path:
- extension `provides.commands[].name` → `TypeError` from the command-name
pattern match. The sibling `file` field was already safe, since
`relative_extension_path_violation()` rejects a non-string.
- preset `provides.templates[].name`/`.file` → `TypeError` from `re.match`
and `os.path.normpath` respectively.
The third manifest twin, `IntegrationDescriptor`, is already hardened: it
type-checks the same four fields and catches `TypeError` alongside
`InvalidVersion`. This brings the other two in line with it.
Tests: 68 added across both suites, covering each field against float, int,
None, list, dict, and bool, plus an end-to-end guard per manifest type
asserting a healthy entry still lists while the bad one degrades. All 68
fail with the source change reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix: narrow exception in invoke separator resolution and add regression test
Narrow 'except Exception' to 'except (ImportError, ValueError, KeyError)'
in register_commands() invoke separator resolution. Add regression test
that verifies TypeError propagates instead of being silently swallowed.
* fix: remove duplicate pass statement in agents.py
Remove redundant second pass statement in the except block
for invoke separator resolution. The narrowed exception handler
now has a single clean pass statement.
Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
The step documents the default twice:
class docstring: "Because workflows run unattended, the step defaults to
``--ignore-agent-tools``"
field docs: "Skip checks for the coding agent CLI (defaults to ``true``)"
It implements that with `config.get("ignore_agent_tools", True)`, which
applies the default only when the key is ABSENT. A bare
`ignore_agent_tools:` in YAML parses to None, and `_resolve_bool(None)`
returns False:
key ABSENT -> True flag emitted: YES
bare ignore_agent_tools: -> False flag emitted: NO <-- bug
explicit true -> True flag emitted: YES
explicit false -> False flag emitted: NO
So the flag is dropped, `specify init` re-runs the agent-CLI presence
check, and an unattended run fails with "Agent Detection Error" for any
integration whose CLI is not installed on the runner.
Normalize an explicit null to the default, mirroring the while/do-while
`max_iterations` handling.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT
Resolve the non-interactive/init default integration from the
SPECKIT_INTEGRATION_DEFAULT environment variable, fitting the existing
SPECKIT_INTEGRATION_* namespace. Falls back to the hardcoded "copilot"
default when unset, and warns to stderr (rather than silently falling
back) when the value is not a registered integration key.
Wires the resolver into specify init (interactive prompt default and
non-interactive fallback), the init workflow step, and the bundle init
default. Adds unit and CLI tests and documents the variable.
Closes#3939
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
* test: cover env-var default wiring for picker, workflow step, and bundle
Address PR review: add regression tests so each SPECKIT_INTEGRATION_DEFAULT
wiring site cannot silently revert to the hardcoded constant.
- init.py: interactive picker receives the resolved key as default_key.
- workflow init step: no step/workflow default + env var drives output
integration and argv.
- bundle _resolve_init_integration: env-var default applies when unspecified,
while explicit override and manifest-declared integration still win.
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
* feat(extensions): scaffold config templates on extension add/enable
Deploy an extension's provides.config templates into .specify/ when the
extension is added or enabled. Existing files are never overwritten, so
user customizations are preserved.
Addresses the review on #2000:
- ExtensionManifest.config returns [] unless provides.config is a list of
dicts, so a malformed manifest cannot crash callers.
- scaffold_config returns a consistent (deployed, skipped_existing, failed)
tuple on every path, including a missing manifest.
- Template paths must resolve inside the extension dir and targets inside
.specify/; symlinks and non-regular files are rejected.
- Callers distinguish "already exists (preserved)" from "not scaffolded",
and extension_enable no longer crashes on a corrupt manifest.
- Tests cover traversal, absolute paths, symlinks, directory templates,
malformed provides.config, and the missing-manifest tuple shape.
Ported onto the extensions package introduced by #3014: the manager and
manifest changes land in extensions/__init__.py and the CLI wiring in
extensions/_commands.py.
* fix(extensions): deploy config where it is read, and contain the write
Addresses @Copilot's review.
Config now lands in .specify/extensions/<id>/ rather than the .specify/
root. ConfigManager._get_project_config() reads
.specify/extensions/<id>/<id>-config.yml, and the bundled scripts and
READMEs use the same path, so a scaffolded git-config.yml was being
written somewhere the git extension never looks.
Containment is checked component by component before .specify is used as
the root. Resolving it first and trusting the result let a symlinked
component point outside the project, after which every target satisfied
relative_to and copy2 wrote externally. This matches the project
safe-write path in shared_infra.
mkdir moved inside the OSError handler. A nested target like
foo/config.yml raised out of scaffolding when its parent could not be
created, and on extension add that happened after the extension was
already installed.
The 'Configuration may be required' warning is now conditional. It ran
unconditionally after the scaffolding block, so it contradicted the
success output directly above it and fired for extensions with no
provides.config at all.
Tests cover the corrected location, a symlinked config root, and an
uncreatable nested target.
* fix(extensions): only scaffold config targets that removal preserves
remove(keep_config=True) rmtree's every subdirectory and keeps only top-level
-config.yml / -config.local.yml files; the backup path globs the same
top-level pattern. Scaffolding a nested or differently-named target therefore
handed the user a file that 'extension add --force' silently deleted and
replaced with the template default, losing customization.
Constrain scaffold targets to that convention rather than widening four
removal paths.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
execute() reads `on_reject = config.get("on_reject", "abort")` and, in the
reject branch, handles only "abort" and "retry" before falling through to
its `# on_reject == "skip"` case. So any other value makes a REJECTED gate
report COMPLETED and the run walks straight past the review the gate
exists to enforce:
on_reject='abort' -> failed "Gate rejected by user at step 'g'"
on_reject='retry' -> paused
on_reject='skip' -> completed (by design)
on_reject='Abort' -> completed <-- rejection silently discarded
on_reject='fail' -> completed <-- same
on_reject='stop' -> completed <-- same
on_reject=None -> completed <-- same
on_reject=5 -> completed <-- same
Reachable by a capitalisation slip, a guessed verb, a non-string, or a
bare `on_reject:` — note `config.get(k, default)` does NOT substitute the
default for an explicit YAML null.
`validate` already rejects anything outside abort/skip/retry, but the
engine does not auto-validate before execute(). Fail loudly instead,
mirroring the `options` and `verdict_input` guards in the same method, and
placed before the non-TTY short-circuit so it surfaces in CI too.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: eliminate TOCTOU race in zip packaging
Open file once and derive both stat info and content from the same file
descriptor to prevent race conditions where the file is modified between
stat() and read_bytes() calls.
* test: add regression test for TOCTOU stat/read consistency in packager
The old implementation called file_path.stat() then file_path.read_bytes()
as separate syscalls. The fix opens the file once and uses os.fstat() +
fh.read() on the same handle. This test verifies the archived bytes and
mode are consistent with the opened file descriptor.
execute() did:
output_config = config.get("output") or {}
if not isinstance(output_config, dict):
output_config = {}
so every non-mapping `output` was silently discarded and the step still
returned COMPLETED — every declared aggregation key vanished, and
downstream `{{ steps.<id>.output.<key> }}` resolved to None and
interpolated as an empty string:
output=[] -> completed, error=None
output=False -> completed, error=None
output=0 -> completed, error=None
output='' -> completed, error=None
output=['a'] -> completed, error=None
output='oops' -> completed, error=None
output=5 -> completed, error=None
`validate` already rejects this and its comment names the flaw exactly:
"execute() silently coerces a non-mapping output to {}, so the author's
declared aggregation keys would vanish with no error." The engine does not
auto-validate before execute(), so on an unvalidated run that is what
happened — and `x or {}` masked the falsy shapes before the isinstance
check even ran.
Fail loudly with validate()'s own message, mirroring the `wait_for` guard
in the same method. An explicit `output:` (YAML null) stays valid.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add --extension flag to specify init for installing extensions at init time
Adds a repeatable --extension flag to `specify init` so users can opt into
extensions (bundled name, local path, or HTTPS URL) during initialization,
without a separate `specify extension add` step.
- New `_install_extension_during_init` helper in commands/init.py that
auto-detects source type (URL / local path / bundled name / catalog) and
installs via ExtensionManager. Failures are non-fatal and recorded in the
tracker without aborting init.
- Extension tracker steps are pre-registered before the Live context and run
after preset install, before finalize.
- Five new tests in TestExtensionFlag covering bundled name, multiple
extensions, local absolute path, unknown extension (graceful error), and
combination with --preset.
Rebased onto upstream/main and adapted to the refactored init command
(moved to src/specify_cli/commands/init.py) from stale PR #2396.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Address review: reuse hardened downloader, refresh events, escape labels, fix bundler call
Responds to review feedback on #3914 and fixes CI (pytest bundler failure).
- Extract shared `install_extension_from_url` helper in extensions/_commands.py
that reuses the authenticated, redirect-guarded, bounded (50 MiB) download
and TOCTOU-safe transient archive used by `extension add --from`. Both
`extension add --from` and `specify init --extension <url>` now go through
this single downloader instead of a second raw urlopen path.
- Refresh native event configuration once after successful extension installs
during init (mirrors `_refresh_events_and_warn` in the add path) so an
extension declaring `events:` has its hooks activated.
- Escape user-controlled extension specs and error text before interpolating
them into StepTracker labels (Rich markup injection).
- Pass `extensions=None` from bundler's `_run_init` so the init callback no
longer receives the typer OptionInfo sentinel ('OptionInfo' object is not
iterable), which broke `test_install_initializes_uninitialized_project`.
- Add init URL coverage in TestExtensionFlag: non-HTTPS rejection and a
successful HTTPS ZIP install with download-cache cleanup assertion.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Add default-deny trust confirmation for URL extension installs at init
URL-based --extension installs now require explicit trust, matching the
`extension add --from` posture. Interactive sessions show an "Untrusted
Source" panel and prompt (default no); non-interactive sessions deny by
default unless --trust-extension-urls is passed. Trust is resolved before
the Live display since the prompt can't be answered under the spinner.
- Add --trust-extension-urls option and _ext_spec_is_url /
_confirm_extension_url_trust helpers
- Skip (not abort) unconfirmed URL extensions, consistent with other
non-fatal extension failures
- Pass trust_extension_urls=False from the bundler init callback
- Add tests for deny-by-default, interactive confirm, and trusted install
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3
* fix: bound response reads in extension catalog and download
Replace unbounded
esponse.read() calls with
ead_response_limited()
from _download_security in extensions/__init__.py to prevent denial-
of-service via oversized catalog or extension archive responses.
Three call sites fixed:
- _fetch_single_catalog JSON read (catalog metadata)
- _fetch_catalog JSON read (legacy path)
- download_extension ZIP read (binary download)
All existing mock tests updated to use side_effect with BytesIO.read
instead of
eturn_value, ensuring compatibility with the chunked read
loop in
ead_response_limited.
Two regression tests added:
- test_oversized_catalog_response_rejected
- test_oversized_extension_download_rejected
* fix: remove .decode utf-8 to preserve bytes for json.loads
json.loads accepts bytes directly. Removing .decode maintains
compatibility with BOM-bearing or UTF-16/32 catalogs.
A gate with `on_reject: retry` consumes a bound reject verdict before
pausing by resetting the named input to `""` (documented behaviour, so a
later resume prompts again). Every `resume()` re-resolves the persisted
inputs through `_coerce_input`.
Those two rules collide when the bound input declares an `enum` that does
not list `""`. The reset writes a value the input's own enum forbids, and
the run wedges:
inputs:
spec_verdict:
type: string
enum: [approve, reject]
steps:
- id: review
type: gate
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
$ specify workflow run wf --input spec_verdict=reject
Status: paused
$ specify workflow resume <run_id> --input note=b
Error: Input 'spec_verdict' value '' not in allowed values:
['approve', 'reject'].
The workflow validates clean and the first run looks fine, so the failure
only appears at the second resume. It is also unrecoverable in practice:
`_resolve_inputs` re-coerces the whole persisted map, so *any* resume that
supplies an input dies on the stored `""`. Only a resume with no inputs at
all still works -- and that is precisely the call that cannot deliver a new
verdict, which is the one thing the retry cycle exists to allow.
Extend the existing `verdict_input` cross-check (which already confirms the
name is declared) to also require that a retry-bound input's `enum` admits
the reset sentinel, and report it with a fix hint. To do that, thread the
input *definitions* through `_validate_steps` instead of just their names.
Rejected the alternative of popping the key instead of writing `""`: that
lets the input's `default` flow back in on the next resume, so a gate the
user just rejected would silently auto-approve.
Docs: note the `enum` requirement next to the reset behaviour it follows
from.
Adds 4 validation tests for the new guard plus a characterization test that
drives the engine directly to pin the wedge it prevents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Opus 5 (1M context)
* chore: bump version to 0.15.1
* chore: begin 0.15.2.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
`workflow resolve` printed two lines through `console.print`, which has
Rich markup enabled, without escaping:
1. The layer tier was wrapped in literal brackets:
`f" • [{layer.tier}] {layer.source} ..."`. Rich parsed `[base]`
and `[project-overlay]` as style tags, so the tier label was swallowed
on *every* invocation -- no untrusted input required. The column has
never rendered.
2. Step attribution interpolated `composed.step_id` raw. Step IDs come
from base-workflow / overlay YAML and are only validated against `:`
(see `_parse_edit`), so brackets pass validation. A balanced
`[stuff]` is silently swallowed; an unbalanced `[/red]` raises
`rich.errors.MarkupError`, producing an uncaught traceback and exit 1
-- the workflow cannot be inspected at all.
Route the interpolated fields through `rich.markup.escape` and escape
the literal tier bracket as `\[`, matching the existing pattern in
`workflow info`'s step graph and `workflow_list`'s `\[disabled]`.
Only display is affected; the returned payload was already unescaped and
is unchanged.
Adds 3 regression tests, all of which fail without the fix: the tier
label renders, and a step ID survives both the swallowing and the
crashing markup cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Opus 5 (1M context)
* fix(scripts): tolerate an unusable integration.json in the Python helper
`get_invoke_separator()` in scripts/python/common.py indexed the parsed JSON
directly, so two shapes escaped its `except (OSError, json.JSONDecodeError)`
while BOTH of its twins fall back to "." for them:
* A non-mapping top level is valid JSON, so JSONDecodeError never fires and
`state.get(...)` raised AttributeError.
* A non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an OSError.
Realistic on Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.
Measured on main -- 6 of 7 inputs crashed the Python helper while bash and
PowerShell 5.1 returned "." for every one:
input python bash pwsh 5.1
{"default_integration":"forge"} '.' . .
[] AttributeError . .
"forge" AttributeError . .
42 AttributeError . .
null AttributeError . .
UTF-16 file UnicodeDecodeError . .
Split the parse out of the lookup, complete the exception tuple, and guard the
top-level shape -- matching `read_feature_json_feature_directory` in this same
module, which already does exactly this. The hyphen-separator feature is
unchanged (regression test included).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(scripts): point the parity comment at the sibling above, not below
read_feature_json_feature_directory is defined at line 81, above
get_invoke_separator, so "below" sent maintainers the wrong way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(catalogs): validate the port in the shared catalog-URL validator
`CatalogStackBase._validate_catalog_url()` reads `parsed.hostname` inside
its `try/except ValueError` but never reads `parsed.port`. `urlparse()` and
`.hostname` do not perform port validation — only `.port` does — so a
catalog URL with a non-numeric or out-of-range port passes validation.
Every implementation that documents itself as mirroring this function
already reads `.port` inside the same try: workflows/catalog.py (4 sites),
bundler/services/adapters.py (2), bundler/commands_impl/catalog_config.py,
and commands/bundle/__init__.py. The shared base — inherited by
ExtensionCatalog and IntegrationCatalog — is the only one without it.
The accepted URL then escapes as a raw `http.client.InvalidURL`, which is
neither `urllib.error.URLError` nor `json.JSONDecodeError` (the only two
the fetcher converts), so it surfaces as an unhandled traceback rather
than the validator's normal error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(catalogs): describe both bad-port failure modes accurately
The comment attributed both malformed-port cases to
http.client.InvalidURL. Only a non-numeric port raises that (when the
connection object is built); an out-of-range port constructs fine and
fails later in the socket layer. Measured:
example.invalid:notaport -> http.client.InvalidURL: nonnumeric port
example.invalid:65536 -> HTTPSConnection() OK, connect() fails
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(presets): add opt-in constitution-sync preset
Follow-up to #3790, which removed the consistency-propagation pass from the
core /constitution command in favor of runtime resolution. Teams that treat
materialized plan/spec/tasks templates as reviewed, committed artifacts lost
the auto-sync of amended constitutional guidance on a non-forced upgrade.
Add a bundled, opt-in `constitution-sync` preset that restores that behavior
via a wrap-strategy override of speckit.constitution (composes on {CORE_TEMPLATE}
so it stays forward-compatible). It only writes into the project's own
.specify/templates scaffolds and installed command files, never into
stack-owned template layers.
- presets/constitution-sync/: preset.yml (requires >=0.14.4), wrap command,
README documenting the tension between auto-propagation and the resolution stack
- presets/catalog.json: bundled entry
- docs/upgrade.md: document the 0.14.4 behavior change and the opt-in
- tests/test_presets.py: structural + composition coverage (TestConstitutionSyncPreset)
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* fix(presets): ship constitution-sync in wheel, clarify scope guard, assert composition
Address review feedback on #3873:
- pyproject.toml: force-include presets/constitution-sync into the wheel's
core_pack so `_locate_bundled_preset` resolves it in a released install; the
bundled advertisement was otherwise unshippable.
- tests/contract/test_wheel_bundled_presets.py: new contract test asserting
every bundled preset in presets/catalog.json is force-included (guards lean too).
- commands/speckit.constitution.md: explicitly state the propagation section
supersedes the core Scope Guard, which otherwise says dependent templates are
not modified here.
- tests/test_presets.py: assert resolve_content substitutes {CORE_TEMPLATE} and
the effective command embeds both the core body and the sync pass.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* test(presets): parse frontmatter as YAML in constitution-sync wrapper test
Address review feedback on #3873: assert `strategy: wrap` structurally by
parsing the Markdown frontmatter as YAML (instead of a substring match that
could false-positive on body text), and assert {CORE_TEMPLATE} in the body
section only.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(presets): reframe constitution-sync README around behavior and caveats
Rework the preset's user-facing docs to describe what it does, what it does
not do, and the caveats you take on — rather than leading with version/origin
history. The preset stack is the project's forward direction, so the README no
longer positions this as "restoring pre-0.14.4 behavior."
Also make the edit-in-place vs. composition conflict explicit and consistent
across the wrapper command and docs: propagation into command files/templates
that are provided or wrapped by a preset/extension is clobbered on stack
reconciliation (integration use/upgrade, preset/extension install/remove), so
the wrapper restricts propagation to project-local artifacts the team owns.
- README: forward-looking "What it does / does not do / When to use / Caveats"
- speckit.constitution.md: step 4 no longer hand-edits composed command files;
closing caveat covers command files too
- docs/upgrade.md: note the composition-model conflict in the opt-in section
- tests: assert the updated closing-caveat wording
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(presets): tweak constitution-sync README default-behavior wording
Phrase the default-behavior note as "the current version of Spec Kit" and
rewrap the opening paragraph.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(presets): keep emphasis spans on one line in constitution-sync README
Avoid **bold** spans broken across soft line breaks (runtime resolution,
reviewed committed artifacts) so they render consistently.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(presets): refocus constitution-sync README on what it restores
Reframe the intro around what the preset restores and what the user opts
into, rather than describing current Spec Kit default behavior. Be honest
that propagation was removed deliberately (duplicates the source of truth,
fights composition) and this preset knowingly reintroduces it and its
tradeoffs. Minor flow fixes (comma splice, terse bullet).
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(upgrade): de-pin version from /constitution behavior-change section
The upgrade guide always describes the latest version, so hard-pinning
"0.14.4" in the heading and "Starting in 0.14.4" in the body added no
value. Keep the #3790 provenance link and the "no longer propagates"
framing; the machine-readable version gate stays in preset.yml.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* docs(upgrade): clarify non-breaking nuance and presets direction
Note that the /constitution scope change is only noticeable if you relied
on the old edit-in-place behavior, and add the forward-looking framing:
presets and extensions — not in-place file edits — are how Spec Kit now
governs, versions, and audits shared assets across repositories.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
* fix(extensions): harden URL download cache
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* fix(extensions): retain secure archive descriptor
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Harden extension URL cache anchor opens
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Use descriptor-safe mkdir for cache components
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Harden extension URL download cache
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Align extension manifest regression expectation
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Make download cache leaf anonymous to remove cleanup TOCTOU
Address review: the best-effort cleanup walk re-derived the downloads
directory by path, so a cache ancestor swapped after the archive was
opened could redirect os.unlink to a replacement leaf, and it silently
no-op'd (failing open) on platforms without descriptor-relative unlink.
_safe_open_download_zip now unlinks the exclusively-created leaf
immediately via the same directory descriptor, returning an fd backed by
an anonymous inode. Installation already consumes that descriptor through
archive_file, so the on-disk pathname is never reopened and no cleanup
walk is needed. The capability gate additionally requires os.unlink in
os.supports_dir_fd, so unsupported platforms fail closed. Removed the
now-unused _safe_unlink_download_zip helper and its cleanup finally, and
updated the tests accordingly.
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Fix Windows test matrix for cache hardening tests
The hardened cache primitives fail closed on platforms without dir_fd/
O_NOFOLLOW, so on the windows-latest matrix several tests errored instead
of exercising POSIX behavior:
- test_symlinked_cache_ancestor_is_refused and
test_cache_ancestor_resolving_outside_project_is_refused called
_validate_safe_cache_dir directly and expected typer.Exit, but on
Windows it raises NotImplementedError first. Guard both with
_require_secure_dir_fd() so they skip where the primitive is unavailable.
- test_safe_open_fails_closed_without_atomic_platform_support built its
download dir via _validate_safe_cache_dir, which itself fails closed on
Windows; construct the directory directly so the assertion targets
_safe_open_download_zip's platform gate in isolation.
- The _open_test_download_zip stand-in unlinked a still-open file, which
raises PermissionError on Windows. Use O_TEMPORARY there (auto-delete on
close) and keep immediate unlink on POSIX.
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Support Windows in extension URL download-cache hardening
Replace the fail-closed NotImplementedError on platforms lacking dir_fd
with a portable, still-hardened download path so `specify extension add
--from <url>` works on Windows instead of rejecting the install.
- `_validate_safe_cache_dir` now dispatches to a POSIX dir_fd + O_NOFOLLOW
walk when available, and otherwise a portable path-wise walk that rejects
symlink/junction components before and after each mkdir and requires every
component to resolve back under the project root.
- `_safe_open_download_zip` keeps the POSIX anonymous-inode create/unlink and
adds a portable leaf create using O_EXCL + O_TEMPORARY (auto-delete on
close) plus a post-open fstat/lstat inode-identity check to detect a leaf
swapped underneath us. Installation still consumes only the open
descriptor, so the cache pathname is never reopened.
- Detect the symlink-refusal case via errno (ELOOP/ENOTDIR/EMLINK) instead of
FileExistsError, and add O_CLOEXEC to the descriptor-walk opens.
- Drop the now-unreachable NotImplementedError handling in the --from branch.
- Tests: cover the portable path (success, symlinked-leaf refusal, symlinked
ancestor refusal, full --from install) and keep the POSIX-only cases guarded.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
* Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller
Apply the remediation from the bug assessment on issue #3424.
DefaultPrimitiveInstaller lacked a refresh() method, causing
_refresh_component() to fall back to install(), which calls
ExtensionManager.install_from_directory() with force=False. This
raised ExtensionError with a leaked --force hint that bundle update
does not support, leaving users with no valid recovery path.
Fix: add refresh() to each kind manager (ExtensionKindManager and
PresetKindManager delegate to _do_install(force=True); WorkflowKindManager
and StepKindManager delegate to install() as their callables are
idempotent). DefaultPrimitiveInstaller.refresh() dispatches to the kind
manager's refresh(). PresetManager.install_from_directory() and
install_from_zip() gain a force parameter that removes the existing
preset before reinstalling, mirroring ExtensionManager's force semantics.
Refs #3424
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address review feedback on primitives.py and test_bundler_primitives.py
- Replace ... with pass in _KindManager Protocol method stubs
- Conditionally pass force= keyword only when force=True in _PresetKindManager
- Fix _StepKindManager.refresh() to remove step before re-installing
- Rename test to reflect actual assertion (refresh succeeds + force=True)
- Remove duplicate install_bundle import
Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)
* fix: add missing role/effective_integration to InstallPlan in _plan() and remove redundant import
- Remove duplicate `DefaultPrimitiveInstaller` import inside test body
(already imported at module scope on line 15)
- Add required `role` and `effective_integration` fields to `InstallPlan`
constructor in `_plan()` helper to prevent TypeError at runtime
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix: address latest PR review comments
Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous)
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Replace unbounded f.read() with chunked iteration to prevent excessive
memory allocation on large or corrupted manifest files. Matches the
pattern used in integrations/manifest.py _sha256().
`Resolve-SpecifyInitDir` normalized the resolved path with
`[System.IO.Path]::TrimEndingDirectorySeparator`, which is .NET Core only.
Windows PowerShell 5.1 runs on .NET Framework, so on every 5.1 host the call
throws at that line and root resolution fails before the requested command runs:
$ $env:SPECIFY_INIT_DIR = "C:\repo\web"
$ .specify\scripts\powershell\check-prerequisites.ps1 -Json
check-prerequisites.ps1 : Method invocation failed because
[System.IO.Path] does not contain a method named
'TrimEndingDirectorySeparator'.
The same file already documents this exact incompatibility and avoids it
correctly in `Get-FeaturePathsEnv` (~150 lines below), which uses `TrimEnd`
with a comment naming `TrimEndingDirectorySeparator` as .NET Core only.
Worse than a clean failure when the resolver is called directly: the throw is
non-terminating, so `$initRoot` stays `$null` and the very next `Join-Path`
throws too, `Get-RepoRoot` returns `$null`, and the shell exits **0**. A caller
that checks the exit code sees success with an empty root.
Switched to the `TrimEnd('/', '\')` the file already endorses. Note the obvious
swap is not quite enough on its own: a bare `TrimEnd` turns `C:\` into `C:`,
which is not the drive root but a drive-relative reference that later path APIs
re-resolve against the *current directory* — so validation would probe the wrong
tree and, from a cwd that happens to contain `.specify/`, could silently accept
`C:` as the project root. A `GetPathRoot` length check keeps a path that is its
own root intact. Both `GetPathRoot` and `TrimEnd` exist on .NET Framework.
Trailing-separator trimming (the reason the call was there — bash's `cd && pwd`
never yields one, so the two resolvers must agree) is unchanged, as are all
error paths and messages.
Tests in `tests/test_init_dir.py`:
- A static check that no shipped `.ps1` calls a .NET Core-only
`[System.IO.Path]` member (`TrimEndingDirectorySeparator`,
`EndsInDirectorySeparator`, `GetRelativePath`, `Join`). This one runs on all
platforms and is what actually guards CI: the matrix runs the PowerShell
tests under `pwsh`, which is .NET Core, so a .NET Framework-only regression
is otherwise invisible to it. Anchored to the `Path` type so `[string]::Join`
is not flagged.
- Two runtime tests under `powershell.exe` specifically (never `pwsh`),
covering resolution and trailing-separator parity.
- A drive-root test asserting the reported root survives the trim intact.
Test-the-test: reverting the source change fails all four (the runtime pair
with the `does not contain a method named` throw, the static check by locating
the call). Applying only the naive `TrimEnd` fails the drive-root test, which
reports `C:` instead of `C:\`. Verified on Windows PowerShell 5.1.19041.6456,
including the previously-crashing `check-prerequisites.ps1 -Json` end to end.
Also fixes six pre-existing `test_ps_*` failures on 5.1-only hosts, which were
this bug rather than test-harness issues.
Fixes#3749
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: bind gate verdict to workflow input via verdict_input
Add an optional `verdict_input` field to gate steps that lets an
external system supply a verdict through a declared workflow input
instead of an interactive TTY prompt.
When the referenced input carries a non-empty string value that
matches one of the gate's `options` (case-insensitive), the gate
auto-decides, records the matched spelling in `output.choice`, and
applies the existing `on_reject` / abort / skip / retry semantics.
If the value is present but does not match an option, or is a
non-string, the gate fails immediately with a clear error message.
When the input is absent, null, or empty, the gate falls back to
today's TTY-prompt-or-pause behaviour unchanged.
The engine now persists `result.error` alongside each step's status
and output so that failed-step error messages survive across runs.
The CLI (`workflow run` and `workflow resume`) surfaces these
persisted errors after a failed or aborted run. `validate_workflow`
cross-references `verdict_input` against the workflow's declared
inputs block and reports an error for undeclared names, consistent
with the existing `wait_for` id cross-check.
Closes discussion: https://github.com/github/spec-kit/discussions/3717
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix workflow JSON error payloads
Include persisted step errors in _workflow_run_payload so workflow run/resume/status --json all surface failure reasons consistently. Add JSON-path tests for failed and successful runs.
Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(workflows): reject verdict inputs in fan-out
Fan-out items share workflow inputs and cannot safely consume a bound gate verdict. Reject verdict_input bindings during validation and at runtime while preserving unbound gates.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: update workflow command handling
Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Markus <markus@example.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Relative image paths do not render on the PyPI project page. Convert the
remaining logo and video-header image references to absolute
raw.githubusercontent.com URLs so they display correctly on
https://pypi.org/project/specify-cli/ while continuing to render on GitHub.
Addresses the rendering gap noted in github/spec-kit#2908.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e8a8563a-328e-43a4-8eb7-ff381f912161
* chore: bump version to 0.15.0
* chore: begin 0.15.1.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
PR #3847 hardened the prompt step's `timeout` guard against a huge-int
value, but its twin in the shell step — the step the prompt one was
mirrored from — still has the hole.
`math.isfinite(10**400)` raises `OverflowError: int too large to convert
to float`. A 400-digit YAML scalar is an `int` and is not a `bool`, so it
clears every clause before `isfinite()` and raises there, escaping
`_timeout_error()` as exactly the uncaught crash that helper exists to
prevent:
steps:
- id: qa
type: shell
run: echo hi
timeout: 1000...0 # 400 digits
$ specify workflow run wf.yml
Traceback (most recent call last):
...
File "src/specify_cli/workflows/engine.py", line 361, in _validate_steps
step_errors = step_impl.validate(step_config)
File "src/specify_cli/workflows/steps/shell/__init__.py", line 127
or not math.isfinite(timeout)
OverflowError: int too large to convert to float
`workflow_run` calls `engine.validate()` before executing any step, so
the OverflowError propagates out of `validate_workflow` and kills the
command with a bare traceback that names neither the step nor the field,
instead of the "Workflow validation failed" report. `execute()` shares
the same helper, so an unvalidated run raises there too — and the engine
re-raises anything a step throws, aborting the whole workflow after
earlier steps have already run their side effects. The value is
genuinely invalid rather than merely unrepresentable in the check:
`subprocess.run(timeout=10**400)` raises the same OverflowError.
Unlike the prompt step, the shell step checks `isfinite()` *before*
`timeout <= 0`, so a negative huge int (`-(10**400)`) crashes as well
rather than being caught by the sign check.
Wrapped the condition in `try/except OverflowError` and treated the
value as invalid, mirroring the prompt step's guard so both steps reject
the same values with the same message. Now:
Workflow validation failed:
- Shell step 'qa': 'timeout' must be a positive number of seconds,
got 1000...0.
Valid int/float timeouts, non-finite floats, bools, strings and
non-positive values are unaffected — the existing clauses are unchanged.
Regression tests in `TestShellStep`: `validate()` rejects both signs of
the huge int, `validate_workflow()` reports it end to end (pinning the
path the CLI actually takes, not just the helper), and `execute()` fails
only that step with `subprocess.run` patched to assert it is never
reached. Test-the-test: reverting the source change fails all three with
`OverflowError` and leaves the rest of `TestShellStep` passing.
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add `intent` extension submitted by @SuhaibAslam to:
- extensions/catalog.community.json (inserted alphabetically between intake and issue)
- docs/community/extensions.md community extensions table
This revision limits the catalog change to the intent addition and the
top-level updated_at bump only, reverting the unrelated re-serialization
(entry reordering, \u2014 Unicode escaping, tool-array reformatting) that a
reviewer flagged.
Closes#3854
cc @SuhaibAslam
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* fix(workflows): validate prompt step 'timeout' like the shell step
PR #3768 added a `timeout` to the prompt step and passed it straight into
`subprocess.run(timeout=...)`. Neither `validate()` nor `execute()` checks
it, so a bad value from a user-authored `workflow.yml` escapes as a raw
exception:
steps:
- id: first
type: shell
run: echo side-effect
- id: ask
type: prompt
prompt: do it
timeout: abc
$ specify workflow run wf.yml
> [first] shell ...
Workflow failed: unsupported operand type(s) for +: 'float' and 'str'
The engine re-raises anything a step throws, so this takes down the whole
run — after `first` has already run its side effect — with a message that
names neither the step nor the field. `timeout: .nan` raises `ValueError:
cannot convert float NaN to integer` the same way, and a non-positive
`timeout` (`0`, `-5`) makes `subprocess.run` report an immediate
TimeoutExpired for a command that never got the time to run. `timeout:
true` silently becomes a 1-second limit, since bool is an int subclass.
The sibling shell step already rejects exactly these values via a
`_timeout_error()` helper shared by `execute()` and `validate()`, so the
same workflow failed validation cleanly as a shell step and crashed as a
prompt one. Mirrored that helper onto PromptStep: `validate()` reports the
contract error, and `execute()` re-checks it so an unvalidated run fails
just that step instead of aborting. Now:
Workflow validation failed:
- Prompt step 'ask': 'timeout' must be a positive number of seconds,
got 'abc'.
caught before the first step runs. Positive int/float timeouts and an
absent `timeout` are unaffected.
Regression tests in `TestPromptStep` mirror the shell step's: validate
rejects "30"/True/inf/nan/0/-5/list/None, validate accepts 300/5/0.5 and
an absent field, and execute fails cleanly with `subprocess.run` patched
to assert it is never reached. With the source fix reverted, all 9
rejection tests fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* test(workflows): cover the huge-int timeout OverflowError guard
The autofix commit wrapped the prompt step's `_timeout_error()` check in
`try/except OverflowError` but added no test, so nothing pins the
behaviour it introduced.
`math.isfinite(10**400)` raises `OverflowError: int too large to convert
to float` — the value is an `int`, is `> 0`, and is not a `bool`, so it
clears every other clause of the guard and reaches `isfinite()`. Without
the `except`, validating
```yaml
- id: ask
type: prompt
prompt: do it
timeout: 1000...0 # 400 digits
```
raises that `OverflowError` out of `validate()`/`execute()` — exactly the
uncaught-crash failure mode this guard was added to prevent. The same
value raises `OverflowError` from `subprocess.run(timeout=...)`.
Add `10**400` to both parametrized rejection lists (`validate()` and the
`execute()` fails-cleanly loop). Test-the-test: reverting the `try/except`
fails both new cases with `OverflowError` and leaves the rest passing.
Assisted-by: Claude Opus 5 (model: claude-opus-5, autonomous)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
`test_validate_rejects_non_string_condition` contradicts its sibling
`test_validate_accepts_string_or_bool_condition` in the same class: a
bool *is* a non-string, so the two names disagree about the contract the
validator actually implements.
Rename to `test_validate_rejects_non_string_non_bool_condition` in all
three step classes, matching the validator's own message: "'condition'
must be a string or boolean, got <type>".
Test names only — no behaviour change, and the parametrized values are
untouched.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`preset catalog add` and `preset catalog remove` interpolate the raw
`--name` and URL into `console.print()`, so Rich parses them as markup.
Two failure modes:
* Silent misreporting — a name like `[bold red]pwned[/]` is printed as
`pwned`, so the confirmed name is not the persisted name and a later
`remove` with the reported name fails.
* Unhandled MarkupError — an unbalanced closing tag raises, and because
the crash happens *after* preset-catalogs.yml is written, the user gets
a traceback for a catalog that was in fact added.
This file already imports `_escape_markup` and escapes name/description/
url in `preset catalog list` (whose invariant `test_catalog_list_escapes_
rich_markup` already pins); `add`/`remove` were the remaining gaps.
Only rendering changes: the raw values are still what get persisted and
what the duplicate-name comparison uses.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>CopilotManfred RiemCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade
Apply the remediation from the bug assessment on issue #3849.
_register_extension_skills() had a skip guard that refused to overwrite
existing SKILL.md files (protecting user customizations). In the upgrade
path, setup() regenerates all core-template SKILL.md files first, then
calls register_enabled_extensions_for_agent(). The guard then sees those
freshly-written core files as 'existing' and skips every extension, leaving
only core template content on disk.
Fix: add force: bool = False to _register_extension_skills() and thread it
through register_enabled_extensions_for_agent() and
_register_extensions_for_agent(). In integration_upgrade(), pass force=True
so extension content layers on top of the just-regenerated core files.
The force flag is off-by-default so plain extension add still protects
user-modified skill files.
Refs #3849
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Potential fix for pull request finding 'Unused local variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* test: add end-to-end regression guard for upgrade-overwrites-copilot-skills (#3849)
The existing regression tests in TestRegisterExtensionSkillsForceFlag exercise
the new force parameter at the helper level, so without the fix they fail only
with a TypeError (unknown kwarg) rather than on the user-facing behaviour.
Add a command-level test that runs 'specify integration upgrade copilot --skills
--force' end-to-end and asserts the installed git extension's SKILL.md is
restored (with its extension content, not a bare core-template stub) when the
skill directory already exists — the exact skill_dir_preexists path the bug
depends on. The test fails on pre-fix source (the skill is never recreated) and
passes with the fix, so it is a genuine behavioural regression guard rather than
an API-surface check.
Refs #3849
Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
`IntegrationManifest.uninstall()` guards every tracked-file `path.unlink()`
with `except OSError: skipped.append(path)`, but the manifest's own
`manifest.unlink()` is bare. The manifest is deleted *last*, so an
undeletable manifest (read-only file, a directory left at the path, a
Windows lock) raises after the tracked files are already gone.
The caller loses the `(removed, skipped)` result and never runs its
post-uninstall bookkeeping — reassigning the default integration,
rewriting/removing `integration.json`, clearing init options — leaving a
removed integration still recorded as installed.
Report it in `skipped` like any other file we could not remove, mirroring
the `path.unlink()` guard above and the same `except OSError:
skipped.append(...)` pattern in kimi's legacy-directory cleanup.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extensions `events` feature changed the "nothing provided" validation
error from "Extension must provide at least one command or hook" to
"Extension must provide at least one command, hook, or event", but
test_empty_provides_and_no_hooks_keeps_its_own_message still asserted the
old wording, so it failed on main. Update the regex and also pop `events`
from the fixture so the test truly exercises the empty-provides path.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 189d67d7-2028-4319-a459-b22919d43a3e
CommandRegistrar.render_toml_command passes the raw frontmatter `description`
straight into `_render_basic_toml_string`, which iterates the value and calls
ord() on each character. Frontmatter comes from yaml.safe_load, so description
can be any YAML type:
description='ok string' -> description = "ok string"
description=None -> TypeError: 'NoneType' object is not iterable
description=42 -> TypeError: 'int' object is not iterable
description=True -> TypeError: 'bool' object is not iterable
description=['a','b'] -> description = "ab" <- silently WRONG value
This is a format-branch asymmetry: it is the only renderer reached from
register_commands' format branches that does not normalise description.
render_yaml_command (same class, ~70 lines below) already does exactly
`if not isinstance(description, str): description = str(description) if
description is not None else ""`, render_markdown_command goes through
yaml.dump which handles any type, and TomlIntegration._extract_description
returns "" for a non-str. So only extension/preset commands rendered for the two
TOML agents were affected.
Apply the same coercion the sibling uses. After: None -> "", 42 -> "42",
True -> "True", ['a','b'] -> "['a', 'b']", each still valid parseable TOML.
String descriptions are untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both places a user learns the `specify self upgrade --tag` syntax silently drop
the `[suffix]` token, because Rich parses the literal square brackets as a
markup tag and discards them:
rejected tag -> "Invalid --tag: expected vMAJOR.MINOR.PATCH"
(constant is "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]")
--help -> "Pin the target version (vX.Y.Z). Without --tag, ..."
So the CLI implies a bare vX.Y.Z is the ONLY accepted form, when v1.0.0-rc1,
v0.8.0.dev0 and v0.8.0+build.42 are all valid -- and the shipped docs advertise
the suffix in four places (docs/upgrade.md x3, README.md x2).
Escape the rejection message at the PRINT site rather than baking `\[` into
_INVALID_TAG_MESSAGE: the same constant is raised through typer.BadParameter,
which Click renders without Rich, so it must stay plain text. Escape the literal
bracket in the option help, which Typer renders through Rich.
Same literal-bracket class as the existing precedents in workflows/_commands.py
(`\[disabled]`, `\[<type>]`). Static CLI text only -- no validation semantics
change and `_validate_tag` is untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
BundleManifest.from_dict read every required scalar as
`str(raw.get(key, "")).strip()`. The `""` default only covers a MISSING key. A
key present but null -- exactly how YAML spells an empty field (`author:` with
nothing after it) -- yields None, and `str(None)` is the literal string "None".
That value is non-empty, so it sailed past the `if not value` required-field
checks in structural_errors().
Reproduced on main:
bundle.yml with description:/author:/license: left empty
-> description='None' author='None' license='None'
-> structural_errors() == []
-> specify bundle validate: exit 0, "demo is well-formed and valid."
So an empty required field was silently accepted and the bundle shipped the
literal text "None" as its author/license/description -- which is what
`bundle info` and a catalog entry then display. A null `provides.<kind>[].id`
likewise became a component literally named "None".
Add a `_text()` helper beside the existing `_parse_str_list` (the file's
established "one coercion helper applied at every site" shape) mapping an
explicit null to "", and route the required scalars through it. Same
silent-acceptance class as the already-merged guards in this function: #3629
(non-mapping `integration:`) and #3661 (falsy non-mapping requires/provides).
Non-null values are still `str()`-coerced and stripped, and an absent key
already produced "" -- so valid manifests are byte-for-byte unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: first-class agent-native runtime hooks for integrations
* refactor: rework integration events per maintainer review
- Rename hooks terminology to 'events' (events:, --events flag, events.py).
- Use snake_case names for canonical events consistent with spec-kit vocabulary.
- Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format).
- Lift event command-script resolution to core 'specify event run' command.
- Split events sourcing from integration config writing.
- Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'.
- Rewrite and expand full test suite under 'tests/integrations/test_events.py'.
Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous)
* fix(events): resolve ruff lint errors blocking CI
Address Copilot review finding #18 (src/specify_cli/__init__.py event-command
import missing # noqa: E402), #19 (unused console import in commands/event.py),
and #20 (unused patch/yaml/Path/integration imports in test_events.py). Also
fix two stray F541 f-string prefixes in _build_opencode_plugin that ruff
flagged in the same job.
Bump dev version 0.14.2.dev0 -> 0.14.2.dev1 and add a CHANGELOG entry per the
AGENTS.md convention for Specify CLI __init__.py changes.
Refs: PR #3704 Copilot inline review (findings #18, #19, #20)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): make generated native hooks actually execute
Address Copilot review findings that left generated event hooks inert or
schema-invalid after the rework:
- #2: the resolved events map now carries an ordered list of handlers per
event (dict[str, list[dict]]) so two extensions declaring the same event
both run instead of the last one silently winning. collect_extension_events
accumulates; every adapter emits one native entry per handler.
- #6: Claude/Gemini/Qwen/Devin/Tabnine native schema accepts a single
'command' string, not command+args. Each adapter now renders one complete
shell invocation of the dispatcher via _dispatcher_command().
- #7: Gemini measures hook timeouts in milliseconds; add events_timeout_unit
attr and _native_timeout() so the 60s default becomes 60000ms instead of
terminating the dispatcher after 60ms.
- #4: _resolve_event_command_argv() replaces _extract_script_path() —
scripts: values are command strings (e.g. 'scripts/bash/setup-plan.sh --json'),
not bare paths. Resolves the project's sh/ps/py variant, splits safely into
argv, and prepends the interpreter for .py.
- #5: bundled-template fallback now uses _locate_core_pack()/_repo_root()
(core_pack/commands, not the non-existent core_pack/templates/commands).
- #16: all formatters use IntegrationBase.resolve_python_interpreter() so
generated commands honor the project venv and never hard-code python3
(absent on Windows). The opencode TS plugin bakes in the same resolved
interpreter.
- #13: opencode TS plugin runEvent() now throws on failure instead of
process.exit(2), which killed the OpenCode host process; only the failing
hook is rejected.
- #21: user YAML override is validated (event names, non-empty command
strings) before returning; a malformed override is warned about and
ignored rather than crashing installation on cfg.get().
Bump dev version 0.14.2.dev1 -> 0.14.2.dev2 (gemini/__init__.py change) and
add a CHANGELOG entry.
Refs: PR #3704 Copilot inline review (findings #2, #4, #5, #6, #7, #13, #16, #21)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): merge/teardown idempotency and data safety
Address Copilot review findings on native-config merge and teardown:
- #9: _has_marker now recurses into nested 'hooks' arrays so a matcher-group
containing Specify-owned inner hooks is recognized and replaced on upgrade
instead of accumulating duplicates.
- #11: _merge_json_fragment strips ALL Specify-marked entries from every event
before adding the new set, so an override that drops an event (pre_tool_use
-> stop) removes the stale marked entry instead of leaving it active.
- #3: an empty resolved map (--events false / disabled override) now runs the
native-config removal path instead of early-returning, so prior Specify
hooks are stripped. The shared dispatcher is left untouched (#10).
- #14: teardown deletes a Spec-Kit-created config that is now empty of user
content (rather than leaving '{}' that confused manifest.uninstall()),
while preserving pre-existing configs with user hooks/settings.
- #10: the shared .specify/events.py dispatcher is deleted only when no other
installed event-capable integration's manifest still references it, so
uninstalling one multi-install integration doesn't break the others.
- #8: Copilot's .github/hooks/speckit.json now merges owned entries (with
markers) into a pre-existing file instead of overwriting, and teardown
removes only owned entries (deleting the file when no user hooks remain).
- #22/#23: JSON/JSONC parse failures in native configs (Claude/Cursor/etc.
and opencode.json) abort the merge with a warning instead of resetting user
content to '{}'.
- #12: write destinations are validated (symlinked-ancestor rejection +
containment) before any bytes are written, so a symlinked .specify or
native config directory can't redirect writes outside the repository.
Refs: PR #3704 Copilot inline review (findings #3, #8, #9, #10, #11, #12, #14, #22, #23)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): honor enabled flag, refresh on extension lifecycle, strict command validation
Address Copilot review findings on sourcing, validation, and lifecycle:
- #1: collect_extension_events now honors the extension registry's 'enabled'
flag — a disabled extension's events are skipped so disabling an extension
actually deactivates its runtime hooks. Adds refresh_integration_events(),
wired into extension add/remove/enable/disable, so installing, removing,
enabling, or disabling an extension regenerates each installed event-capable
integration's native event config (the documented install-after-init flow is
no longer inert, and disabled/removed extension events are stripped).
- #17: validate_events now requires 'command' to be a non-empty string, not
merely truthy, so a value like 'command: [foo]' is rejected at manifest
load instead of rendering into invalid native configuration.
- #15: updated PR #3704 description to the implemented events terminology
(.specify/events.py, events:, --events, integration-events.yml) replacing
the stale bridge.py / runtime_hooks: / --hooks false / integration-hooks.yml
references that no longer match the shipped API.
(#21 — user YAML override validation — was addressed in the prior tier.)
Refs: PR #3704 Copilot inline review (findings #1, #15, #17)
Assisted-by: opencode (model: glm-5.2, autonomous)
* revert: drop CHANGELOG.md/pyproject.toml version bumps from events fixes
Per maintainer request, the events PR no longer carries CHANGELOG entries or
pyproject version revs. This restores both files to their pre-PR (da6c20d9)
state: pyproject.toml back to 0.14.2.dev0 and the [Unreleased] block removed
from CHANGELOG.md. The AGENTS.md version-rev convention for __init__.py
changes is intentionally waived for this PR by maintainer decision.
This also clears the pending merge conflicts with upstream/main on these two
files (upstream's 0.14.2 release commit c0fe0e43): our side now makes no
net change to them relative to the merge-base, so a future upstream merge
takes theirs on both without conflict.
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): compose --events into Copilot/Devin options() (#8, #9)
Copilot and Devin are event-capable, but their options() overrides returned
only --skills without calling super(), so the base class never declared
--events. The documented --integration-options "--events false" opt-out was
therefore rejected as unknown for both adapters.
Both now compose with super().options() (mirroring Codex and Cursor) so
--events is declared alongside --skills. Added a TestEventCapableOptionsCompo
sition test class asserting --events appears in Copilot, Devin, Cursor, and
Codex options() output.
Refs: PR #3704 Copilot review 4790195897 (findings #8, #9)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): Cursor version field, matcher grouping, Copilot cross-OS
Address three Copilot review findings on native-config generation:
- #7: Cursor's .cursor/hooks.json schema requires top-level "version": 1,
but json-flat used _merge_json_fragment() which only writes hooks, so a
freshly generated file was missing the required schema version. Added a
version kwarg to _merge_json_fragment (preserving a user's value if
present) and the Cursor json-flat branch now passes version=1.
- S3: json-nested placed all handlers under the first handler's matcher, so
two extensions registering the same event with different matchers both ran
for the first matcher and neither for the later. Handlers are now grouped
by distinct matcher, emitting one matcher-group per matcher (handlers
sharing a matcher stay in one group).
- S4: Copilot's bash and powershell fields both received the same
host-resolved command, so a config generated on Linux wrote a POSIX venv
path into the PowerShell hook (and vice-versa). _dispatcher_command gains
a target_os kwarg; Copilot now emits an independent POSIX interpreter
(python3) for bash and a Windows interpreter (python) for powershell, so
the checked-in config works on either OS.
Tests: added TestCursorJsonWriting (version present + preserved) and
matcher-grouping regressions (per-distinct-matcher, shared-matcher); updated
the Copilot generation test to assert bash != powershell with OS-appropriate
interpreters.
Refs: PR #3704 Copilot review 4790195897 (findings #7, S3, S4)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): anchor py scripts and prefix ps launcher in command runner
Address two Copilot review findings on the core command runner:
- S2: the py variant called build_python_invocation() on the raw scripts:
command string, which left 'scripts/...' anchored at the project root
instead of under .specify/ (or .specify/extensions/<id>/). Every event
command in a project configured with --script py launched a nonexistent
project-root path. The py branch now shares the same base-anchoring as
sh/ps and prepends the resolved interpreter as argv (no shell quoting
needed for subprocess.run(shell=False)).
- S6: the ps variant returned the .ps1 path as the executable, but Windows
subprocess.run(shell=False) cannot execute a PowerShell script directly,
so event dispatch failed on the default Windows script type. The ps branch
now prefixes argv with 'pwsh -File' (PowerShell 7+), falling back to
'powershell -File' (Windows PowerShell) when pwsh is absent.
Tests: added test_py_variant_anchored_under_specify and
test_ps_variant_prefixed_with_powershell_launcher covering the new argv
shapes (interpreter + .specify-anchored path; launcher -File + path).
Refs: PR #3704 Copilot review 4790195897 (findings S2, S6)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): skip-tracking on parse fail, drop dispatcher claim on retain, honor --events false in refresh, preserve layers on invalid override
Address four Copilot review findings on merge/teardown/refresh safety:
- S5: _merge_json_fragment/_merge_opencode_plugin_ref/_merge_copilot_json now
return bool (wrote). Install branches skip manifest.record_existing() and
created.append() when a merge was skipped on parse failure, so a user's
JSONC/malformed native config is not tracked and manifest.uninstall() can't
later delete the untouched file.
- S1: remove_integration_events now drops this integration's manifest claim on
the shared dispatcher (manifest.remove) even when the file is retained
because another integration references it. Previously the retained file
stayed tracked, so the subsequent manifest.uninstall() in teardown() saw
the matching hash and deleted the file another integration still depended
on. The unit test now exercises full teardown() (not just
remove_integration_events) to cover the gap.
- S7: refresh_integration_events reads each integration's stored
parsed_options via _resolve_integration_options and passes them to
resolve_events, so a persisted --events false is honored across extension
add/enable/disable instead of being discarded (which re-enabled events the
user had disabled).
- #10: an invalid override entry now abandons the entire override and keeps
the accumulated built-in + extension layers, instead of resetting
resolved_override to {} and assigning that empty map to events (which
silently disabled all hooks on a single typo). Only a fully-valid override
(including an explicit events: {}) replaces the prior layers.
Tests: added TestOverridePreserveLayers (invalid entry keeps layers; explicit
empty disables), TestSkippedMergeNotTracked (JSONC not recorded), and
TestDispatcherManifestClaimDroppedOnRetain (full teardown keeps dispatcher
when another integration references it). Added S7 refresh-honors-events-false
regression.
Refs: PR #3704 Copilot review 4790195897 (findings S5, S1, S7, #10)
Assisted-by: opencode (model: glm-5.2, autonomous)
* test(extensions): update stale validation-message assertion
The 'no commands/hooks/events' validation message changed to
'Extension must provide at least one command, hook, or event' when the
events feature added a third provider kind, but test_no_commands_no_hooks
still matched the old 'must provide at least one command or hook' text and
failed on every CI job. Update the regex to the current message.
Refs: PR #3704 CI failure (test_extensions.py:579)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): forced-teardown data safety, manifest-driven command resolution, toml teardown safe-dest
Address three findings from Copilot review 4791088500:
- S9: _remove_native_event_hooks now unconditionally drops this integration's
manifest claim on the native config, not only when the file was deleted.
Previously a config whose owned entries were cleaned but user content
retained stayed tracked, so teardown(force=True) -> manifest.uninstall(
force=True) deleted the entire user-owned settings file. This is the
config-file mirror of the earlier shared-dispatcher fix.
- S8: _find_command_template resolved extension event commands via a broken
registry lookup (the registry stores per-agent registered_commands
name-lists, not a {name, file} map) and a file-stem scan that only matched
when the .md stem equaled the command name. A manifest mapping
speckit.selftest.extension -> commands/selftest.md resolved as missing. It
now enumerates installed extensions via ExtensionManager.get_extension()
and matches provides.commands[].name -> file, with the directory scan and
core-template lookups kept as fallbacks.
- R3: _remove_toml_entries now validates the destination with
_ensure_safe_destination before read/write, matching the merge path, so a
symlink swap of .codex/config.toml after install can't make teardown
overwrite a file outside the project.
Tests: forced full teardown preserves a user settings file; an extension
command whose file stem differs from its name resolves via the manifest;
TOML teardown rejects a symlinked config destination.
Refs: PR #3704 Copilot review 4791088500 (findings S8, S9, R3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): subprocess cwd, shell quoting, TOML matcher escaping, Tabnine ms
Address four findings from Copilot review 4791088500:
- R1: the generated dispatcher and resolve_and_run_event_command now run
their subprocesses with cwd set to the dispatcher-derived project root.
Previously 'specify event run' (and the resolved script) inherited the
agent's working directory, but event_run resolves the project via
Path.cwd(), so a hook fired from a subdirectory targeted the wrong project
and reported the command missing.
- R2: _dispatcher_command now shell-quotes each component (interpreter,
command, event) for the target shell (POSIX via shlex.quote; PowerShell via
single-quoted literals with doubled quotes). An interpreter path containing
spaces or an extension/override command containing shell metacharacters is
passed as a single argument instead of being reinterpreted by the native
hook shell. Claude's prefix is left unquoted so the
shell still expands it (prefix + relative path are fixed, safe strings).
- R4: the Codex TOML matcher is now rendered through the shared TOML escaper
like command, so a matcher containing a quote/backslash/newline/control
character no longer produces malformed config.toml.
- R5: Tabnine declares events_timeout_unit='ms' (its hook schema mirrors
Gemini's BeforeTool/AfterTool), so the 60s default becomes 60000ms instead
of timeout: 60 (60 ms), which would terminate the dispatcher immediately.
Tests: cwd-forced execution from a subdirectory; POSIX/PowerShell quoting of
metacharacter and space-bearing components; TOML matcher with a quote parses
cleanly; Tabnine timeout converts to 60000. Updated the Copilot generation
test for the new quoted args.
Refs: PR #3704 Copilot review 4791088500 (findings R1, R2, R4, R5)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): POSIX dispatcher path constant + platform-agnostic tests
Three Windows test failures, one a real cross-OS bug:
- W1 (bug): EVENTS_DISPATCHER_REL was str(Path('.specify')/'events.py'),
which yields '.specify\events.py' on Windows. Manifest keys are stored in
POSIX form (.as_posix()), so 'dispatcher_rel in manifest.files' was always
False on Windows: the shared-dispatcher manifest-claim drop was skipped and
manifest.uninstall(force=True) deleted the dispatcher another integration
still depended on. Make it a POSIX constant (.as_posix()) so it matches
manifest keys on every platform.
- W2/W3 (tests): the py/ps argv assertions used endswith() and an exact
launcher-name set that broke on Windows backslash paths and the
pwsh.EXE/full-path launcher returned by shutil.which. Compare in POSIX form
and match the launcher by case-insensitive stem.
Refs: PR #3704 Windows CI failures
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): override layer preservation, matcher validation, event command-ref canonicalization
Address four Copilot review findings:
- C4: a malformed override handler (e.g. "stop: []" or "stop: bad-value")
normalizes to no handlers. Previously the entry was skipped and the override
still adopted, so an override whose only entry was malformed silently
disabled every built-in and extension hook. The empty-handler case now
abandons the whole override (keeps prior layers); an explicit "events: {}"
(no entries) remains a valid disable.
- C6: a non-mapping integration entry (e.g. "claude: bad") was coerced to
"events: {}" and treated as a valid explicit disable. It now warns and
abandons the override, keeping the accumulated layers. Only an explicitly
present, mapping-valued "events" field replaces the prior layers.
- C10: matcher is now validated as a string (or absent) in both
validate_events (manifest) and _validate_resolved_event (override). A
non-string matcher such as "matcher: []" previously passed validation but
crashed by_matcher.setdefault(matcher, ...) with TypeError: unhashable
type, aborting init or refresh.
- C11: ExtensionManifest._validate now applies the same rename + alias-lift
canonicalization to event command references that it already applies to
hook references. An event referencing an auto-corrected command (e.g.
my-ext.boot -> speckit.my-ext.boot) previously kept the obsolete name,
so dispatch reported no command and the event silently no-oped.
Tests: empty-handler/non-mapping override preserves layers; non-string
matcher rejected in manifest and abandoned in override; event command ref
lifted to canonical form with a warning.
Refs: PR #3704 Copilot review (findings C4, C6, C10, C11)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): protect shared dispatcher from stale cleanup, delete Cursor version stub, non-destructive refresh
Address three Copilot review findings:
- C3: the shared .specify/events.py dispatcher is now in
events_stale_exclusions(). It is written into every event-capable
integration's manifest but reference-counted across them; an upgrade with
--events false omits events.py from the new manifest, so the generic stale
pass would delete it without the refcount check, breaking any other
installed event-capable integration. Its deletion is left to
remove_integration_events(), which checks the refcount.
- C5: _remove_json_entries now deletes a Spec-Kit-created Cursor file that
retains only {"version": 1} after all owned hooks are removed (we added the
version field), mirroring _remove_copilot_entries. Previously the generic
remover only deleted a literally-empty object, so clean teardown left a
generated stub behind.
- C12: refresh_integration_events now resolves first and calls
install_integration_events once, instead of running the destructive
_remove_native_event_hooks pre-step before resolution. A later failure
(invalid destination, write error, formatter error) no longer destroys the
working native config before the new one is written.
install_integration_events already removes stale Specify-marked entries and
handles an empty map (stripping prior hooks), so the pre-step was both
unsafe and redundant.
Tests: dispatcher in stale exclusions; Cursor version-only stub deleted on
teardown; refresh failure preserves the pre-existing config (no pre-strip).
Refs: PR #3704 Copilot review (findings C3, C5, C12)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): host target uses POSIX quoting, Claude dispatcher double-quoted, & for windows
Address two Copilot review findings on the shell-quoting added in the prior
round (R2):
- C1: _shell_quote("host") now always uses POSIX shlex.quote, not PowerShell
single-quoting on Windows. The single-command-string formats
(Claude/Gemini/Qwen/Devin/Tabnine) are run via the agent's POSIX-ish shell
(Git Bash on Windows), and a single-quoted 'python' is not invoked as a
command by PowerShell without the call operator — so generated hooks failed
to launch the dispatcher on Windows. Safe tokens pass through bare
(python3, speckit.ext.cmd) on every platform. PowerShell single-quoting is
now used only for the explicit target_os="windows" (Copilot's powershell
field), where the quoted interpreter is prefixed with "& " so it is
actually invoked.
- C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is now double-quoted
("${CLAUDE_PROJECT_DIR}/.specify/events.py") so the variable still expands
(double quotes allow expansion in POSIX shells) but a project path
containing spaces no longer word-splits and breaks dispatcher launch.
Tests: host target never emits PowerShell quotes; windows target carries the
& call operator; Claude dispatcher is double-quoted; updated Copilot
generation assertions for the &-prefixed powershell command.
Refs: PR #3704 Copilot review (findings C1, C2)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): opencode TS plugin resolves dispatcher from directory, execFileSync argv, forwards input+output
Address three Copilot review findings on the opencode TS plugin:
- C8: the dispatcher and interpreter are now resolved per-project at plugin
load from the `directory` OpenCode passes to the plugin factory, not
process.cwd(). OpenCode may be launched from a parent directory or host
another workspace, in which case process.cwd() pointed at the wrong project
and every event failed. The resolver prefers a project-local venv
interpreter, then falls back to python3.
- C9: the dispatcher is launched with execFileSync and an argv array
[interpreter, dispatcher, command, event] instead of a shell command string
built by interpolating the interpreter/command/event into a template
literal. Command/event strings are only validated as non-empty, so quotes or
backticks could previously break the generated TypeScript and shell
metacharacters could execute outside the dispatcher; an interpreter path
with spaces also failed. No shell is involved now.
- C7: tool callbacks now forward both `input` and `output` to runEvent
(combined into one JSON payload), so pre_tool_use can inspect the tool
arguments and post_tool_use can inspect the result — the primary payload for
those events. Previously only `input` was forwarded.
Tests: plugin resolves dispatcher/interpreter from `directory` (no
process.cwd() path.join), uses execFileSync (no shell string), and forwards
output to runEvent for both pre/post_tool_use.
Refs: PR #3704 Copilot review (findings C7, C8, C9)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): Qwen ms timeout, Devin root-nested format, Copilot agentStop
Address three Copilot review findings on adapter mappings (verified against
each agent's published hook documentation):
- U1: Qwen Code command hooks measure timeout in milliseconds (default
60000), per the Qwen Code hooks docs. The adapter previously inherited the
seconds default, so every generated handler got timeout: 60 (60 ms) and was
killed before the dispatcher could start. Declare events_timeout_unit="ms".
- U2: Devin's .devin/hooks.v1.json is a root event map ({"PreToolUse": [...]})
with no top-level "hooks" wrapper (the docs state "the hooks object is the
entire file"). The adapter reused json-nested, which writes events under a
"hooks" key Devin never reads. Add a json-root-nested format with a matching
writer (_merge_json_root) and remover (_remove_json_root_entries) that
operate on the root event keys, sharing the matcher-grouping, marker, and
JSONC-abort behavior of the nested variants.
- U3: Copilot CLI supports the canonical per-turn stop lifecycle as native
agentStop; add "stop": "agentStop" to the mapping so an extension's stop
handler fires for Copilot.
Tests: Qwen timeout converts to 60000; Devin events written at the root (no
"hooks" wrapper) and teardown preserves user root entries; Copilot stop maps
to agentStop.
Refs: PR #3704 Copilot review (findings U1, U2, U3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): collect events via validated manifest, surface refresh failures
Address two Copilot review findings:
- R1: collect_extension_events now reads events from a validated
ExtensionManifest (whose command refs were canonicalized at install
validation, C11) instead of the raw extension.yml YAML. Previously an
event command ref like my-ext.boot was normalized to speckit.my-ext.boot
during install validation, but the on-disk YAML kept the obsolete name;
refresh then emitted it and _find_command_template could not match it,
leaving the hook silently inert. Registry-tracked extensions use the
validated manifest; on-disk extensions not yet in the registry fall back
to the raw YAML (preserving the partial-staged-install scan behavior).
- R3: refresh_integration_events now accumulates per-integration failures
and raises EventRefreshError at the end (after refreshing the others) so
the extension lifecycle commands (add/remove/enable/disable) can't claim
an extension was fully deactivated while a stale native hook may still be
active. A new _refresh_events_and_warn helper surfaces the aggregated
failures as a warning at each call site without aborting the overall
command (the extension was already added/removed/enabled/disabled).
Tests: event command ref canonicalized via the validated manifest;
refresh failure raises EventRefreshError (aggregated) while still preserving
the pre-existing config.
Refs: PR #3704 Copilot review (findings R1, R3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): probe venv for specify_cli before selecting it; python on Windows
Address two Copilot review findings on interpreter resolution:
- R2: the dispatcher's _find_specify and the opencode TS resolver both
selected a project-local venv python and ran `-m specify_cli` without
checking that specify_cli is importable there. In a typical project where
Spec Kit is installed globally (or via uv tool) but the project has its own
unrelated virtualenv, every event invoked that interpreter and failed
instead of reaching the PATH `specify` fallback. Both now probe the
candidate interpreter (subprocess `import specify_cli` / execFileSync probe)
before selecting it, falling through to the fallback when the venv lacks
Spec Kit.
- S2: the opencode TS PATH fallback was always `python3`, which is commonly
unavailable on Windows. It is now `python` on Windows
(process.platform === 'win32') and `python3` on POSIX.
Tests: the generated dispatcher contains the _has_specify_cli probe and the
PATH fallback; the opencode TS plugin probes for specify_cli and uses a
platform-appropriate PATH interpreter.
Refs: PR #3704 Copilot review (findings R2, S2)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): serialize opencode TS plugin string literals as JSON
Address Copilot review finding S1: command and matcher values come from
user/extension YAML but were interpolated into single-quoted TypeScript
literals without escaping. A quote, backslash, or backtick in a command or
matcher produced invalid generated TypeScript and could inject code into the
plugin. _build_opencode_plugin now serializes every interpolated value
(command, event name, native hook key, matcher tool names) as a JSON string
literal via json.dumps, which produces a valid double-quoted, fully-escaped
TS/JS string.
Tests: a command and matcher containing quotes/backticks render inside JSON
double-quoted literals; the dangerous single-quoted form is absent. Updated
the forwards-output test for the new double-quoted literals.
Refs: PR #3704 Copilot review (finding S1)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): thread per-handler timeout through dispatcher, bash launcher for sh on Windows
Address two Copilot review findings:
- S4: the dispatcher and inner runner both hardcoded timeout=120, so a valid
handler configured with a timeout above 120 seconds could never run for its
full duration. The resolved per-handler timeout now flows through the chain:
_dispatcher_command appends it (in the integration's native unit, plus a
small buffer) as a 4th argument; the generated dispatcher reads sys.argv[3]
and uses it for its inner subprocess and the `event run` invocation;
`event run` accepts a timeout argument and passes it to
resolve_and_run_event_command, which uses it for the script subprocess.
Defaults to 120s when absent (backward compat with already-deployed
dispatchers that don't pass the arg).
- S5: for a project configured with the sh script type on Windows,
subprocess.run(shell=False) cannot execute a .sh file directly (chmod
doesn't change that). The sh variant now prefixes a bash/sh launcher
(resolved via shutil.which) on Windows, mirroring the ps branch's
pwsh -File handling.
Tests: dispatcher reads the timeout arg and uses it; the native command
appends the resolved timeout; the sh variant uses a launcher on Windows.
Refs: PR #3704 Copilot review (findings S4, S5)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): delete shared dispatcher when last event integration disables events
Address Copilot review finding S3: the empty-resolved-map install path
(--events false upgrade, or override disabling events) stripped prior native
hooks but left the shared dispatcher behind. Because the new manifest no
longer claims it and stale cleanup excludes it (C3), .specify/events.py
became permanently orphaned when this was the last event-capable
integration — uninstall could not remove it.
Extracted the dispatcher refcount cleanup into _cleanup_shared_dispatcher
(shared by remove_integration_events and the empty-map install path) and
called it from the empty-map path so the dispatcher is deleted when no other
installed event-capable integration's manifest references it, while still
being retained when another integration does.
Tests: an --events false upgrade of the last event integration deletes the
dispatcher; with another integration still referencing it, the dispatcher is
retained.
Refs: PR #3704 Copilot review (finding S3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): map user_prompt_submit/stop for Gemini and Tabnine
Address two Copilot review findings on adapter mappings:
- S6: Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
point (verified against Gemini CLI's hooks docs — BeforeAgent fires after
the user submits a prompt, before planning). The mapping omitted
user_prompt_submit, so valid extension handlers were skipped. Added
user_prompt_submit -> BeforeAgent.
- S7: Tabnine's Gemini-compatible schema also provides BeforeAgent and
AfterAgent, but the mapping omitted user_prompt_submit and stop. Added
user_prompt_submit -> BeforeAgent and stop -> AfterAgent so those
extension events fire instead of being warned about and skipped.
Tests: Gemini and Tabnine mappings include BeforeAgent/AfterAgent.
Refs: PR #3704 Copilot review (findings S6, S7)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): correct timeout unit threading through dispatcher and opencode TS
Address two Copilot review findings on the per-handler timeout threading
added in the prior round (S4):
- R2: _dispatcher_command passed _native_timeout(timeout_seconds) as the
dispatcher's 4th argument, but the dispatcher interprets that argument as
seconds. For Gemini/Qwen/Tabnine (ms adapters), 60 seconds became 60000
seconds (~16h). It now passes the raw seconds (no unit conversion). The
+5s buffer moves to the native hook timeout field
(_native_timeout(seconds + EVENT_TIMEOUT_BUFFER)) so the agent's outer cap
fires after the dispatcher's inner subprocess timeout — letting the inner
kill its child cleanly instead of being killed mid-flight (which orphaned
the grandchild script process).
- S3: the opencode TS runEvent hardcoded timeout: 60000 (60s) and invoked the
dispatcher without its timeout argument, so handlers configured above 60s
were killed early while the inner runner defaulted to 120s. runEvent now
accepts a timeoutSec parameter (seconds); execFileSync uses
(timeoutSec + buffer) * 1000 ms and appends String(timeoutSec) to the
dispatcher argv, so both layers honor the per-handler timeout.
Tests: the dispatcher arg is raw seconds for ms adapters (60, not 60000); the
native timeout field carries the buffer (65 for a 60s Claude handler); opencode
runEvent threads the per-handler timeout as the 5th argument.
Refs: PR #3704 Copilot review (findings R2, S3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): skip disabled extensions in _find_command_template and disk fallback
Address Copilot review finding S1: _find_command_template resolved event
commands without filtering enabled: false — the registry loop used
registry.keys() and the raw directory fallback could also rediscover
disabled extensions. If native cleanup is skipped (e.g. a JSONC config
cannot be parsed), a stale hook would therefore continue executing a
disabled extension.
Extracted the disabled-ID logic into _disabled_extension_ids (shared with
collect_extension_events) and applied it to both the manifest-resolution
loop and the on-disk fallback scan in _find_command_template, so a disabled
extension's command is never resolved for dispatch.
Tests: a disabled extension's command resolves to None via both the manifest
loop and the disk-fallback path.
Refs: PR #3704 Copilot review (finding S1)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): delete shared dispatcher regardless of fresh manifest claim
Address Copilot review finding S2: _cleanup_shared_dispatcher gated the
no-other-references deletion on `dispatcher_rel in manifest.files`. An
`integration upgrade --integration-options "--events false"` passes a fresh
manifest (created in _migrate_commands) that never recorded the dispatcher,
so the condition was false even though the old on-disk manifest owned the
file — and stale cleanup explicitly excludes it (C3), leaving
.specify/events.py orphaned after the last integration disabled events.
The refcount deletion now runs independently of whether the new manifest
contains the key; manifest.remove() stays conditional (a no-op when the key
is absent).
Tests: an upgrade passing a fresh manifest (no dispatcher claim) still
deletes the shared dispatcher when no other integration references it.
Refs: PR #3704 Copilot review (finding S2)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): refresh native event config after extension update
Address Copilot review finding S4: the _refresh_events_and_warn helper was
wired to extension add/remove/enable/disable, but not to extension_update,
which replaces the installed extension.yml (remove + install_from_zip).
If an update adds, removes, or changes event declarations, native configs
remained stale until a manual integration upgrade.
extension_update now refreshes once after the update loop finalizes its
successful updates (skipped on rollback/failure), mirroring the other
lifecycle commands.
Refs: PR #3704 Copilot review (finding S4)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): make the dispatcher self-contained for one-time/temporary installs
Address Copilot review finding R1: the dispatcher required a persistent
`specify` executable at runtime. The supported one-time flow runs
`specify init` through a temporary `uvx` environment that is discarded, so
generated hooks later reached the PATH fallback with no `specify` on PATH
and every event failed.
The generated .specify/events.py is now self-contained:
- Preferred path: it imports specify_cli.events.resolve_and_run_event_command
when the package is importable (durable pip/pipx/uv-tool install), which
handles extension manifests whose file stem differs from the command name
and the project's custom script selection, staying in sync with the CLI.
- Fallback path: an inline stdlib-only resolver finds the command template,
parses its scripts: frontmatter, resolves the project's script variant
(reading .specify/init-options.json directly), and runs the script with
the correct launcher (pwsh/bash/interpreter), so one-time and temporary
installs work without a persistent `specify` executable on PATH.
The `event run` CLI command remains available for manual use; the dispatcher
no longer depends on it.
Tests: the dispatcher delegates to specify_cli when importable and falls back
to the inline resolver when it is not; the inline fallback finds the command
template and runs its script end-to-end (shadowing specify_cli with an empty
package to force the fallback); the preferred path also runs end-to-end.
Refs: PR #3704 Copilot review (finding R1)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): validate safe destination on all removers and teardown unlinks
Address Copilot review findings (inline #1, suppressed #2, #3):
- Guard all removers (_remove_json_entries, _remove_copilot_entries,
_remove_json_root_entries, _remove_opencode_entries, _remove_native_event_hooks),
_cleanup_shared_dispatcher, and remove_integration_events with
_ensure_safe_destination(dst) before reading, rewriting, or unlinking.
- Prevents teardown or removal operations from overwriting or unlinking external
files if a config file, plugin path, or .specify directory is replaced with
a symlink post-installation.
Tests: added unit tests in TestSafeWriteDestination covering JSON config,
OpenCode plugin, and TOML teardown symlink rejection.
Refs: PR #3704 Copilot review (findings inline #1, suppressed #2, #3)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): manifest-driven resolution and disabled-extension filter in dispatcher template
Address Copilot review finding (suppressed #1):
- In _EVENTS_DISPATCHER_TEMPLATE's _find_command_template, read
.specify/extensions/.registry to identify disabled extensions (enabled == false).
- Parse provides.commands in each enabled extension's extension.yml to match
command_name to its declared file, so commands whose file stem differs
from the command name (e.g. speckit.selftest.extension -> commands/selftest.md)
resolve correctly when specify_cli is unavailable (one-time uvx installs).
- Skip disabled extensions in both manifest-driven and on-disk fallback scans.
Refs: PR #3704 Copilot review (finding suppressed #1)
Assisted-by: opencode (model: glm-5.2, autonomous)
* fix(events): positive integer timeout validation and OpenCode multi-handler error aggregation
Address Copilot review findings (suppressed #4, #6):
- In validate_events and _validate_resolved_event, validate that timeout (when
present) is a positive integer (isinstance(t, int) and not isinstance(t, bool)
and t > 0). Rejects string, boolean, zero, or negative timeouts at manifest
and override validation time instead of crashing during setup/refresh.
- In _build_opencode_plugin, wrap each runEvent invocation inside _ev() in a
try/catch block, collect error messages, and throw an aggregate error at the
end if any handler failed. Guarantees that all handlers for an event execute
to completion even if an earlier handler throws.
Tests: added TestTimeoutValidation testing string, boolean, and zero timeout
rejections; updated OpenCode plugin merging tests for try/catch error collection.
Refs: PR #3704 Copilot review (findings suppressed #4, #6)
Assisted-by: opencode (model: glm-5.2, autonomous)
ExtensionManifest.REQUIRED_FIELDS only checks key PRESENCE, so a section that is
written but left empty (`provides:` -> None) or given the wrong shape
(`provides: []`) passes it and then fails on first use:
extension: null -> TypeError: argument of type 'NoneType' is not iterable
requires: null -> TypeError: argument of type 'NoneType' is not iterable
provides: null -> AttributeError: 'NoneType' object has no attribute 'get'
provides: [] -> AttributeError: 'list' object has no attribute 'get'
Neither is a ValidationError, so both escape the callers that already handle
malformed manifests. list_installed() catches ValidationError only and has a
deliberate "Corrupted extension" fallback, so a single bad extension took down
the whole command -- reproduced end-to-end:
before: specify extension list -> exit 1, raw AttributeError, no output
after: specify extension list -> exit 0, the good extension listed, the
bad one shown as "Corrupted extension"
Add an isinstance guard for each required section, mirroring the nested guards
already in this function ("Invalid provides.commands: expected a list", "Invalid
hooks: expected a mapping") and _load_yaml's document-root check. Only the three
REQUIRED sections lacked one.
`provides: {}` is unaffected: it is a well-shaped mapping, so an extension that
provides only hooks still validates, and with no hooks it keeps the pre-existing
"must provide at least one command or hook" message. Both are locked by tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(presets): escape installed preset metadata in Rich output
`preset.yml` is user-editable, but the installed-preset display paths
interpolated its fields straight into `console.print`, where Rich parses
`[...]` as a style tag. PR #3773 escaped the *catalog* branch of these
commands; the local branch was left behind, so the same field rendered
correctly from a catalog and incorrectly once installed.
Two failure modes:
- Silent data loss: a description `Does [stuff] nicely` renders as
`Does nicely`.
- Hard crash: an unbalanced tag such as `Broken [/red] tag` raises
`rich.errors.MarkupError`, aborting `preset list`/`preset info` with a
traceback and exit code 1 — the preset cannot be inspected at all.
Escaped the installed branch of `preset list` (name/id/version/
description) and `preset info` (name/id/version/description/author/tags/
repository/license plus the per-template description), and the catalog
branch's tags join that the earlier sweep missed.
`preset resolve` was unescaped throughout: it echoes its own
`template_name` argument, so `preset resolve 'no[/red]such'` crashed on
user input alone. Also escaped the resolved paths, layer sources, and
composition-error message.
Separately, the composition chain's `[{strategy_label}]` was consumed as
a style tag, so every chain line printed a blank label instead of
`[base]`/`[append]`. Escaped the literal bracket as `\[`, matching the
step-graph line in `workflow info`.
Regression tests in `TestInstalledPresetRichMarkup` cover all five
behaviours; each fails before this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(presets): cover catalog tags and resolve escapes
Addresses Copilot review feedback on #3826: two escapes added by the
previous commit had no regression assertion, so they could be reverted
with the suite still green.
- `test_info_escapes_catalog_markup` asserted every catalog field except
`tags`; the new tag assertion only exercised an installed preset. Assert
the rendered tags join in the catalog branch too.
- The escapes on `preset resolve`'s resolved path, layer source, and
composition-error message were untested. Add three cases patching
`PresetResolver` to feed markup through the top-layer line, the no-layer
`resolve_with_source` fallback, and a markup-bearing `resolve_content`
exception.
Test-the-test: with `_commands.py` reverted to the pre-fix revision, 9 of
the 10 markup tests fail (was 5); with the fix applied all 10 pass.
A closing tag cannot be embedded in the mocked path — `Path` treats the
`/` as a separator — so the path assertion uses an opening tag for the
swallowing case and the unbalanced tag rides on the adjacent `source`
field on the same line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
PromptStep._try_dispatch runs `subprocess.run(exec_args, ...)` with an
UNRESOLVED argv[0] -- a bare name like `claude`. On Windows subprocess.run calls
CreateProcess, which does not consult PATHEXT, so an agent CLI installed as a
`.cmd`/`.bat` shim (the usual npm layout) raises FileNotFoundError [WinError 2].
That OSError is swallowed by the method's `except OSError: return None`, and
execute() then reports "CLI not found or not installed" -- even though the
step's own preflight `shutil.which(...)` two lines earlier just found it.
The sibling path does not have this bug: IntegrationBase.dispatch_command (used
by the `command` step) resolves argv[0] through shutil.which first, added in
8e5643d for exactly this reason. Same machine, same integration, CLI present as
a .cmd shim:
type: prompt -> failed "integration 'claude' CLI not found or not installed."
type: command -> completed
Primitive confirmation: bare `subprocess.run(["fakeagent"])` raises
[WinError 2] while `subprocess.run([shutil.which("fakeagent")])` runs fine.
Reuse the path the preflight already resolved (`fallback_cli_path`) instead of
calling which() again, so the shim is executed. On POSIX it is the same
executable, so behaviour is unchanged there.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: bump version to 0.14.4
* chore: begin 0.14.5.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
yamlio.py is the single chokepoint for every bundler read, and its module
docstring states the contract: "All reads/writes go through these functions so
that IO failures degrade into actionable BundlerError rather than raw
tracebacks."
Both readers catch only OSError, but `Path.read_text(encoding="utf-8")` and
`json.load()` raise UnicodeDecodeError on a non-UTF-8 file --
`issubclass(UnicodeDecodeError, OSError)` is False (its MRO is UnicodeError ->
ValueError). So the decode error escaped uncaught:
load_yaml: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff
load_json: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff
In load_json, json.JSONDecodeError does not help: it is a *sibling* of
UnicodeDecodeError, not a parent.
This is realistic rather than theoretical -- on Windows, PowerShell 5.1's
`Out-File` and `>` default to UTF-16, so a hand-edited
`.specify/bundle-catalogs.yml` or records file hits it.
Widen both read clauses to `(OSError, UnicodeError)`, matching the sibling
catalog readers (catalogs.py:101, workflows/catalog.py:336). JSONDecodeError
deliberately stays FIRST so malformed-but-decodable JSON keeps its more
specific "Invalid JSON" message; a regression test locks that ordering.
Write paths are unaffected -- verified that dump_yaml/dump_json do not leak
UnicodeEncodeError (both escape unencodable input), so this stays scoped to the
two read paths.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`workflow run` and `workflow resume` both print the step-progress line as
`f" ▸ [{sid}] {label} …"`. Rich parses the bracketed step id as a style tag,
which produces three failures on main:
1. The id is SILENTLY SWALLOWED on every run -- the only identifying content on
the line. `id: greet` prints " ▸ shell …"; "[greet]" is absent.
2. An id that forms a closing tag FAILS THE WHOLE RUN. `validate_workflow`
places no charset restriction on step ids, so `id: "/"` is a valid workflow;
the callback then raises MarkupError, which propagates into execute()'s
handler -> run persisted as `failed` with empty `step_results`, the step
never executed, exit 1 with a Rich internals error.
3. An id that is a real style (`bold`, `red`) is applied as FORMATTING to the
rest of the line.
The unescaped `label` (from `step_config["command"]`) compounds it.
Escape the literal bracket with `\[` and escape both interpolated values, at
both sites. This mirrors the `\[<type>]` step-graph precedent already in this
file (workflow_info). Escaping only the values is NOT sufficient -- the
f-string's own brackets are what Rich consumes.
Verified through the real CLI: ids `greet`/`bold`/`a]b` now render verbatim, and
`id: "/"` goes from a failed run to `Status: completed`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(integrations): reject empty --commands-dir in generic raw_options
GenericIntegration._resolve_commands_dir has a parity gap: the parsed-options
branch guards emptiness (`if commands_dir:`), but the raw_options fallback
returned the value verbatim with no check. So `--integration-options=
"--commands-dir="` (or `--commands-dir ""`) resolves to `""`, which makes
setup() compute `dest = project_root / "" == project_root` and write every
speckit command file (specify.md, plan.md, ...) directly into the PROJECT
ROOT — silently bypassing the documented "--commands-dir is required"
contract and polluting the repo root.
Apply the same non-empty guard to the raw_options branch so an empty value
falls through to the existing "required" ValueError on every input form.
Non-empty values resolve exactly as before.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(integrations): reject a BLANK --commands-dir, not just an empty one
Self-review follow-up: bare truthiness only closes the empty-string subset. A
whitespace-only value passed both branches (verified: raw "--commands-dir ' '"
returned ' ', parsed {"commands_dir": " "} returned ' '), so command files
still landed in a directory literally named " " instead of failing with the
documented "required" error.
Require a non-BLANK value and normalize the padding, in the parsed branch as
well as raw_options so the two cannot drift apart -- a padded but real value
(" .myagent/cmds ") now resolves to ".myagent/cmds" rather than being rejected,
matching how other padded config references are normalized.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(integrations): use strip() only to test blankness, return the value verbatim
Address review feedback: normalizing with strip() changed EXISTING valid values,
contrary to this PR's "no behaviour change for valid usage" claim -- a quoted
`--commands-dir ' commands '` previously targeted the literal ` commands `
directory and would have started writing to `commands` instead.
The blankness test still uses strip(), but the accepted value is now returned
unchanged, so the fix stays limited to empty/blank input. Test updated
accordingly: a padded non-blank value must round-trip verbatim (quoted in
raw_options, since shlex.split() consumes unquoted padding before this code
sees it).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(presets): guard non-list/non-mapping provides.templates in PresetManifest
PresetManifest._validate iterated provides["templates"] with no shape guards,
unlike the sibling ExtensionManifest. A malformed third-party preset.yml
crashed with a raw TypeError that escapes the install handler's
PresetValidationError/PresetError catch and dumps an unhandled traceback:
templates: 5 -> "'int' object is not iterable"
templates: [null] -> "argument of type 'NoneType' is not iterable"
templates: [5] -> "argument of type 'int' is not iterable"
(and a string/list entry raised the misleading "Template missing 'type',
'name', or 'file'"). Add a container list-guard and a per-entry mapping-guard
that raise a clean PresetValidationError, mirroring ExtensionManifest's
provides.commands guards. Valid manifests (list of mappings) are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(presets): check provides.templates type before emptiness
Address review feedback: the new shape guard sat behind the existing truthiness
check, so a FALSY non-list (templates: 0/false/null/''/{}) still reported the
misleading "Preset must provide at least one template" instead of the type
error. Only truthy non-lists (5, "oops", {"a": 1}) reached the guard, which is
why the original test (templates: 5) passed.
Split the checks: presence -> container type -> emptiness. A falsy non-list now
reports "expected a list"; an EMPTY LIST keeps the "at least one template"
message, since that genuinely is a well-typed container with no templates.
Parametrize the non-list test over truthy AND falsy values, and add a
regression guard for the empty-list message.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(presets): drop the redundant empty-list templates test
Address review feedback: the added test duplicated the pre-existing
test_no_templates_provided -- both set provides.templates to [] and assert the
same "must provide at least one template" error. That test already guards the
empty-list result of the type-before-emptiness ordering, so keeping mine only
added maintenance. Left a pointer comment where it was.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): resolve az via shutil.which so azure-cli token works on Windows
AzureDevOpsAuth._acquire_via_az_cli runs subprocess.run with a bare "az".
On Windows the Azure CLI is installed as az.cmd, and subprocess.run calls
CreateProcess, which does not consult PATHEXT -- so a bare "az" fails with
WinError 2 even after `az login`, and azure-cli token acquisition silently
returns None (the OSError is swallowed).
Resolve the executable with shutil.which("az") (which honors PATHEXT) before
the call, mirroring the maintainer's own fix in integrations/base.py for the
same CreateProcess/.cmd issue. `or "az"` preserves prior behavior (and the
existing not-installed OSError path) when az is absent. POSIX is unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): require an absolute az path so the CWD cannot hijack the lookup
Self-review catch on my own change: resolving with a bare
`shutil.which("az") or "az"` widened an execution surface. On Windows
shutil.which prepends the CURRENT DIRECTORY to the search path (unless
NoDefaultCurrentDirectoryInExePath is set) AND honors PATHEXT, so a stray
.\az.cmd / .\az.bat in the working directory resolves ahead of the real Azure
CLI -- for a credential operation. Verified: with the real az scrubbed from
PATH, shutil.which("az") returns '.\az.CMD'.
Accept the resolution only when it is absolute; otherwise fall back to the bare
"az" (which also preserves the existing not-installed OSError path). A
legitimate install always resolves absolutely, so the Windows .cmd fix this PR
exists for is unaffected. The not-installed and PATHEXT tests are extended with
relative-result cases, all of which fail before this commit.
Note: integrations/base.py resolves executables the same way; hardening that
shared path is a separate concern and is left untouched here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(auth): build the mocked az path with the host's path rules
Fixes the macOS CI failure. The test hardcoded a Windows absolute path, but the
production code calls os.path.isabs() -- on POSIX runners "C:\Program
Files\..." reads as RELATIVE, so the fallback branch ran and argv[0] was "az"
instead of the resolved path.
Construct the path with os.path.join(os.path.abspath(os.sep), ...) so it is
absolute under the host's rules, and assert against that value. The fallback
test's inputs (".\az.CMD", "az.cmd", "./az") are relative under both ntpath
and posixpath, so they were already portable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level
WorkflowCatalog._load_catalog_config parsed the config with
`yaml.safe_load(...) or {}`, then checked `isinstance(data, dict)`. The
`or {}` coerces a FALSY non-mapping top level (`[]`, `false`, `0`, `''`) to
`{}` *before* the guard runs, so those are silently swallowed as "empty
config" and fall back to the built-in defaults -- while a TRUTHY non-mapping
(`5`, a bare list) correctly raises. Same silent-swallow inconsistency the
bundler catalog reader fixed for its own config.
Drop the `or {}` and branch on `None` (empty document / explicit `null`)
explicitly: `None` stays a valid no-op, every non-mapping (falsy or truthy)
now raises the same actionable error. Correct configs are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(workflows): describe the catalog-config fallthrough accurately
The comment said a None return means "no project catalogs, fall back to the
built-in defaults". Both halves were imprecise: _load_catalog_config serves the
project AND user configs, and get_active_catalogs falls through env -> project
-> user -> built-in, so a None from the project layer moves on to the USER
config; the built-in defaults apply only once every layer returned None.
Reword the loader comment and the mirror test docstring. Comments only -- no
behaviour change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workflows): close the same falsy-mask gap in 'catalogs' and StepCatalog
Self-review follow-up: the top-level fix left the identical asymmetry live five
lines below, and again in this file's twin loader.
1. WorkflowCatalog._load_catalog_config: the ``catalogs`` shape check sat behind
an emptiness check, so a FALSY non-list (``catalogs: {}``/``''``/``0``/
``false``) was silently swallowed as "no catalogs" while ``catalogs: 5``
raised. Verified before this commit: ``catalogs: {}`` -> None (no error).
Shape now checked first; absent/explicit-null and empty-list stay no-ops
(matching the bundler's reader).
2. StepCatalog._load_catalog_config -- the step-catalog twin, read the same way
-- still had ``yaml.safe_load(...) or {}``, so falsy non-mappings bypassed its
isinstance guard (``[]`` -> None while ``5`` raised). Same two guards applied,
keeping the two loaders in lockstep.
Eight new parametrized cases, all failing before this commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(workflows): move StepCatalog guards into TestStepCatalog and add the nested case
Address three review points:
1. The StepCatalog regression tests sat inside TestWorkflowCatalog, so a
targeted `pytest ...::TestStepCatalog` run skipped them entirely. Moved into
that class, where the duplicated twin loader belongs.
2. StepCatalog had no nested-value coverage (only top-level). Added the
parametrized falsy ``catalogs:`` case, plus the absent/null/empty no-op
cases. Verified against upstream/main's catalog.py: 8 fail there, pass here.
3. Dropped the inaccurate parity parenthetical. src/specify_cli/catalogs.py
RAISES for missing/empty ``catalogs`` and coerces a null document to {}, so
it is not the behavior this loader matches -- the comment now just states
what changed (only the misreported shapes) without claiming parity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent)
DroidIntegration is an always-skills agent: it installs commands as
.factory/skills/speckit-<name>/SKILL.md and its build_command_invocation
returns the hyphenated /speckit-<name>. But "droid" was missing from every
_invocation_style set, so is_slash_skills_agent("droid", True) returned False
and both HookExecutor._render_hook_invocation and `specify init` next-steps
fell through to the dotted /speckit.<name> form — a command Droid never
registers.
Add "droid" to ALWAYS_SLASH_AGENTS, matching its always-skills siblings
grok/trae/zed/devin (each added there by their own integration PR; droid's
#3587 omitted it).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(integrations): assert Droid is ALWAYS-slash (disabled case too)
Address review: the test only covered ai_skills=True, which would also pass
if Droid were miscategorized as CONDITIONAL_SLASH. Add the ai_skills=False
assertion — True there is what distinguishes an ALWAYS_SLASH agent from a
conditional one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prompt step subprocess.run() had no timeout, allowing a hung
LLM invocation to block the entire workflow engine indefinitely.
The shell step already defaults to 300s timeout.
Add timeout parameter (defaulting to 300s, matching shell step)
and handle subprocess.TimeoutExpired gracefully.
The tag extraction in resolve_github_release_asset_api_url split the
URL path on / and assumed the tag was a single segment at index 4.
Tags containing literal / (e.g. feature/v1) would be split across
multiple segments, causing the tag to be truncated to only the first
part and the asset name to include leftover tag segments.
Fix by reconstructing the tag as all segments between 'download' and
the final asset segment: tag = '/'.join(parts[4:-1]), asset = parts[-1].
* fix(skills): match closing frontmatter delimiter on its own line
SkillsIntegration.setup parsed each command template's frontmatter with
raw.split("---", 2). A bare substring split stops at the first `---`
*anywhere*, so a template whose description embeds `---` (e.g.
"Separate sections with --- markers") truncated the parsed frontmatter:
later keys were dropped, the description fell back to the generic default,
and the leftover frontmatter spilled into the skill body.
Scan for the closing `---` on its own line instead, for both the
description parse and the body strip. The frontmatter block is parsed
unstripped so trailing newlines in literal (|) block scalars still survive,
and the body slice keeps the newline after the marker so output stays
byte-for-byte identical to the old split for well-formed templates.
Adds regression tests covering the dashed-description truncation and the
frontmatter-spilled-into-body cases.
* fix: use bounded read for integration catalog HTTP responses
The integration catalog fetch used unbounded resp.read() to read
HTTP responses into memory. A malicious or misconfigured catalog
server could return an arbitrarily large response causing OOM.
Replace with read_response_limited() capped at MAX_JSON_METADATA_BYTES
(1 MiB), consistent with how other JSON fetch paths in the codebase
(_version.py, _github_http.py, authentication/azure_devops.py) already
enforce bounded reads.
Pass error_type=IntegrationCatalogError so oversized catalogs are
caught by the existing per-entry recovery path in
_get_merged_integrations() rather than aborting the entire merge.
Add regression test verifying oversized responses are rejected as
IntegrationCatalogError and that healthy catalogs remain usable.
Add README.zh-CN.md with a hand-crafted (non-machine) Chinese
translation of the project README, and add a language switcher
link at the top of both README files.
Code blocks, command names, badges, and links are kept identical to
the English source; only prose is translated.
Co-authored-by: yifosheng001 <yifosheng001@ke.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workflows): reject non-string 'condition' in if/while/do-while steps
`if_then`, `while_loop`, and `do_while` validate() confirm `condition` is
present but never that it is a string. execute() feeds it to
`evaluate_condition()`, which returns a non-string as-is and takes `bool()`
of it -- so `condition: [1, 2]` (a list authoring mistake) silently resolves
to `True`, branching wrongly / spinning the loop to `max_iterations`, with no
error reported.
Reject a present-but-non-string `condition` at validation, mirroring the
existing prompt/shell/command 'must be a string' guards. `"true"`/`"false"`
and expressions like `"{{ ... }}"` are strings, so they stay valid.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(workflows): describe the evaluate_expression/evaluate_condition split accurately
Address review feedback: the guard comments attributed the non-string
pass-through to evaluate_condition(), which always returns a bool. It is
evaluate_expression() (called by evaluate_condition) that returns a non-string
unchanged; evaluate_condition then applies bool() to that value.
Reword all four sites (if/while/do-while guards + the mirror test comment) to
name the two stages correctly. Comments only -- no behaviour change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workflows): keep a literal bool 'condition' valid
Self-review catch: the guard rejected EVERY non-string, which broke an input
that previously worked. An unquoted ``condition: false`` is idiomatic YAML and
resolves exactly today -- evaluate_expression passes the bool through and
evaluate_condition's bool() is a no-op (verified: evaluate_condition(False) is
False, (True) is True). The if/while steps even default ``condition`` to the
bool ``False`` themselves, so bool is the field's natural type, not an
authoring mistake.
Accept (str, bool) and reject only the genuinely silent-coercion types
(list/dict/int/float, e.g. condition: [1, 2] is always True). Message updated
to "must be a string or boolean"; the bad-value tests drop True and gain 1.5,
and each step gains a positive bool case.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`workflow search`, `workflow info`, `extension search` and `extension info`
crashed with `TypeError: 'int' object is not iterable` when a catalog entry
carried a scalar `tags:` value (e.g. `tags: 5`). Catalog payloads are
user-editable YAML/JSON, so this shape reaches the display unvalidated.
Both backends already guard their tag *filter* with
`isinstance(raw_tags, list)` — `WorkflowCatalog.search` and
`ExtensionCatalog.search` skip a non-list `tags` cleanly. Only the display
paths were unguarded: they tested truthiness (`if info.get("tags"):`) and
then iterated. A scalar is truthy but not iterable, so `--tag` filtering
survived while plain `search`/`info` rendering blew up.
Note this is distinct from the non-string *element* handling added in
#3746/#3747: coercing elements with `str(t) for t in ...` does not help when
`tags` is not a sequence at all. The fix is the guard the sibling
integration commands already use — `integrations/_query_commands.py:332,402`
gate on `isinstance(tags, list) and tags`. This aligns workflows and
extensions with that reference pattern, leaving all four tag-join display
sites in these modules consistent.
Regression tests drive the full CLI via CliRunner and cover search + info in
one case per module; both fail before the fix with the exact TypeError.
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(presets): tolerate non-string and non-list catalog fields in preset search/info
`preset search` and `preset info` crashed with a raw traceback on catalog
payloads that are valid YAML/JSON but not string-typed. Catalog files are
user-editable, so these shapes reach the code unvalidated.
`PresetCatalog.search` had three unguarded assumptions:
- `--author` called `.lower()` on the raw value → `AttributeError: 'int'
object has no attribute 'lower'` for `author: 789`.
- the query searchable-text join passed raw `name`/`description` through →
`TypeError: sequence item 0: expected str instance, int found`.
- the `--tag` filter iterated `tags` without a list check, so a scalar
`tags: 5` (truthy, not iterable) raised `TypeError: 'int' object is not
iterable`.
PR #3743 fixed only the non-string *elements* of `tags` here; a non-list
`tags` and the `author`/`name`/`description` fields were still unguarded.
The sibling catalogs already handle all of these — `extensions/__init__.py`
and `integrations/catalog.py` coerce with `str(...)` and gate on
`isinstance(raw_tags, list)`. This aligns presets with them.
The same scalar-`tags` crash reached the four display sites in
`presets/_commands.py`, so those now gate on `isinstance(tags, list)`,
matching `integrations/_query_commands.py`. Note `PresetManifest.tags`
returns `self.data.get("tags", [])` and manifest validation does not
enforce list-ness, so a local `preset.yaml` with `tags: 5` validates
successfully and then crashed `preset info` — hence the guard on the
local-manifest branch too.
While here, `preset search` printed tags unescaped, so a tag containing
`[bold]` was silently swallowed as a Rich style tag; it now routes through
`_escape_markup` like the `preset list` line directly above it.
Regression tests in `TestPresetTagsNonString` drive the full CLI path via
CliRunner. All five fail before the fix, each with the exact exception it
targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* chore: regenerate security audit requirements (annotated-doc 0.0.5)
The Security Audit workflow's "Check committed audit requirements are
current" step regenerates requirements with `uv pip compile --upgrade`,
which now resolves annotated-doc==0.0.5. Re-sync the committed snapshot
so the check passes. No pyproject dependency changes; upgrade drift only.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481
* fix(integrations): use native dollar skill invocations
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): preserve skill post-process idempotence
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): preserve literal skill invocations
Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): preserve shared invocation prefix
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): preserve install invocation prefix
Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): preserve dollar refs everywhere
Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(shared-infra): preserve dollar command hints
Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(shared-infra): render native helper prefixes
Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(skills): use invocation-neutral hook guidance
Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology.
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* test(integrations): expect native fallback invocation
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* refactor(integrations): centralize invocation prefix selection
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
* fix(integrations): add Kimi /skill: prefix and fix docstrings
- Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to
_invocation_style.py so Kimi resolves to '/skill:' in skills mode
- Switch invoke_prefix_for_integration() to use get_invocation_prefix()
instead of the binary dollar/slash check
- Update post_process_skill_content docstring (base.py) to cover both
slash and dollar native invocation forms
- Update _resolve_command_refs_in_skill docstring (presets/__init__.py)
to document the dollar-prefixed result alongside slash forms
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix(agents): use get_invocation_prefix for Kimi in register_commands
Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to
/skill:speckit-<name> rather than /speckit-<name>.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix(agents): remove unused is_dollar_skills_agent import
Leftover from replacing the inline ternary with get_invocation_prefix.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix(integrations): use get_invocation_prefix in post_process_skill_content
Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that Kimi's hook-command note is injected as /skill:speckit-git-commit from
the start. This keeps _inject_hook_command_note idempotent for Kimi: the
previous note with its native prefix now matches on repeated passes, preventing
duplicate note injection.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix(presets): use get_invocation_prefix in _resolve_skill_command_refs
Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-*
(which previously relied on the broad post-process body replacement).
Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core
with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver
rather than bypassing it with a pre-resolved string.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* docs(presets): document /skill: form in _resolve_skill_command_refs
Add /skill:speckit-<cmd> to the docstring so the contract covers all
three native prefix forms returned by get_invocation_prefix.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* test(integrations): add Kimi /skill: prefix coverage
- test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix
- test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill:
for kimi (skills), / for kimi (non-skills), $ for codex, / for claude
- test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command
note is injected with /skill: prefix and does not duplicate on re-runs
- test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash
formatter outputs /skill:speckit-plan when installed with /skill: prefix
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
* fix(kimi): use get_invocation_prefix in process_template, remove broad replacement
process_template() was still using a binary is_dollar_skills_agent ternary
to select between dollar and slash prefix, so Kimi tokens were emitted as
/speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-')
in KimiIntegration.post_process_skill_content(). That broad replacement would
also rewrite any literal /speckit-* text in generated skill content, contrary
to the PR's token-only behavior.
- Use get_invocation_prefix(agent_name, invoke_separator == '-') in
process_template() so Kimi tokens are emitted as /skill:speckit-* directly.
- Remove the broad .replace() from KimiIntegration.post_process_skill_content();
it is now a no-op (tokens are already correctly prefixed at source).
- Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Issue #3737 asked the /constitution command to synchronize constitutional
guidance into every effective task/plan/spec template, including active
preset-provided replacements. This changes the fix's direction: rather than
teach the command to discover and edit more template layers, it removes the
template-propagation behavior entirely.
Why this is the correct fix:
- The governed templates do not embed constitutional content. plan-template
carries a runtime placeholder ("[Gates determined based on constitution
file]") and spec-template/tasks-template reference no principles at all.
- The consuming commands read .specify/memory/constitution.md at runtime and
derive their Constitution Check gates live (plan, tasks), and analyze is the
dedicated drift checker that validates spec/plan/tasks against the
constitution. Enforcement is therefore already automatic and always current.
- Statically editing template files fights the preset/override composition
system: a replace preset shadows an edited core template entirely, and a
hand-edited versioned preset file is clobbered on its next update. Presets and
extensions are formalized, versioned artifacts the command must not mutate.
So the original bug (constitution edits missing active preset templates) is
resolved by not propagating at all: the runtime read is the single source of
truth. The /constitution command is scoped to its own artifact — it drafts and
writes the constitution and reports a Sync Impact Report changelog, and no
longer reads, edits, or reports on plan/spec/tasks/preset/extension templates.
Refs #3737
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5b80589c-74e8-42e5-b2cb-7a7e0d69a964
* chore: bump version to 0.14.3
* chore: begin 0.14.4.dev0 development
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-28 09:15:50 -05:00
227 changed files with 26145 additions and 1813 deletions
- If you host a bundle catalog, test catalog installation with `specify bundle catalog add <catalog-url> --id <catalog-id> --policy install-allowed` and `specify bundle install <bundle-id>`
- If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project
**After submitting:** a maintainer applies the `bundle-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
- Create a GitHub release with a version tag (e.g., v1.0.0)
- Test installation: `specify extension add <extension-name> --from <your-release-url>`
**After submitting:** a maintainer applies the `extension-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
- Create a GitHub release with a version tag (e.g., v1.0.0)
- Test installation from the release archive: `specify preset add --from <download-url>`
**After submitting:** a maintainer applies the `preset-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
@@ -415,36 +415,28 @@ Some agents require custom processing beyond the standard template transformatio
### Copilot Integration
GitHub Copilot has unique requirements:
GitHub Copilot uses skills by default, scaffolded as
`speckit-<name>/SKILL.md` under `.github/skills/`.
-Commands use `.agent.md` extension (not `.md`)
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
via `--integration-options="--commands"`. When enabled:
- Commands use `.agent.md` extension under `.github/agents/`
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs`.vscode/settings.json` with prompt file recommendations
-Context file lives at `.github/copilot-instructions.md`
-`.vscode/settings.json` is merged with prompt file recommendations
-`build_command_invocation()` returns bare args for `--agent` dispatch
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
-`post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
-`build_command_invocation()` returns `/speckit-<stem>` instead of bare args
In the default skills mode, no companion prompts or VS Code settings merge are
created, and `build_command_invocation()` returns `/speckit-<stem>`.
The two modes are mutually exclusive — a project uses one or the other:
@@ -509,6 +501,11 @@ When an issue exists, include its number immediately after the prefix — this i
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
### Opening pull requests
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
### Commits
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
@@ -55,6 +55,8 @@ Here are a few things you can do that will increase the likelihood of your pull
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request.
### Branch naming
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
@@ -85,7 +90,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses`$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI and Command Code in skills mode use `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
@@ -131,7 +136,7 @@ For detailed step-by-step instructions, see our [comprehensive guide](./spec-dri
Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
```
### 5. 制定技术实现方案
使用 **`/speckit.plan`** 命令提供你的技术栈和架构选择。
```bash
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,也包括基于 IDE 的助手。完整列表以及相关说明和使用细节,请参阅[支持的 AI 编码助手集成](https://github.github.io/spec-kit/reference/integrations.html)指南。
运行 `specify integration list` 可查看当前安装版本中所有可用的集成。
## 可用的斜杠命令
运行 `specify init` 后,你的 AI 编码助手就能使用这些斜杠命令来进行结构化开发。对于支持技能模式的集成,传入 `--integration <agent> --integration-options="--skills"` 会安装助手技能,而不是斜杠命令的提示词文件。
@@ -25,15 +25,18 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) |
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) |
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
@@ -50,6 +53,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| ContextForge MCP | Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase | `code` | Read+Write | [contextforge-mcp](https://github.com/capatinore/contextforge-mcp) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
@@ -66,9 +70,11 @@ The following community-contributed extensions are available in [`catalog.commun
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Keel Discovery | A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
@@ -141,7 +147,10 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) |
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
| SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
@@ -150,6 +159,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs. They are useful read-only examples of completed flows, but they are not official golden outputs for generated specs, plans, or tasks.
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
Some environments (corporate networks, mirrors, proxies, or artifact feeds) require installing `specify-cli` from a package index other than the default public PyPI. Each Python tool exposes a way to point at a different index — configure it before running the install commands above. Substitute your own index URL for the placeholder shown here.
```bash
# uv — via environment variable (applies to the whole command)
> The same index configuration applies to pinned installs, upgrades (`--force`/`--upgrade`), and one-time usage — set the environment variable or flag on those commands too. If your index requires authentication, follow your tool's documentation and prefer credential environment variables, keyring, or netrc; do not embed secrets in command-line URLs because they can leak through shell history, process listings, or logs. Avoid committing secrets. For fully offline installs, see the [air-gapped installation guide](air-gapped.md).
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade and how to [install from a custom or private package index](install/pypi.md#install-from-a-custom-or-private-package-index).
@@ -5,8 +5,7 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
> [!NOTE]
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> [!NOTE]
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
## Recommended Process
@@ -83,7 +82,7 @@ Generates the design artifacts from the spec. This is where implementation detai
### Step 5: `/speckit.checklist` — validate the spec
Generates a quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down.
Generates a custom quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down. These custom checklists are reviewer-owned requirements-quality review artifacts: mark an item `[x]` only when the reviewer determines that requirement-quality criterion is satisfied. Checked custom items do not mean implementation work is complete.
```text
/speckit.checklist
@@ -107,7 +106,7 @@ Reports conflicts, gaps, and ambiguities across `spec.md`, `plan.md`, and `tasks
### Step 8: `/speckit.implement` — build it
Executes the tasks in `tasks.md` in dependency order. Run it once to build everything, or scope it to one phase at a time for large features.
Executes the tasks in `tasks.md` in dependency order. Before implementation, it reads checklist checkbox state as a gate and asks before proceeding if any checklist items are unchecked; it does not change any checklist files or markers. The built-in `checklists/requirements.md` checklist is maintained by `/speckit.specify` and `/speckit.clarify`, while custom checklists remain reviewer-owned. Run it once to build everything, or scope it to one phase at a time for large features.
```text
/speckit.implement
@@ -136,5 +135,6 @@ Checks the codebase against the spec, plan, and tasks. If it finds gaps, it appe
- See the [Agentic SDD](reference/agentic-sdd.md) reference for full detail on every command
- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance
- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository
- Compare the [core templates](https://github.com/github/spec-kit/tree/main/templates) with
[community walkthroughs](community/walkthroughs.md) to see how Spec-Driven Development is used in real projects
- Explore the [source code on GitHub](https://github.com/github/spec-kit)
@@ -23,6 +23,8 @@ Creates or updates the project **constitution** — the guiding principles that
Creates or updates the feature **specification** from a natural-language description. Focus on the **what** and **why** — the user-facing behavior and goals — not the tech stack, which belongs in `/speckit.plan`.
This workflow may also maintain `checklists/requirements.md`, the built-in spec-quality checklist that `/speckit.specify` creates and `/speckit.clarify` re-evaluates. That lifecycle is separate from custom checklists generated by `/speckit.checklist`.
```text
/speckit.specify Build an application that helps me organize photos into albums grouped by date, re-orderable by drag-and-drop on the main page, with a tile preview inside each album.
```
@@ -37,6 +39,8 @@ Asks up to five targeted questions about underspecified areas of the current spe
Clarifying before planning keeps you from designing on top of ambiguity. If `/speckit.analyze` later surfaces requirement gaps, come back and run `/speckit.clarify` (or `/speckit.specify`) again.
When `checklists/requirements.md` exists, `/speckit.clarify` may update its evaluated state as part of tightening the spec. This exception applies only to the built-in requirements checklist, not to custom review checklists.
## `/speckit.plan`
Runs the planning process to generate design artifacts from the spec. This is where implementation detail belongs — provide your tech stack, architecture, and technical constraints as arguments.
@@ -49,6 +53,8 @@ Runs the planning process to generate design artifacts from the spec. This is wh
Generates a quality checklist for the feature — think of it as **"unit tests for your requirements."** Rather than testing code, it checks whether the spec itself is complete, clear, unambiguous, and consistent (for example: "Are the drag-and-drop rules defined for every column?", "Is behavior specified for a deleted assigned user?").
Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts. An agent may help evaluate them when explicitly asked, but implementation must not silently self-approve them. In a custom checklist, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does not mean implementation work is complete.
Run it with no arguments for a broad pass, or pass a focus area to target one aspect:
```text
@@ -59,7 +65,7 @@ Run it with no arguments for a broad pass, or pass a focus area to target one as
/speckit.checklist Focus on the Kanban board interactions and comment permissions.
```
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down.
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down, then mark each custom checklist item `[x]` only after the requirements-quality criterion has been reviewed and satisfied.
## `/speckit.tasks`
@@ -83,6 +89,8 @@ Run it before implementing, while the artifacts can still be adjusted cheaply. I
Executes the tasks in `tasks.md`, running each phase in dependency order and respecting parallel markers.
Before executing tasks, it reads checklist checkbox state as a gate. Checklist markers are read-only for this command: `/speckit.implement` counts checked and unchecked items and asks before proceeding when any are unchecked, but it must not change checklist markers. For custom checklists, checked items mean reviewer approval of requirements quality, not completed implementation work.
For a small feature, run it once to build everything:
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing*`.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
> **Two resolution axes.**`SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
> **Version control.**`specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
> **Symlinked project roots.**`SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-<command>` |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md`plus`.github/prompts/*.prompt.md`and a`.vscode/settings.json`merge). Without this flag, install warns that legacy markdown mode is deprecated. |
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md`commands with`.github/prompts/*.prompt.md`companions and merge`.vscode/settings.json`instead of using the default skills layout. |
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
Example:
@@ -278,6 +280,7 @@ The currently declared multi-install safe integrations are:
Lists installed presets with their versions, descriptions, template counts, and current status.
Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files.
@@ -39,6 +39,21 @@ specify workflow run my-pipeline.yml --json
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
For `failed` and `aborted` runs, the payload includes an `error` field carrying the terminal step's error message:
```json
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "failed",
"current_step_id": "boom",
"current_step_index": 0,
"error": "Command exited with code 3"
}
```
`completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status <run_id> --json` surfaces the same message after the fact.
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
| `--dev` | Install from a local workflow YAML file or directory |
| `--dev` | Install from a local YAML file, package directory, or archive |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
archive. Archives may contain `workflow.yml` at the root or inside one
top-level directory.
Directory and archive installs preserve the complete workflow package,
including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz`
archives follow the same validation and installation behavior.
## Workflow Overlays
@@ -266,7 +288,9 @@ Lower priority values have higher precedence. Change this overlay to `priority:
### Interaction with Bundles and Updates
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
`specify workflow add <local-directory>` installs the complete local workflow
package into `.specify/workflows/<id>/`. Archive installs preserve the same
package contents.
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
@@ -554,6 +578,64 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
### Gate Verdict Inputs
`verdict_input` binds a gate's verdict to a named workflow input. The input must be declared in the workflow's `inputs` block; `specify workflow validate` reports an undeclared reference.
`verdict_input` is not supported inside a `fan-out` template. Fan-out items
share workflow inputs, while workflow state can represent only one paused
gate. Place a gate before the fan-out to approve the whole batch, or after a
fan-in to review the aggregated results.
**Input value semantics:**
| Value | Behavior |
|---|---|
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; `output.choice` is set to the configured option spelling |
| Non-empty string, no match | Gate fails immediately |
| Non-string | Gate fails immediately |
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
**Default value semantics:** A non-empty `default` is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
- `commands`: Array of command objects (at least one command or hook is required)
- `commands`: Array of command objects
- `templates`: Array of template objects
- `scripts`: Array of script objects
`hooks` and `events` are separate top-level manifest fields (siblings of
`provides`, not nested under it — see [`hooks`](#hooks) below). At least one
of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
`events` is required.
**Command object**:
@@ -188,6 +195,21 @@ What the extension provides.
- `description`: Command description (optional)
- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)
**Template object**:
- `name`: Template name (lowercase, alphanumeric, hyphens — e.g. `myext-template`)
- `file`: Path to template file (relative to extension root)
- `description`: Template description (optional)
**Script object**:
- `name`: Script name (lowercase, alphanumeric, hyphens — e.g. `myext-collect`)
- `file`: Path to script file (relative to extension root)
- `description`: Script description (optional)
- `runtimes`: Runtimes the script supports (optional, array; subset of `bash`, `powershell`, `python` — informational only, not used to select or invoke the script)
Extension-provided templates and scripts always resolve as `replace`; a manifest that includes a `strategy` key on one of these entries is rejected with a `ValidationError`. Composable strategies (`wrap`/`prepend`/`append`) are preset-only.
@@ -151,7 +151,7 @@ To submit your extension to the community catalog, file a new issue using the **
### What Happens After You Submit
1. Your issue is automatically labeled and assigned to a maintainer for review
1. A maintainer reviews the issue during issue triage and applies the `extension-submission` label, which starts the automated catalog validation. On this public repository, contributors cannot apply that label themselves, so there is nothing to label or re-request — the issue simply waits in triage.
2. A maintainer verifies that the catalog entry is complete and correctly formatted
3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README
4. Your extension becomes discoverable via `specify extension search`
"name":"adrkit — decision memory for spec-driven development",
"id":"adrkit",
"description":"Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact.",
"description":"Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase.",
"description":"Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength.",
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. External coverage continued across developer blogs and industry press. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
***
@@ -94,7 +94,7 @@ On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub S
### Developer Articles and Blog Posts
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
April produced a steady stream of external articles.
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
This edition covers Spec Kit activity in July 2026 — a month of hardening and expanding the envelope. Twenty-eight releases shipped (v0.12.3 through v0.15.1), crossing three minor bumps and delivering three headline capabilities: the **`assess` "Idea Assessment Pipeline" extension**, which pushes spec-driven development *upstream* of the spec to answer "should we even build this?"; the new **`py` (Python) script type** and the broad shell→Python port that underpins it; and a **first-class agent-native runtime events layer** for integrations. Beneath the features, the month's dominant engineering theme was a sustained **security-hardening wave** — bounded HTTP reads, strict redirect validation, TOCTOU-race elimination, and defensive validation across the workflow engine. Externally, coverage broadened structurally: mainstream tech press (heise online) covered the v0.13 `assess` release in two languages, and a **companion-tooling ecosystem** bloomed around the project — spec↔code drift detectors, model-sizing advisors, and testing-gap tools all built *on top of* Spec Kit. A summary is in the table below, followed by details.
| Twenty-eight releases shipped (v0.12.3–v0.15.1), crossing v0.13, v0.14, and v0.15. Headline features: the `assess`**Idea Assessment Pipeline** extension (capture→evidence→refine→design→go/clarify/kill), the new **`py` script type** plus a shell→Python port of the core scripts, git extension, and agent-context updater, and an **agent-native runtime events layer** for integrations. Three agents joined (Grok Build, Factory Droid CLI, Alquimia AI), the label-driven **bug-fix/bug-test** automation completed the triage pipeline, and a heavy **security-hardening** wave landed. The repo grew from ~117,400 to **124,655 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 125 to **144 entries**; presets reached **29**, community workflows **2**, bundles **1**. **heise online** covered the v0.13 `assess` release in English and German. Coverage shifted toward comparisons, companion tooling, and "who verifies the spec?" critiques. **~258 contributors** now listed. | A **companion-tooling ecosystem** emerged — artgraph (deterministic spec↔code drift), SpecJudge (model right-sizing), GAUNTLEX (security-testing gap), and custom skills like `speckit-next` and `prefill`. Comparisons increasingly pit Spec Kit against Kiro; balanced reviews keep flagging documentation proliferation and cognitive load, precisely the gaps the `assess` upstream step and the drift/companion ecosystem are built to close. |
***
> **Hardening the Foundation, Expanding the Envelope.** If June was defined by external validation, July was defined by internal consolidation and reach. No single release carried the weight of `converge` or `bundle`, but the month moved the project in two directions at once. It reached *upstream* — the new `assess` pipeline lets a team evaluate an idea (capture evidence, refine, design, then go/clarify/kill) *before* a spec exists, extending SDD past the spec into the decision to build. And it reached *down to the metal* — a new `py` script type and a systematic port of the core scripts, git extension, and agent-context updater from shell to Python, alongside a security-hardening wave that bounded every HTTP read, validated every redirect hop, eliminated file-race conditions, and taught the workflow engine to fail loudly instead of crashing on malformed input. Meanwhile the ecosystem answered the project's most-cited critique — "who verifies the spec, and who reads all this documentation?" — not with complaints but with *code*: a wave of companion tools built directly on Spec Kit artifacts. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
## Spec Kit Project Updates
### Releases Overview
**v0.12.3–v0.12.18** (July 1–17) was the month's longest patch run and carried two features amid heavy hardening. The **`py` script type** landed (#3285), adding Python interpreter resolution alongside the existing `sh`/`ps` options, and the **label-driven bug-fix (#3258) and bug-test (#3239) agentic workflows** completed the `bug-assess → bug-test → bug-fix` triage pipeline. The systematic shell→Python port began here: the **`update-agent-context` script** (#3387), the **git extension scripts** (#3400), and a **`check-prerequisites` proof-of-concept** (#3302) were all ported. **PyPI was documented as a first-class second install route** (#3516). New agents arrived — **Grok Build** (#3535) as a skills-based integration — while **Roo Code was retired** as a shut-down product (#3212). The rest was a broad defensive-validation sweep across the workflow engine (case-insensitive gate reject, quote-aware interpolation, host-less catalog-URL rejection, and dozens of "fail loudly on malformed input" guards). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.13.0–v0.13.4** (July 17–22) delivered the month's headline feature: the **`assess` Idea Assessment Pipeline extension** (#3568), a pre-spec evaluation flow. The release also completed the Python port of the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386) — and added **Azure DevOps `az`-CLI token acquisition** hardening (#3527), **community bundle submission automation** (#3553), and the standalone **`WorkflowResolver`** refactor (#3557). **Factory Droid CLI** joined as an integration (#3587), **Bob was updated to a skills-based layout for Bob 2.0** (#3415), the **`pipeline` workflow** was added to the community catalog (#3338), and the **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.14.0–v0.14.4** (July 23–29) crossed a minor with a **security-hardening focus**: **bounded HTTP reads and strict redirect enforcement** (#3140, #3671), **secured extension/preset archive downloads** (#3141), and the **removal of the `shell` parameter from `run_command`** (#3716). The **git extension gained configurable Conventional Commit support** (#3413), the wheel now **bundles `scripts/python`** so `--script py` works from a clean install (#3665), and **Alquimia AI** joined as the month's third new agent (#2734). Documentation added a **Simplified Chinese README translation** (#3740), and the constitution stopped **propagating guidance into templates** (#3790). A long run of bundler, preset, and integration validation fixes rounded out the cluster. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.15.0–v0.15.1** (July 30–31) closed the month with a **first-class agent-native runtime events layer for integrations** (#3704) — the release's headline — plus a continued security pass: **TOCTOU-race elimination in file-unlink calls** (#3811, #3815, #3819), **UTF-8 encoding on registry file opens** (#3810, #3816), and **hardening of the extension URL-download cache against symlink/junction races** (#3869). Workflows gained the ability to **bind a gate verdict to a workflow input via `verdict_input`** (#3725), an opt-in **`constitution-sync` preset** shipped (#3873), the **`yolo` workflow** was added to the community catalog (#3864), and installs gained **tar-archive support** (#3874). [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Idea Assessment Pipeline: `assess`
July's headline feature was the **`assess` extension** (#3568), an *idea-assessment pipeline* that ships as an opt-in extension and extends spec-driven development one step further upstream than it has ever reached. Where the core lifecycle begins at `/speckit.specify` — assuming the decision to build has already been made — `assess` addresses the question that comes *before* the spec: **should this idea be built at all, and is it understood well enough to specify?**
The pipeline runs a staged flow — **capture → evidence → refine → design → decision** — that takes a raw idea, gathers supporting evidence, refines it into something concrete, sketches a design, and terminates in an explicit **go / clarify / kill** verdict. A `go` feeds a well-formed problem into the existing `/speckit.specify` step; a `clarify` routes back for more information; a `kill` stops work before a line of spec is written. Its input is just an idea — pasted text, a URL, a ticket, or a codebase pointer — so the pipeline works **equally well on an empty, freshly-initialized project or on an existing codebase** (#3732); a team can evaluate a green idea before any scaffolding exists, or assess a change against a repo that already has one.
The feature drew the month's most prominent mainstream-press coverage: **heise online** ran *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German, framing `assess` as the notable addition of the 0.13 line alongside the Azure DevOps CLI support and the bundler/preset validation fixes. Coming from a major European technology outlet rather than a developer blog, it was a signal that Spec Kit's release cadence is now tracked as mainstream tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
### The Python Migration: the `py` Script Type
Spec Kit's second July theme was quieter but structurally important: the project began migrating its shell scripts to **Python**. The new **`py` script type** (#3285) joins `sh` (bash) and `ps` (PowerShell) as a third option at `specify init`, backed by Python-interpreter resolution that skips broken stubs (including the Windows Store `python3` alias, #3385). The `py` type is the project's answer to the perennial bash/PowerShell parity tax — every script fix previously had to be written twice and kept in sync, a recurring source of the Windows-parity bugs that filled prior months' changelogs.
Behind the new type, a systematic port landed piece by piece across the month: the **`update-agent-context`** updater (#3387), the **git extension scripts** (#3400), a **`check-prerequisites`** proof-of-concept (#3302), and finally the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386). The wheel was updated to bundle `scripts/python` so `--script py` works from a clean PyPI install (#3665), and the installation docs and init option table were updated to document the new type and the sh/ps migration plan (#3284, #3640). The end state is a single, cross-platform script implementation that removes an entire class of parity bugs. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent-Native Runtime Events
The v0.15.0 headline was a **first-class agent-native runtime events layer for integrations** (#3704). It bridges Spec Kit to the host agent's own lifecycle via a set of canonical, snake_case event names — `session_start`, `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `stop`, and `session_end`. A lightweight, zero-dependency **Event Dispatcher** (`.specify/events.py`) is scaffolded during `specify init`, and per-integration **Event Adapters** translate each canonical event into the agent's *native* hook configuration — `.github/hooks/speckit.json` (bash/PowerShell variants) for **Copilot CLI**, `.claude/settings.json` for Claude Code, `.cursor/hooks.json` for Cursor, `.codex/config.toml` for Codex, a TypeScript plugin for opencode, and native settings merges for Gemini, Qwen, Devin, and Tabnine — so extension authors declare `events:` in `extension.yml` once and never learn agent-specific names. Resolution is a four-tier stack (CLI `--events false` → user `.specify/integration-events.yml` override → extension-declared events → built-in defaults), and multiple extensions declaring the same event all run. The change accompanied a broader integration-refinement run: agents that use an always-slash invocation (Droid, Forge, Cline) now render hyphenated `/speckit-<name>` commands correctly (#3688, #3642, #3622), native skill-invocation prefixes are preserved (#3663), and several agents (kiro-cli, Lingma, Pi, omp) were declared multi-install-safe. The through-line is that integrations are increasingly *native* to each agent rather than a lowest-common-denominator overlay. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Security-Hardening Wave
The dominant engineering theme across all twenty-eight releases was **security and robustness**. The work fell into three bands. **Bounded I/O:** every catalog, download, and bundle HTTP response is now read under a byte cap with strict redirect validation on every hop (#3140, #3671, #3763, #3141), closing a class of unbounded-read / DoS exposure. **Race elimination:** TOCTOU races in file-unlink and state-file handling were removed (#3811, #3815, #3819), the extension URL-download cache was hardened against symlink and junction races (#3869), and registry file opens were pinned to UTF-8 (#3810, #3816). **Injection and input hardening:** the `shell` parameter was removed from `run_command` (#3716), user-supplied catalog metadata is escaped in every discovery/list/`init` output path (#3772, #3773, #3774, #3806, #3826, #3863), and catalog URLs are re-validated *after* redirects to preserve HTTPS/host guarantees (#3523, #3524).
Running alongside this was a systematic **"fail loudly, don't crash"** campaign across the workflow engine and catalog loaders: dozens of PRs replaced raw `ValueError`/`OverflowError`/crash paths with clean validation errors on malformed input — non-string commands, prompts, integrations, and models; non-list branches and `wait_for` entries; `priority: .inf` and boolean priorities; non-mapping manifest blocks; and superscript-digit gate prompts. The entire month's hardening arrived as prevention rather than response. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Workflow Engine & Bundles Mature
Beyond hardening, the **workflow engine** kept gaining capability. Steps can now read their **workflow source directory** (#3469), the shell and prompt steps got **configurable, validated timeouts** (#3404, #3847, #3768), a **gate verdict can bind to a workflow input** via `verdict_input` (#3725), and the **`WorkflowResolver`** was extracted as a standalone component (#3557). Two community workflows reached the catalog — the guided **`pipeline`** (#3338, which chains into the core `/speckit.converge`) and **`yolo`** (#3864) — bringing the standalone-workflow count to two.
The **bundle subsystem** introduced in June matured through a long tail of correctness work — reproducible builds via canonical POSIX arcnames (#3658), literal UTF-8 manifest dumps (#3660), strict rejection of malformed `requires`/`provides`/`integration`/`catalogs` blocks, and a clean `BundlerError` on malformed download URLs (#3586). **Community bundle submission automation** landed (#3553) and the **SicarioSpec Security & Governance Bundle** became a cataloged community bundle (#3636), making bundles a live community-submittable artifact type in practice. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent Integrations
The agent portfolio grew net **+3 to 37 integrations**. Three joined — **Grok Build** (#3535), **Factory Droid CLI** (#3587, closing the 300+-day #822), and **Alquimia AI** (#2734) — while **Roo Code** was retired as a shut-down product (#3212). **Bob** was migrated to a skills-based layout for Bob 2.0 (#3415), **Kilocode** now installs commands under `.kilo/commands` (#3672), and a broad correctness pass fixed hyphenated-command rendering and dispatch for the always-slash agents (Droid, Forge, Cline) and preserved native skill-invocation prefixes (#3663). The pattern continues from June — pruning dead products while making the surviving integrations more native to each agent. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension & Preset Ecosystem
The community extension catalog grew from 125 to **144 entries** during July — nineteen net additions. Community presets grew from 23 to **29**, community workflows reached **2**, and the first community **bundle** (SicarioSpec) was cataloged.
The catalog also showed strong maintenance activity: **DocGuard — CDD Enforcement** advanced through several releases (to v0.33.0), **Verify Review Ship** and **Quality Gates (Enforcement Layer)** iterated rapidly, and **Architecture Guard**, **Golden Demo**, **Coding Standards Drift Control**, **Ripple**, and the **Ralph Loop** all shipped updates. The preset side was the month's busiest: a large **governance-preset** family expanded and iterated — the **Autonomous Run Governance** and **Parallel Autonomous Run Governance** presets, a full **Intake** governance suite (Authoring, Review, Sequencing), **Test-First Governance**, and coordinated version bumps across the A11Y, Agent-Parity, Cross-Platform, iSAQB-Architecture, Architecture, and Security governance presets. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Documentation & Docs Site
July's documentation work paired the new features with a landing-page refresh. The **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648), the **`py` (Python) script type** was documented in the installation guide and init option table (#3284, #3625, #3640), and the **`__SPECKIT_COMMAND`** token for portable cross-command references was documented (#3503). The landing page was reframed to weave the **harness/SDLC framing** and modernize the install and positioning story (#3565, #3567), ecosystem stats were refreshed (#3561), and **`extensions.yml` hook configuration** was documented (#3563). Upgrade guidance clarified that project-file upgrades flow through `integration upgrade` / `extension update` (#3326) and that Claude Code files live in `.claude/skills` (#3708). [\[github.com\]](https://github.com/github/spec-kit/releases)
## Community & Content
### Press and Industry Coverage
July's coverage shifted from "what is SDD" explainers toward tool comparisons, companion tooling, and pointed "who verifies the spec?" critiques. No first-party Microsoft or GitHub post appeared in July; the nearest remained June's Microsoft Developer Blog piece.
**heise online** (Wolf Hosbach, July 21) was the month's most prominent mainstream-press coverage, publishing *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German — news coverage of the `assess` Idea Assessment Pipeline, the Azure DevOps CLI support, and the 0.13 validation fixes. Mainstream European tech press now tracks Spec Kit's minor releases as tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
**Towards AI** (Rost Glukhov, July 12) compared **GitHub Spec Kit vs Kiro vs Claude Code** on SDD workflow rather than model capability, part of a July-long current of "which SDD tool?" comparisons that increasingly pit Spec Kit specifically against Kiro. [\[pub.towardsai.net\]](https://pub.towardsai.net/github-spec-kit-vs-kiro-vs-claude-code-sdd-workflows-a9e7fab3e545)
**ranjankumar.in** (Ranjan Kumar, July 13) argued that four SDD frameworks — BMAD, Spec Kit, Kiro, and Superpowers — converge on the same structural "invariants," engaging Spec Kit's actual internals (`workflows.md`, run-state `state.json`) rather than treating it as a black box. [\[ranjankumar.in\]](https://ranjankumar.in/spec-driven-development-invariants-not-frameworks)
Release-trackers continued their factual coverage of the 0.13–0.15 run, and **Level Up Coding** (JingJing "Chris" Bao) published a three-part practitioner series on Spec Kit's presets, extensions, and pipeline/workflow features as the path beyond linear slash-commands. [\[levelup.gitconnected.com\]](https://levelup.gitconnected.com/from-linear-commands-to-automated-pipelines-how-spec-kit-orchestrates-nonlinear-ai-development-55ca03c5617b)
### The Companion-Tooling Ecosystem
July's most telling signal was not an article but a pattern: independent developers responded to Spec Kit's most-cited critiques by **building tools on top of it**. The recurring complaint — documentation proliferation and "who verifies the generated spec?" — turned into code.
- **artgraph** (mori-shin, July 20) — a deterministic, hash-based spec↔code drift-detection CLI with an `artgraph integrate speckit` hook, built specifically to give Spec Kit's LLM-prompt-based verification a deterministic backstop. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
- **SpecJudge** (Joaquín Ruiz, July 21) — a companion CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a *right-sized* model for the project. [\[dev.to\]](https://dev.to/jokiruiz/specjudge-which-ai-model-is-right-sized-for-your-project-ask-your-specs-2edp)
- **GAUNTLEX** (Sanjoy Ghosh, July 16) — named Spec Kit a leading SDD tool while arguing SDD leaves a security-testing gap, and shipped a tool to fill it. [\[hashnode.dev\]](https://sanjoy1234.hashnode.dev/the-testing-gap-nobody-s-talking-about-in-spec-driven-development)
- **Custom skills** — [`speckit-next`](https://qiita.com/htcd/items/ec76f2b7194be3297b93) (htcd, July 31), a skill that recommends the next command because the names and order are hard to remember, and [`prefill`](https://velog.io/@k3nta/ai-adoption-journey-1-tools) (k3nta, July 1), a skill that patches `clarify`'s blind spots.
Together these are the clearest evidence yet that Spec Kit has become a *platform* — its artifacts are stable enough, and its gaps well-enough understood, that a third-party tooling layer is forming around it. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
### Developer Articles and Blog Posts
July's articles skewed heavily multilingual — strong hands-on series in Japanese, Chinese, and Korean — with a clear thread of honest, use-it-in-anger critique.
Notable articles:
- **ta_kawano** (note.com, July 28–31) published a consolidated four-part **Kiro vs Spec Kit** head-to-head, completing a 108-task / 301-test build with Spec Kit where Kiro ran out of credit, praising measurable Success Criteria and auto-listed edge cases while flagging ~15,000 lines of generated documentation — and reframing Spec Kit as a requirements-elicitation tool. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
- **magebyte / 码哥字节** (SegmentFault, July 26) built a Go REST API through the five-step workflow, covered the three spec-persistence models and the extension/preset system, and claimed ~80% less AI "hallucination" rework. [\[segmentfault.com\]](https://segmentfault.com/a/1190000048082146)
- **Nil Seri** (Medium, July 16) published a brownfield guide adding Spec Kit to an existing Spring Boot / Maven project with Jira and Confluence integration — "from Jira ticket to verified code." [\[medium.com\]](https://medium.com/@senoritadeveloper/using-spec-kit-in-an-existing-spring-boot-maven-project-from-jira-ticket-to-verified-code-4e6da99b5d19)
- **kitroc7134** (Qiita, July 4) tested `/speckit.converge` with deliberate fault-injection on a FastAPI Todo API — detect drift → append convergence tasks → re-implement — validating June's convergence loop in the field. [\[qiita.com\]](https://qiita.com/kitroc7134/items/117d4839f259bc403626)
- **yutakaosada** (Zenn, July 25) — a Microsoft-MVP .NETラボ talk that uses Spec Kit but candidly flags AI-credit consumption, over-production of docs, and single-source-of-truth collapse, comparing it with Copilot Plan mode. [\[zenn.dev\]](https://zenn.dev/yutakaosada/articles/70e01981647159)
Additional coverage appeared on TechWealthBuzz, Hashnode, TabNews-adjacent outlets, CSDN and 腾讯云 (Chinese), Naver/velog/Tistory (Korean), and Qiita/note (Japanese) — including several "is it too heavy?" and documentation-proliferation critiques, and a [Korean instructor's piece](https://blog.naver.com/gaussian88/224363268926) citing Spec Kit's star growth from ~90k in May to ~120k in July. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
July's clearest ecosystem signal was structural: the conversation moved from "how do I use Spec Kit?" to "what do I build *around* it?" The companion tools — artgraph for deterministic drift, SpecJudge for model sizing, GAUNTLEX for the testing gap, and a growing set of custom agent skills — treat Spec Kit's artifacts (constitution, spec, tasks, run-state) as a stable substrate to build against. The public community catalog reinforces the point: the loudest theme across the 144 cataloged extensions is verification and quality (review, validate, drift, sync, verify, audit), and core SDD verbs are increasingly *re-expressed* by extensions rather than merely overridden — evidence of demand for composable, overridable core commands. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Competitive Landscape
The "which SDD tool?" comparison remained the dominant content genre, but July's framing narrowed: where June's surveys ran a seven-tool field, July's most substantive pieces increasingly went head-to-head **Spec Kit vs Kiro** (ta_kawano, [faruryo](https://qiita.com/faruryo/items/87a14728299e89f80ff4), Towards AI). The recurring verdict held — Spec Kit is the heaviest and most flexible option, strong on measurable Success Criteria, requirements elicitation, and greenfield decomposition, while its documentation proliferation and cognitive load are the consistent trade-off. The convergence-invariants analyses (ranjankumar.in) went further, arguing the frameworks are converging on the same structural primitives, which shifts the competitive question from "which tool" to "which ecosystem and governance model." On that axis, Spec Kit's widening catalog, agent-neutrality, and now a forming companion-tooling layer are its differentiators. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
## Roadmap
Areas under discussion or in progress for future development:
- **Upstream of the spec** — the `assess` Idea Assessment Pipeline extends SDD before the spec exists. Expect the capture→evidence→refine→design→decision flow to deepen, and the boundary between idea assessment and `/speckit.specify` to be a key area to refine as the pipeline sees real use. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
- **The Python migration** — the `py` script type and the port of the core scripts, git extension, and agent-context updater establish Python as the path out of the bash/PowerShell parity tax. Completing the port and making `py` a well-trodden default (rather than sh/ps) is the payoff: an entire class of Windows-parity bugs disappears. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Agent-native runtime events** — the first-class events layer lets integrations wire Spec Kit into each agent's own runtime through canonical event names and per-agent adapters. The layer is **actively evolving** — early signals point to opencode context injection and JSON-envelope agent hooks. Expect more agents to gain event adapters and extension authors to lean on the declarative `events:` surface as the integration layer shifts from lowest-common-denominator overlay to genuinely native behavior. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Copilot skills as the default** — July shipped a warning ahead of the skills-default rollout, and the signals now point to the **default flip being in progress** — moving `specify init --integration copilot` to the skills-based layout and making the default init integration overridable via an environment variable, with the markdown-command layout becoming the legacy path. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **A Copilot-native surface** — the first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) repo (a listed community friend) wraps the `specify` CLI as a **Copilot skills plugin** (nine skills across setup, init, extensions, presets, bundles, workflows, and self-upgrade) for the Copilot CLI and App, aligned to CLI v0.15.0. The emerging direction is a **visual, Copilot-driven surface** — early work explores canvas dashboards for the Spec-Driven Development flow, a Bug Fix Pipeline, and `assess` — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
- **The companion-tooling layer** — artgraph, SpecJudge, GAUNTLEX, and custom skills signal a third-party ecosystem forming on Spec Kit artifacts. The open question is whether the project absorbs these patterns (as it did drift → `converge`) or leaves them to the ecosystem; the verification/drift demand in the extension catalog suggests continued upstream pull. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Security and robustness as a standing discipline** — July's hardening wave (bounded reads, strict redirects, TOCTOU elimination, fail-loudly validation) shifted from feature to routine, and the signals point to it **continuing as an ongoing campaign** — a stdin read cap to close a DoS path and a broader atomicity push (atomic temp-file writes, subprocess timeouts, structured logging, and narrowed exception handlers). Sustaining the no-unbounded-read invariant as the surface (bundles, workflows, catalogs, events) grows is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across July's balanced reviews (ta_kawano, yutakaosada, and multiple Japanese/Korean pieces). The `assess` upstream gate, the lean/TinySpec presets, `/speckit.converge`, and the forming companion-tooling layer all provide answers; surfacing them to new users is the persistent opportunity. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June brought broad validation — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and coverage spanned dozens of languages. A summary is in the table below, followed by details.
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. Press coverage spanned dozens of languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
***
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. Coverage appeared in dozens of languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
## Spec Kit Project Updates
@@ -84,7 +84,7 @@ On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Develop
### Press and Industry Coverage
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
June's press coverage spanned dozens of languages and platforms.
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
@@ -102,7 +102,7 @@ June was the **highest-volume coverage month on record — 75 substantive articl
### Developer Articles and Blog Posts
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
June's coverage skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
Notable English-language articles:
@@ -137,7 +137,7 @@ Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substac
### The Category Consolidates
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
Across June's broad article coverage, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and many more across multiple languages. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
***
@@ -76,7 +76,7 @@ May produced the broadest press coverage to date, with publications from the mai
### Developer Articles and Blog Posts
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
May produced a wave of independent coverage across many languages.
@@ -59,6 +59,19 @@ Content resolution functions for composition:
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
### Constitution lifecycle
Initialization resolves `constitution-template` through the full stack and seeds
`.specify/memory/constitution.md` once. Existing files are preserved byte-for-byte. On subsequent
`/constitution` runs, the command resolves the current composed template at runtime and uses the live
constitution as the source of project-specific values and amendments.
Preset installation, removal, enablement, disablement, and priority changes do not materialize
`constitution-template` by default. When the enabled preset registry contains `constitution-sync`,
those operations may reconcile the live file, but only if its provenance hash proves it is still
generated content. Missing files may be seeded when the preset is installed; authored or edited
constitutions are never overwritten.
## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
@@ -15,6 +15,16 @@ If no preset is installed, core templates are used — exactly the same behavior
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
`constitution-template` follows the same runtime model. Project initialization seeds
`.specify/memory/constitution.md` once so downstream commands always have a constitution to read.
After that, installing, removing, enabling, disabling, or reprioritizing presets does not rewrite the
live constitution. Each `/constitution` run resolves the current composed `constitution-template`,
then applies existing project values and amendments to that scaffold.
Teams that intentionally want preset stack changes to refresh an unchanged generated constitution can
install the bundled `constitution-sync` preset. It restores guarded install-time materialization in
addition to its command-time propagation behavior; authored constitutions remain protected.
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
"description":"Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusivecontent, didactic-comment review, and audit-ready evidence.",
"version":"0.4.3",
"description":"Adds WCAG 2.2 AA, accessible status output, bilingual CEFR-B2 delivery, inclusive-content and didactic-comment governance, and provider-neutral model routing.",
"description":"Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
"version":"0.3.3",
"description":"Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.",
"description":"Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
"version":"0.2.2",
"description":"Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing.",
"description":"Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
"version":"0.3.1",
"description":"Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring.",
"description":"Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technicaldebt.",
"version":"0.2.2",
"description":"Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing.",
"description":"Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git.",
"description":"Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
"version":"0.2.4",
"description":"Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
"description":"Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
"version":"0.6.2",
"description":"Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing.",
"description":"Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.",
description: Create or update the project constitution, then propagate the amended guidance into dependent templates and installed command files (opt-in template sync).
strategy: wrap
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
{CORE_TEMPLATE}
## Constitution Template Sync
> **This section supersedes the "Scope Guard" above for template and command propagation.**
> The core Scope Guard states that dependent templates and commands "are not modified here" —
> that restriction is intentionally lifted by this preset. Propagating amended guidance into the
> project's own dependent scaffolds and installed command files (as described below) **is in
> scope** whenever this preset is installed. The Scope Guard's other constraints still apply:
> do not implement features, generate application code, or touch artifacts unrelated to the
> constitution/template workflow.
After you have written the updated constitution above, perform a consistency propagation pass
so the dependent artifacts reflect the amended principles:
1. Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align
with the updated principles. Only materialize concrete gate text here if your team intends to
review it as committed content; otherwise leave the runtime pointer
`[Gates determined based on constitution file]` in place so `/plan` fills it from the live
constitution.
2. Read `.specify/templates/spec-template.md` for scope/requirements alignment — update if the
constitution adds/removes mandatory sections or constraints.
3. Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or
description:"Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts."
author:"github"
repository:"https://github.com/github/spec-kit"
license:"MIT"
requires:
# Requires the runtime-resolution baseline (#3790, shipped in 0.14.4) where the
# core /constitution command no longer propagates. Installing this preset on an
# older core would double-apply propagation.
speckit_version:">=0.14.4"
provides:
templates:
- type:"command"
name:"speckit.constitution"
file:"commands/speckit.constitution.md"
description:"Wrap /constitution to also propagate guidance into dependent templates and command files"
echo"ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo"Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.