Compare commits

..

22 Commits

Author SHA1 Message Date
github-actions[bot]
690dc28356 chore: bump version to 0.12.14 2026-07-13 22:52:30 +00:00
github-actions[bot]
a8d3038ece [extension] Add Spec Kit Memory extension to community catalog (#3455)
* Add Spec Kit Memory extension to community catalog

Add memory extension submitted by @zaytsevand to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3446

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: resolve merge conflicts with main branch

- extensions/catalog.community.json: keep updated_at 2026-07-10 (more recent)
- docs/community/extensions.md: include both Spec Kit Figma (main) and
  Spec Kit Memory (this PR) in alphabetical order

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)

* fix: add memsearch optional tool dependency to memory extension catalog entry

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, 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>
2026-07-13 17:50:58 -05:00
github-actions[bot]
5f59a5b238 Add Test-First Governance preset to community catalog (#3504)
Add test-first-governance preset submitted by @mnriem to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3502

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>
2026-07-13 17:43:04 -05:00
github-actions[bot]
3c9aa1f81b Add Autonomous Run Governance preset to community catalog (#3501)
Add autonomous-run-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3499


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>
2026-07-13 17:13:17 -05:00
Ali jawwad
52c1acf8ba fix(workflows): validate command step input/options are mappings (#3262)
* fix(workflows): validate command step input/options are mappings

CommandStep.validate() only checked for 'command'; execute() then does input.items() and options.update(step_options). A non-mapping input:/options: (e.g. a YAML list or scalar) raised AttributeError at run time, bypassing the per-step FAILED/continue-on-error contract -- unlike the sibling steps (switch 'cases', fan-out 'step') which type-check their config fields in validate(). Add the same checks, plus a defense-in-depth coercion in execute() since the engine does not auto-validate before running a step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix code-comment typo in CommandStep.validate

The explanatory comment said options.update(options) but execute() does
options.update(step_options). Comment-only change; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(workflows): command step FAILS on malformed input/options instead of coercing

execute() previously coerced a non-mapping 'input' to {} and silently ignored a
non-mapping 'options', then dispatched the command anyway. For a workflow that
skipped validation (the engine does not auto-validate before execute()), that
let an explicitly malformed step run with empty args and report COMPLETED —
masking the config error and defeating the per-step FAILED / continue_on_error
semantics this change is meant to provide.

Both now return a FAILED StepResult with the same contract error validate()
reports (never crashing on .items()/.update()). Valid mapping configs are
unaffected. Strengthened the execute() test to assert FAILED + the exact
'must be a mapping' error for input and options (fails before: the result
carried the downstream dispatch error, not the shape error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:54:59 -05:00
Ali jawwad
fc1a3fd76c fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
* fix(presets): resolve() honors manifest-declared file: for installed presets

PresetResolver.resolve()'s tier-2 (installed presets) loop was
convention-only: it looked for templates/<name>.md and <name>.md,
ignoring a preset manifest that declares the template with an explicit,
non-convention file: path. So resolve() returned the core template (and
resolve_with_source() misattributed source='core') while
collect_all_layers()/resolve_content() correctly used the preset's
declared file — a divergence inside the same class. It could also return
a stray convention-path file the manifest deliberately points away from.
Mirror collect_all_layers()'s manifest-first logic: use the declared
file: when present (skip convention fallback if it's missing, to avoid
masking typos), and fall back to the convention walk only when the
manifest is absent or doesn't list the template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(presets): clarify the empty/falsey manifest-file branch comment

Per review: 'file' is a required key for every template entry
(PresetManifest._validate()), so the manifest-found branch is reached
for an empty/falsey/non-usable 'file' value, not a truly absent one.
Reword the comment to say so. Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(presets): resolve() returns only real files; test missing-file skip

Per review:
- Use is_file() (not exists()) when honoring a manifest-declared file: so a
  manifest pointing at a directory is treated as missing rather than
  returned to a caller that will read_text() it. Applied in both resolve()
  and collect_all_layers() so the two stay consistent.
- Add a regression test for the skip-convention-fallback-when-declared-file-
  missing behavior: manifest declares a missing custom/spec.md while the pack
  has a convention templates/spec-template.md; resolve() must skip the pack
  and fall through to core, not pick up the stray convention file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(presets): resolve()/collect_all_layers() require a regular file for manifest file:

A manifest-declared file: path is honored via exists(), which also accepts
a directory. If a preset points file: at a directory, resolve() returned it
and downstream read_text() crashes. Use is_file() in both resolve() and
collect_all_layers() so a non-file (directory) is treated as missing and the
convention fallback is skipped (pack yields to core), matching the existing
missing-file behavior.

Adds a directory-at-file: test (fails on exists(), passes on is_file()) that
also asserts collect_all_layers() never returns the directory as a layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(presets): extract shared _manifest_declared_template for resolve()/collect_all_layers()

Both methods reimplemented the manifest-entry lookup + authoritative-fallback
rules independently — the exact duplication that let them diverge and caused the
bug this PR fixes. Extract a single _manifest_declared_template(pack_dir, name,
type) -> (entry, candidate) helper (candidate is the declared file only when it
is_file(); a declared-but-unusable file returns (entry, None) so callers skip the
convention fallback). resolve() and collect_all_layers() now both call it, so
their manifest-first resolution cannot silently diverge again.

Pure refactor, behavior-preserving: full test_presets.py (331) still passes,
including the directory-at-file:, missing-file, and manifest-file-wins cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:40:36 -05:00
Ali jawwad
993083405e fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
* fix(init): don't block on confirmation for 'init --here' without a TTY

When 'specify init --here' targets a non-empty directory without --force, it called typer.confirm() unconditionally. In a non-interactive session (no TTY -- CI, piped, agent) there is no input, so the prompt reads EOF and aborts unhelpfully (or blocks), with no actionable message. The named-project path already fails fast and points to --force; --here was the inconsistent outlier.

Guard the confirmation with the existing _stdin_is_interactive() helper: when non-interactive, print a clear 'directory not empty; re-run with --force' error and exit 1 instead of prompting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(init): honor piped confirmation for 'init --here'; only fail-fast on empty stdin

The first version of this fix short-circuited on '_stdin_is_interactive()' (isatty) before typer.confirm, which broke 'init --here' when confirmation is piped (e.g. 'echo y | specify init --here' / CliRunner input='y\n') -- a non-TTY pipe with valid input was wrongly rejected, regressing test_init_here_without_force_preserves_shared_infra. Instead, call typer.confirm normally (piped 'y'/'n' is honored) and catch the Abort/EOFError it raises only when stdin is empty, converting that to the actionable '--force' guidance. This keeps the UX win for the no-input case without rejecting piped input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(init): distinguish interactive cancel from no-input; defer merge warning

Address Copilot review on the --here non-empty path: (1) treat typer.Abort during an interactive confirm (e.g. Ctrl+C) as a normal cancellation (exit 0), and only emit the '--force' guidance + exit 1 when there is no TTY (empty stdin / EOF) -- no longer conflating the two; (2) move the 'will be merged / may overwrite' warning so it only shows when actually proceeding (force) or folded into the confirmation prompt, not on the fail-fast path where nothing is merged.

Piped confirmation (e.g. 'echo y | specify init --here') is still honored, which is why the prompt is attempted rather than refused outright when non-interactive -- the existing test_init_here_without_force_preserves_shared_infra pipes 'y' and must succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(init): fail fast on non-interactive --here instead of prompting

Per Copilot review: do not call typer.confirm when stdin is not a TTY -- an open-but-idle non-TTY stdin (CI/agent) could block on the prompt. When the directory is non-empty and --force is not given, fail fast with '--force' guidance unless an interactive terminal is present. Interactive confirm still offers the merge-but-preserve path (distinct from --force, which overwrites); a Ctrl+C there is treated as a normal cancellation (exit 0). The merge/overwrite warning is only printed when actually proceeding, not on the fail-fast path.

Updated the preserve-merge E2E test to simulate an interactive terminal so it exercises the confirm path (non-interactive sessions now require --force).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(init): honor piped y/n for 'init --here', error only on no-input

Per maintainer review: restore the second-revision shape. Calling
typer.confirm normally keeps 'echo y | specify init --here' reaching the
non-destructive preserve-merge path (and piped 'n' cancels with exit 0).
Only when no confirmation input is available at all (closed/empty stdin
-> typer.Abort/EOFError) is it converted into the actionable error that
points at --force. This drops the _stdin_is_interactive fail-fast that
broke the common piped-confirm idiom and made preserve-merge
interactive-only. The preserve test no longer needs to monkeypatch
_stdin_is_interactive - it passes on the real contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(init): preserve interactive-cancel semantics; fold merge risk into the prompt

Two review-driven refinements to the 'init --here' non-empty confirm, keeping
the maintainer-endorsed control flow (piped y/n honored; non-interactive EOF →
actionable --force error):

1. typer.confirm raises typer.Abort for BOTH an interactive Ctrl+C and an EOF on
   closed/empty stdin. Catching it unconditionally reported 'no confirmation
   input available, use --force' and exited 1 even when the user cancelled at a
   real TTY. Branch on _stdin_is_interactive(): a TTY cancel is a normal exit 0
   ('Operation cancelled'); only non-interactive EOF becomes the --force error.

2. Fold the merge-risk warning into the confirmation question instead of printing
   it unconditionally beforehand, so the EOF/no-input path (which exits without
   changing anything) no longer prints a misleading 'will be merged' line first.

Adds test_init_here_interactive_cancel_exits_zero (fails before: exit 1 with
--force; passes after: exit 0, 'cancelled', pre-existing file untouched). The
non-interactive EOF and piped-y preserve-merge tests are unchanged and still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:15 -05:00
github-actions[bot]
801ff888ff [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
* Add Quality Gates (Enforcement Layer) extension to community catalog

Add gates extension submitted by @schwichtgit to:
- extensions/catalog.community.json (alphabetical order, between fx-to-dotnet and github-issues)
- docs/community/extensions.md community extensions table

Closes #3414

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: revert unrelated catalog reformatting and remove empty changelog field from gates entry

- Restore original ordering/formatting of aide, checkpoint, critique,
  threatmodel entries and inline requires.tools objects that were
  inadvertently reordered in the previous commit
- Remove `"changelog": ""` from the gates entry (empty URL is
  inconsistent with catalog conventions; field should be omitted when
  no changelog URL exists)

Addresses review comments:
- github/spec-kit#3431 (comment) — unrelated reformatting/reordering
- github/spec-kit#3431 (comment) — empty changelog field

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)

* Fix gates entry tool requirements: git required, add node and shellcheck optional

- Mark git as required (per v0.1.0 README: \"jq and git — the hooks and verify.sh require them\" and release notes: \"Requires Spec Kit >=0.12.0, jq, and git\")
- Add node as optional tool (per issue #3414 submission)
- Add shellcheck as optional tool (per issue #3414 submission)
- Update gates entry updated_at and top-level updated_at to 2026-07-13

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, 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>
2026-07-13 15:16:45 -05:00
Noor ul ain
c05a626cbc fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
* fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457)

`_parse_integration_options` called `shlex.split(raw_options)` unguarded, so an
unbalanced quote in the flag value (e.g. `--integration-options='--commands-dir
"foo'`) made shlex raise `ValueError: No closing quotation` and a raw traceback
escaped — unlike every other bad-input path in this function (unknown option,
missing value, unexpected value), which print a message and exit 1.

Reachable from `specify init --integration-options=...` and every `specify
integration install/switch/upgrade/migrate --integration-options=...`.

Wrap the split in a try/except ValueError that prints a one-line error and
raises `typer.Exit(1)`, matching the existing loud-fail UX. Add a test asserting
the unbalanced-quote input raises `typer.Exit` with exit code 1.

Co-Authored-By: Claude Opus 4.8 (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 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-13 14:33:02 -05:00
Noor ul ain
0acb5c6461 fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
kiro-cli confines all of its managed files to an isolated agent root
(`.kiro/`, with commands in `.kiro/prompts`) that no other integration
writes to, so it meets every documented criterion for multi-install
safety — but `KiroCliIntegration` never set `multi_install_safe = True`.

As a result, co-installing kiro-cli alongside any other integration left
`specify integration status` permanently in ERROR:

    error unsafe-multi-install: Installed integrations are not all
    declared multi-install safe: kiro-cli

`--force` bypasses the install-time gate but does not clear the status
error, and there is no flag or config to acknowledge it, so the error is
permanent while both integrations remain installed.

Set `multi_install_safe = True`. The registry's parametrized
multi-install-safe contract tests (static isolated root, distinct agent
roots / command dirs, disjoint manifests) now cover kiro-cli
automatically, and a focused regression test pins the declaration so a
future edit cannot silently drop it and reintroduce the error.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:13:44 -05:00
Noor ul ain
a965413a24 fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
`FanInStep.validate()` and the engine's fan-in checks both reject a
non-list `wait_for`, but the engine's `execute()` path does not
auto-validate (see `WorkflowEngine.load_workflow`, whose docstring notes
the definition is "not yet validated"). On an unvalidated run, `execute`
iterated the raw value with `for step_id in wait_for`, with two bad
outcomes:

  * a scalar (`wait_for: 5`, `wait_for: null`) raised `TypeError` and
    took down the whole run — the engine invokes `step_impl.execute()`
    with no surrounding try/except; and
  * a string (`wait_for: stepA`) silently iterated its characters and
    returned a join of empty results with a COMPLETED status — the exact
    "silent empty result + COMPLETED" wiring bug the engine's own fan-in
    validation comment warns against.

Guard `execute` to return a FAILED StepResult naming the type error
instead, mirroring the fan-out step's non-list `items` handling. A
missing `wait_for` key still defaults to an empty list (COMPLETED),
unchanged; the guard fires only on an explicit non-list value.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:30:09 -05:00
Manfred Riem
8cb0889f4a chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
* chore: bump version to 0.12.13

* chore: begin 0.12.14.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-13 13:08:42 -05:00
Noor ul ain
e590cd8007 fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
`SwitchStep.validate()` already rejects a non-mapping `cases`, but the
engine's `execute()` path does not auto-validate (see
`WorkflowEngine.load_workflow`, whose docstring notes the definition is
"not yet validated"). On an unvalidated run, `execute` called
`cases.items()` on the raw value, so a list or scalar `cases` authoring
mistake raised `AttributeError` and took down the whole run — the engine
invokes `step_impl.execute()` with no surrounding try/except.

Guard `execute` to return a FAILED StepResult naming the type error
instead, mirroring the fan-out step's non-list `items` handling. The
expression is still evaluated first, so its value is surfaced in the
step output for downstream context.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:02:04 -05:00
Yoshiyuki Kinjo
e649bbdc44 Cleanup agent-file-template.md (#2579)
* follow  fc3d1244c0

agent-file-template.md is removed at  fc3d1244c0

* Fix ruled line for constitution-template.md

* fix test
2026-07-13 12:59:31 -05:00
NgoQuocViet2001
6664cf813c fix: mark Kiro integration as multi-install safe (#3472)
Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-13 10:57:26 -05:00
Marsel Safin
3b7d95a408 fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
* fix: rewrite extension-relative subdir paths in generated command bodies

Extension command bodies reference bundled files relative to the
extension root (agents/, knowledge-base/, templates/, ...). Generated
SKILL.md and command files emitted those paths verbatim, so agents
resolved them against the workspace root where they do not exist.

Add CommandRegistrar.rewrite_extension_paths, which rewrites references
to subdirectories that actually exist in the installed extension to
.specify/extensions/<id>/..., and call it once in register_commands so
every output format and alias gets the fix. commands/, specs/ and
dot-directories are never rewritten.

Fixes #2101

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: only rewrite relative extension path references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use callable re.sub replacement for extension subdir rewrite

subdir and extension_id come from filesystem directory names and were
interpolated into a re.sub string replacement template. A directory name
containing a backslash (e.g. assets\q) would raise re.error: bad escape,
aborting command registration even when the body didn't reference it.
Use a callable replacement so these values are treated literally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: make subdir rewrite regression test cross-platform

Renamed the test's subdir fixture from "assets\\q" to "assets[q]":
on Windows, backslash is a path separator, so mkdir would create
nested "assets/q" dirs instead of one literally-named directory,
and iterdir() would only discover "assets", never exercising the
rewrite. extension_id keeps a real backslash/"\\1" since it isn't
used to create a directory, still verifying the callable replacement
handles it literally. Added a sanity assertion for this assumption.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply extension subdir path rewrite in skills-mode renderer

register_commands() rewrote extension-relative subdir references
(agents/, knowledge-base/, etc.) via rewrite_extension_paths(), but
_register_extension_skills() - the separate renderer used for active
non-native skills agents (e.g. Claude with ai_skills: true) - never
called it. Generated SKILL.md files left agents/... and
knowledge-base/... unresolved, and mapped the extension's own
templates/ through the generic project-level rewrite instead of its
installed .specify/extensions/<id>/templates/ location.

Reuse the existing rewrite_extension_paths() helper in
_register_extension_skills() at the same point register_commands()
applies it (before resolve_skill_placeholders' generic rewrite), and
add a skills-mode regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply extension subdir path rewrite on preset restore/reconcile paths

_unregister_skills() restored extension-backed SKILL.md content via
resolve_skill_placeholders() without first calling
rewrite_extension_paths(), so removing a preset override that shadowed
an extension command restored the bare, unresolvable agents/... and
knowledge-base/... references. Carried extension_id/extension_dir
through _build_extension_skill_restore_index() and applied the same
rewrite used at initial registration before restoring.

Found the identical gap in _reconcile_composed_commands()'s non-skill
agent path: when a removed preset's command reverts to an extension
winner, register_commands_for_non_skill_agents() was called without
extension_id, so the rewrite never ran for plain command-file agents
either. Passed extension_id through there too.

Added regression tests for both restore paths (skills-mode and
non-skill-agent command files) in tests/test_presets.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: apply extension subdir path rewrite when composing over extension base

PresetResolver.resolve_content() read the effective base layer's raw
content directly via path.read_text() before composing append/prepend/
wrap overlays on top of it, and its outright-replace shortcut did the
same. When that base layer was extension-provided, neither read path
applied rewrite_extension_paths(), so composing a preset over an
extension command (or an extension winning outright through
resolve_content) left bare, unresolvable agents/... and
knowledge-base/... references in the composed output.

All three call sites (PresetManager._register_commands()'s composed
path, _reconcile_composed_commands()'s composed path, and skills-mode
reading the .composed file written by either) consume resolve_content's
return value, so fixing the read at its source covers command output,
skill output, and both initial-install and reconcile flows without
threading extension identity through each caller.

Tagged extension layers in collect_all_layers() with extension_id/
extension_dir, and added a _read_layer_content() helper in
resolve_content() that applies rewrite_extension_paths() whenever a
layer carries that extension identity — used at both raw-read sites
(outright-replace shortcut and composition base). Composing
(append/prepend/wrap) layers are never extension-provided (extensions
are always inserted with strategy "replace"), so no other read site
needs the rewrite.

Added regression tests: a parametrized resolve_content() test covering
append/prepend/wrap composing over an extension base, a skills-mode
test asserting the composed SKILL.md resolves the extension's subdir
references, and a non-skill-agent (Gemini) install-time test matching
the reported live repro.

Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-13 10:50:59 -05:00
Marsel Safin
086929e546 fix(templates): point constitution sync checklist at installed command files (#3418)
* fix(templates): point constitution sync checklist at installed command files

The consistency-propagation checklist told the agent to read
.specify/templates/commands/*.md, but specify init never creates that
directory — command templates are rendered straight into the
agent-specific directory (.github/prompts/, .claude/commands/, ...).
The checklist step could therefore never run against real files.

Point it at the installed speckit.* command files for the active agent
instead.

Fixes #660

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(templates): cover hyphenated and skills-mode command filenames

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(templates): use actual integration output directories in examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(templates): cover skills-based command layouts in sync checklist

Copilot skills mode installs speckit-<name>/SKILL.md under .github/skills/,
not .github/agents/. Mention both directories and the SKILL.md layout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(templates): restore hyphenated speckit-* naming in sync checklist

The previous commit dropped the speckit-* flat-file variant used by
Cline and others while adding the SKILL.md layout. Name all three:
speckit.*, speckit-*, and speckit-<name>/SKILL.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: clarify agent-specific reference phrasing in constitution template

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-13 10:49:27 -05:00
Noor ul ain
32952c94f4 feat(workflows): make shell step timeout configurable (#3327) (#3328)
* feat(workflows): make shell step timeout configurable (#3327)

The `shell` step hardcoded a 300s subprocess timeout, so any command
that legitimately runs longer than five minutes (a full build, a linter
aggregator, an integration-test target) was killed with TimeoutExpired
and failed the whole run, with no YAML knob to raise the limit.

Add an optional `timeout` field (seconds) that defaults to 300 for
backward compatibility and is threaded through to `subprocess.run`. The
timeout failure message now reports the configured value instead of a
hardcoded 300. `validate` rejects a `timeout` that is not a positive
number (bool is rejected explicitly, since it is an int subclass but a
config error rather than a duration).

Co-Authored-By: Claude Opus 4.8 (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>

* test(workflows): cover non-finite timeout rejection in shell step

The isfinite guard added in 955d46a rejects YAML .inf/.nan timeouts, but no test asserted it. inf and nan are floats that pass a plain > 0 check (nan <= 0 is False), so without an explicit case a regression could silently reaccept them and crash subprocess.run(timeout=...) at runtime. Addresses the remaining Copilot review comment on PR #3328.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(workflows): document configurable shell step timeout

Address Copilot review feedback on #3328: the per-step `timeout`
option was not reflected in the public workflow docs. The Shell Steps
section only showed `run:`, so readers couldn't discover `timeout:`,
its unit (seconds), or its default (300).

Co-Authored-By: Claude Opus 4.8 (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>

* refactor(workflows): consolidate shell-step timeout validation into one path

Address Copilot review feedback on #3328:

- Remove the dead "fall back to default" timeout block in execute(): it
  re-read `timeout` from config immediately after, so the fallback was
  discarded and its comment contradicted the new fail-on-invalid behavior.
- Extract a single `_timeout_error()` helper shared by execute() and
  validate() so both reject the same values with the same message, instead
  of two drifting copies of the check.
- Hoist the duplicated inline `import math` to module scope.
- Add test_execute_fails_cleanly_on_invalid_timeout: asserts execute()
  fails the step (rather than raising) on an unvalidated string/bool/inf/0
  timeout, covering the engine-skips-validate path Copilot flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-13 10:32:58 -05:00
Emre Değirmenci
82c078bb3a docs: clarify that release tags keep the leading v prefix (#3463)
Readers were replacing vX.Y.Z with bare versions like 0.12.11,
which fails because git tags are named v0.12.11.

Assisted-by: Cursor Grok 4.5 (supervised)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 10:27:18 -05:00
Quratulain-bilal
86d769b47c fix(workflows): don't crash on membership test against a non-iterable (#3448)
* fix(workflows): don't crash on membership test against a non-iterable

the `in` / `not in` operators in _evaluate_simple_expression only guarded
`right is not None`, so `left in right` still raised a raw TypeError when the
right operand was any other non-iterable (int, bool, float). a condition like
`{{ inputs.tag in inputs.count }}` where count is a number crashed the whole
workflow run instead of evaluating.

nothing is contained in a non-iterable, so treat membership as False (`not in`
as True) via a new _safe_membership helper that swallows TypeError. this
generalizes the old None guard and mirrors _safe_compare, which already
catches TypeError for the ordering operators.

added a regression test; confirmed it fails on the pre-fix code (raw
TypeError) and that genuine list/substring membership still works.

* address review: float membership case + broaden _safe_membership docstring

- add a float right-operand assertion so the test matches its comment (was
  claiming float coverage while only exercising int/bool/None).
- reword the _safe_membership docstring to describe TypeError generally
  (non-iterable right is the common case, but also e.g. an unhashable left
  against a set) rather than implying only the right operand matters.
2026-07-13 10:20:48 -05:00
Ali jawwad
55c66125f0 fix(workflows): if-step validate accepts falsy non-list else (#3264)
* fix(workflows): if-step validate accepts falsy non-list else

IfThenStep.validate() guarded the 'else' branch with
'if else_branch and not isinstance(else_branch, list)'. The leading
truthiness check short-circuits for falsy non-list values (False, 0,
'', {}), so a malformed else-branch passes validation and is then
silently skipped at runtime. The sibling 'then' branch is validated
strictly; 'else' now matches by switching to an 'is not None' guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(workflows): cover explicit else:None and missing-else separately

Per Copilot feedback: the parametrized valid-else test omitted the
'else' key when the value was None, so it covered only the missing-else
case, not an explicit 'else: None'. Set 'else' explicitly (including
None) in the parametrized test and add a dedicated missing-else test, so
both accepted shapes are pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:14:55 -05:00
Manfred Riem
7ff4522cf3 chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
* chore: bump version to 0.12.12

* chore: begin 0.12.13.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-13 10:03:36 -05:00
35 changed files with 1665 additions and 185 deletions

View File

@@ -2,6 +2,37 @@
<!-- insert new changelog below this comment -->
## [0.12.14] - 2026-07-13
### Changed
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
- Add Test-First Governance preset to community catalog (#3504)
- Add Autonomous Run Governance preset to community catalog (#3501)
- fix(workflows): validate command step input/options are mappings (#3262)
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
## [0.12.13] - 2026-07-13
### Changed
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
- Cleanup agent-file-template.md (#2579)
- fix: mark Kiro integration as multi-install safe (#3472)
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
- fix(templates): point constitution sync checklist at installed command files (#3418)
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
- docs: clarify that release tags keep the leading v prefix (#3463)
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
## [0.12.12] - 2026-07-13
### Changed

View File

@@ -45,7 +45,7 @@ Spec-Driven Development **flips the script** on traditional software development
### 1. Install Specify CLI
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z

View File

@@ -98,6 +98,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
@@ -119,6 +120,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |

View File

@@ -11,6 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 10 templates, 2 commands | — | [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) |
@@ -28,6 +29,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| 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) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |

View File

@@ -11,7 +11,8 @@ If you want to try Spec Kit without installing it permanently, use `uvx` to run
# Create a new project (latest from main)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
# Or target a specific release (replace vX.Y.Z with a tag from Releases;
# keep the leading v, e.g. v0.12.11 not 0.12.11)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Initialize in the current directory

View File

@@ -7,7 +7,8 @@
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
# Install a specific stable release (recommended — replace vX.Y.Z with the
# latest tag, keeping the leading v, e.g. v0.12.11 not 0.12.11)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes)

View File

@@ -15,7 +15,7 @@
### Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
> [!NOTE]
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1427,6 +1427,57 @@
"created_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-06T00:00:00Z"
},
"gates": {
"name": "Quality Gates (Enforcement Layer)",
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
"author": "schwichtgit",
"version": "0.1.0",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.12.0",
"tools": [
{
"name": "jq",
"required": true
},
{
"name": "git",
"required": true
},
{
"name": "node",
"required": false
},
{
"name": "shellcheck",
"required": false
}
]
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"quality",
"enforcement",
"hooks",
"ci",
"governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
"id": "github-issues",
@@ -2217,6 +2268,42 @@
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"memory": {
"name": "Spec Kit Memory",
"id": "memory",
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
"author": "Andrey Zaytsev",
"version": "0.3.0",
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/zaytsevand/spec-kit-memory",
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
"changelog": "",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{ "name": "memsearch", "required": false }
]
},
"provides": {
"commands": 2,
"hooks": 3
},
"tags": [
"memory",
"recall",
"research",
"memsearch"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",

View File

@@ -158,8 +158,7 @@ presets/
├── plan-template.md
├── tasks-template.md
├── checklist-template.md
── constitution-template.md
└── agent-file-template.md
── constitution-template.md
```
## Module Structure

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-30T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -131,6 +131,34 @@
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
},
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.1.1",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 10,
"commands": 2
},
"tags": [
"autonomous",
"governance",
"evidence",
"permissions",
"retrospective"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
"id": "canon-core",
@@ -618,6 +646,34 @@
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"test-first-governance": {
"name": "Test-First Governance",
"id": "test-first-governance",
"version": "1.3.0",
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
"author": "Zoltán Katona, PhD",
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.12.11"
},
"provides": {
"templates": 10,
"commands": 8
},
"tags": [
"tdd",
"bdd",
"atdd",
"quality-gates",
"traceability"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",

View File

@@ -44,12 +44,6 @@ provides:
description: "Self-test constitution template"
replaces: "constitution-template"
- type: "template"
name: "agent-file-template"
file: "templates/agent-file-template.md"
description: "Self-test agent file template"
replaces: "agent-file-template"
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"

View File

@@ -1,9 +0,0 @@
# Agent File (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Agent Instructions
Follow these guidelines when working on this project.

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.12"
version = "0.12.14"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -213,6 +213,52 @@ class CommandRegistrar:
".specify.specify/", ".specify/"
)
@staticmethod
def rewrite_extension_paths(
text: str, extension_id: str, extension_dir: Path
) -> str:
"""Rewrite extension-relative paths to their installed locations.
Extension command bodies reference bundled files relative to the
extension root (e.g. ``agents/control/commander.md``). After install
those files live under ``.specify/extensions/<id>/``, so bare
references would resolve against the workspace root and never be
found (#2101).
Only directories that actually exist inside *extension_dir* are
rewritten, keeping the behaviour conservative and avoiding false
positives on prose. ``commands`` (slash-command sources), ``specs``
(user project artifacts) and dot-directories are never rewritten.
"""
if not isinstance(text, str) or not text:
return text
skip = {"commands", ".git", "specs"}
try:
subdirs = [
entry.name
for entry in extension_dir.iterdir()
if entry.is_dir()
and entry.name not in skip
and not entry.name.startswith(".")
]
except OSError:
return text
for subdir in subdirs:
# Only rewrite relative references (subdir/... or ./subdir/...);
# absolute paths like /subdir/... keep their meaning. Use a
# callable replacement: subdir/extension_id come from the
# filesystem and could contain backslashes or "\1"-like
# sequences, which would corrupt a string replacement template.
replacement = f".specify/extensions/{extension_id}/{subdir}/"
text = re.sub(
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
lambda m: m.group(1) + replacement,
text,
)
return text
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
@@ -639,6 +685,9 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
if extension_id:
body = self.rewrite_extension_paths(body, extension_id, source_root)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)

View File

@@ -220,16 +220,45 @@ def register(app: typer.Typer) -> None:
console.print(
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
)
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
if force:
# Proceeding: the merge/overwrite warning is accurate here.
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print(
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
)
else:
response = typer.confirm("Do you want to continue?")
if not response:
# Fold the merge risk into the confirmation prompt rather than
# printing it unconditionally first: on the EOF/no-input path
# below the command exits without changing anything, so a
# standalone "will be merged" line would mislead. Interactive
# users still see the risk as part of the question.
#
# Call typer.confirm normally so piped y/n is honored — e.g.
# `echo y | specify init --here` keeps reaching the
# non-destructive preserve-merge path.
try:
proceed = typer.confirm(
"Template files will be merged with existing content "
"and may overwrite existing files. Do you want to continue?"
)
except (typer.Abort, EOFError):
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
# and an EOF on closed/empty stdin. Distinguish them: a real
# TTY cancellation is a normal exit (0, "cancelled"), while a
# missing-input EOF (non-interactive) becomes an actionable
# error pointing at --force.
if _stdin_is_interactive():
console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0) from None
console.print(
"[red]Error:[/red] Current directory is not empty and no "
"confirmation input is available. Re-run with "
"[bold]--force[/bold] to merge into it."
)
raise typer.Exit(1) from None
if not proceed:
console.print("[yellow]Operation cancelled[/yellow]")
raise typer.Exit(0)
else:

View File

@@ -1078,6 +1078,11 @@ class ExtensionManager:
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
# Mirror the register_commands() rewrite (#2101): resolve
# extension-relative subdir references (agents/, knowledge-base/,
# etc.) to their installed .specify/extensions/<id>/ location
# before the generic placeholder/path resolution below.
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)

View File

@@ -190,7 +190,15 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
"""
import shlex
parsed: dict[str, Any] = {}
tokens = shlex.split(raw_options)
try:
tokens = shlex.split(raw_options)
except ValueError as exc:
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
# makes shlex raise "No closing quotation". Translate it into the same
# clean exit-1 UX as every other bad-input path below rather than
# letting a raw traceback escape.
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
raise typer.Exit(1)
declared_options = list(integration.options())
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
allowed = ", ".join(sorted(opt.name for opt in declared_options))

View File

@@ -13,6 +13,7 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
multi_install_safe = True
config = {
"name": "Kiro CLI",
"folder": ".kiro/",
@@ -26,3 +27,10 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). The registry's multi-install-safe contract tests
# enforce that isolation for every integration setting this flag.
multi_install_safe = True

View File

@@ -778,6 +778,7 @@ class PresetManager:
matching_cmds, ext_id, ext_dir,
self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
extension_id=ext_id,
)
registered = True
except Exception:
@@ -1199,6 +1200,8 @@ class PresetManager:
"command_name": cmd_name,
"source_file": source_file,
"source": f"extension:{manifest.id}",
"extension_id": manifest.id,
"extension_dir": ext_root,
}
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
restore_index.setdefault(modern_skill_name, restore_info)
@@ -1463,6 +1466,17 @@ class PresetManager:
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
# knowledge-base/, etc.) to their installed location before
# the generic placeholder resolution below, otherwise
# restoring after a preset override removal would leave
# bare, unresolvable paths in the skill body.
body = registrar.rewrite_extension_paths(
body,
extension_restore["extension_id"],
extension_restore["extension_dir"],
)
if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
@@ -2574,6 +2588,39 @@ class PresetResolver:
self._manifest_cache[key] = None
return self._manifest_cache[key]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
"""Resolve a preset's manifest-declared template entry and usable file.
Returns ``(entry, candidate)``:
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
the manifest is absent or does not list this ``(name, type)``.
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
The manifest is authoritative: when it declares a template (``entry`` is
not ``None``) but the file is unusable (``candidate`` is ``None``),
callers must NOT fall back to the convention lookup — that would mask a
typo or pick up an undeclared file. Shared by ``resolve()`` and
``collect_all_layers()`` so their manifest-first resolution cannot
silently diverge again (the divergence this fix addressed).
"""
manifest = self._get_manifest(pack_dir)
if not manifest:
return None, None
for tmpl in manifest.templates:
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
file_path = tmpl.get("file")
if file_path:
manifest_candidate = pack_dir / file_path
return tmpl, (
manifest_candidate if manifest_candidate.is_file() else None
)
return tmpl, None
return None, None
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
"""Build unified list of registered and unregistered extensions sorted by priority.
@@ -2676,6 +2723,27 @@ class PresetResolver:
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
# and do NOT fall back to convention when it's missing, to
# avoid masking typos or picking up an undeclared file. Only
# when the manifest is absent or doesn't list this template do
# we use the convention-based subdir lookup. Mirrors
# collect_all_layers()/resolve_content() so resolve() and
# resolve_with_source() agree with them instead of returning
# the core template (or a stray convention file).
entry, manifest_candidate = self._manifest_declared_template(
pack_dir, template_name, template_type
)
if manifest_candidate is not None:
return manifest_candidate
if entry is not None:
# Manifest declares this template but the file is missing,
# non-file (e.g. a directory), or an empty/falsey ``file``
# value. The manifest is authoritative, so skip this pack's
# convention fallback rather than mask a typo — mirrors
# collect_all_layers().
continue
for subdir in subdirs:
if subdir:
candidate = pack_dir / subdir / f"{template_name}{ext}"
@@ -2943,31 +3011,22 @@ class PresetResolver:
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
strategy = "replace"
manifest_file_path = None
manifest_has_strategy = False
manifest_found_entry = False
manifest = self._get_manifest(pack_dir)
if manifest:
for tmpl in manifest.templates:
if (tmpl.get("name") == template_name
and tmpl.get("type") == template_type):
strategy = tmpl.get("strategy", "replace")
manifest_has_strategy = "strategy" in tmpl
manifest_file_path = tmpl.get("file")
manifest_found_entry = True
break
# Use manifest file path if specified, otherwise convention-based
# lookup — but only when the manifest doesn't exist or doesn't
# list this template, so preset.yml stays authoritative.
entry, manifest_candidate = self._manifest_declared_template(
pack_dir, template_name, template_type
)
if entry is not None:
strategy = entry.get("strategy", "replace")
manifest_has_strategy = "strategy" in entry
# Use the manifest's declared file when it's a usable regular file;
# only fall back to convention-based lookup when the manifest
# doesn't list this template at all, so preset.yml stays
# authoritative (a declared-but-unusable file skips convention —
# parity with resolve()).
candidate = None
if manifest_file_path:
manifest_candidate = pack_dir / manifest_file_path
if manifest_candidate.exists():
candidate = manifest_candidate
# Explicit file path that doesn't exist: skip convention
# fallback to avoid masking typos or picking up unintended files.
elif not manifest_found_entry:
# Manifest doesn't list this template — check convention paths
if manifest_candidate is not None:
candidate = manifest_candidate
elif entry is None:
candidate = _find_in_subdirs(pack_dir)
if candidate:
# Legacy fallback: if manifest doesn't explicitly declare a
@@ -3038,6 +3097,8 @@ class PresetResolver:
"path": candidate,
"source": source,
"strategy": "replace",
"extension_id": ext_id,
"extension_dir": ext_dir,
})
# Priority 4: Core templates (always "replace")
@@ -3157,10 +3218,32 @@ class PresetResolver:
if not layers:
return None
def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
Extension layers are always inserted with strategy "replace"
(see collect_all_layers), so a layer only ever needs this
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
"""
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
from ..agents import CommandRegistrar
text = CommandRegistrar.rewrite_extension_paths(
text, extension_id, extension_dir
)
return text
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if layers[0]["strategy"] == "replace":
return layers[0]["path"].read_text(encoding="utf-8")
return _read_layer_content(layers[0])
# Composition: build content bottom-up from the effective base.
# The base is the nearest replace layer scanning from highest priority
@@ -3183,7 +3266,7 @@ class PresetResolver:
# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
content = _read_layer_content(layers[base_layer_idx])
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1

View File

@@ -464,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
if op == "<=":
return _safe_compare(left, right, "<=")
if op == " in ":
return left in right if right is not None else False
return _safe_membership(left, right, negate=False)
if op == " not in ":
return left not in right if right is not None else True
return _safe_membership(left, right, negate=True)
# Numeric literal
try:
@@ -511,6 +511,26 @@ def _coerce_number(value: Any) -> Any:
return value
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
``left in right`` raises ``TypeError`` whenever the operands don't support
membership testing — most commonly a non-iterable right operand (``None``,
an int, a bool), but also cases like an unhashable ``left`` against a set.
In every such case the membership relation is undefined, so treat it as
``False`` (``not in`` as ``True``) rather than leaking the error out of the
evaluator and crashing the whole workflow. Mirrors the graceful
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
generalizes the previous ``right is not None`` guard to any operand pair
that can't be membership-tested.
"""
try:
contained = left in right
except TypeError:
contained = False
return not contained if negate else contained
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.

View File

@@ -31,6 +31,20 @@ class CommandStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
input_data = config.get("input", {})
# validate() rejects a non-mapping input, but the engine does not
# auto-validate before execute(); a workflow that skipped validation can
# still reach here. Fail the step with the same contract error rather
# than silently coercing to {} and dispatching with empty args — that
# would change the command's meaning, hide the config error, and report
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
if not isinstance(input_data, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'input' must be a "
f"mapping, got {type(input_data).__name__}."
),
)
# Resolve expressions in input
resolved_input: dict[str, Any] = {}
@@ -50,8 +64,18 @@ class CommandStep(StepBase):
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
if step_options:
options.update(step_options)
# Same rationale as 'input': a malformed options fails the step rather
# than being silently ignored (which would let an invalid step run and
# apparently complete).
if not isinstance(step_options, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'options' must be a "
f"mapping, got {type(step_options).__name__}."
),
)
options.update(step_options)
# Attempt CLI dispatch
args_str = str(resolved_input.get("args", ""))
@@ -155,4 +179,16 @@ class CommandStep(StepBase):
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
# execute() iterates input.items() and options.update(step_options); a
# non-mapping here would raise at run time. Validate the shape like the
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
# crashed on.
if "input" in config and not isinstance(config["input"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
)
if "options" in config and not isinstance(config["options"], dict):
errors.append(
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
)
return errors

View File

@@ -24,6 +24,24 @@ class FanInStep(StepBase):
if not isinstance(output_config, dict):
output_config = {}
# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then
# either crashes the whole run (a scalar like an int or None raises
# TypeError) or, worse, silently iterates a string's characters and
# yields a bogus join of empty results with a COMPLETED status — the
# exact "silent empty result + COMPLETED" wiring bug the engine's
# fan-in validation guards against. Fail this step loudly instead,
# mirroring the fan-out step's non-list ``items`` handling.
if not isinstance(wait_for, list):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
f"a list of step IDs, got {type(wait_for).__name__}."
),
output={"results": []},
)
# Collect results from referenced steps
results = []
for step_id in wait_for:

View File

@@ -47,8 +47,8 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
)
else_branch = config.get("else", [])
if else_branch and not isinstance(else_branch, list):
else_branch = config.get("else")
if else_branch is not None and not isinstance(else_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import math
import subprocess
from typing import Any
@@ -25,15 +26,20 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# Defensive: the engine does not auto-validate step config, so an
# invalid ``timeout`` (string, None, ...) would otherwise raise a
# TypeError from subprocess.run() and crash the whole run. Mirror
# the engine's handling of unvalidated ``continue_on_error`` by
# only honoring well-formed values and falling back to the default.
# Per-step execution timeout in seconds; defaults to 300 for backward
# compatibility. The engine does not auto-validate step config, so
# validate here as well — a caller that skips WorkflowEngine.validate()
# must fail the step cleanly rather than crash subprocess.run() with a
# TypeError (or silently coerce ``timeout: true`` to a 1s duration,
# since bool is an int subclass).
timeout = config.get("timeout", 300)
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
timeout = 300
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(
status=StepStatus.FAILED,
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -92,6 +98,32 @@ class ShellStep(StepBase):
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message. An absent ``timeout`` is valid (the
default is used). bool is a subclass of int, but ``timeout: true`` is a
config error rather than a duration, so it is rejected explicitly.
Non-finite floats (YAML ``.inf``/``.nan``) pass a plain ``> 0`` check
but would raise in subprocess.run(), so they are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
@@ -114,16 +146,7 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
if "timeout" in config:
timeout = config["timeout"]
# bool is an int subclass, so reject it explicitly.
if (
isinstance(timeout, bool)
or not isinstance(timeout, int)
or timeout <= 0
):
errors.append(
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive integer (seconds) when present, got {timeout!r}."
)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
return errors

View File

@@ -26,6 +26,20 @@ class SwitchStep(StepBase):
str_value = str(value) if value is not None else ""
cases = config.get("cases", {})
if not isinstance(cases, dict):
# The engine does not auto-validate step config, so an unvalidated
# run with a non-mapping ``cases`` (a list/scalar authoring mistake)
# would otherwise raise AttributeError from ``.items()`` below and
# crash the whole run. Fail this step loudly instead, mirroring the
# fan-out step's non-list ``items`` handling.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a "
f"mapping, got {type(cases).__name__}."
),
output={"matched_case": None, "expression_value": value},
)
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
return StepResult(

View File

@@ -81,7 +81,7 @@ Follow this execution flow:
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):

View File

@@ -115,6 +115,63 @@ class TestInitIntegrationFlag:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
"""`init --here` on a non-empty directory with no confirmation input (empty
stdin) must fail fast with guidance to use --force, instead of the bare
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
empty stdin, so typer.confirm raises Abort, which the command converts to the
actionable error."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "nonempty-here"
project.mkdir()
(project / "existing.txt").write_text("keep me", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 1, result.output
assert "--force" in result.output
# Aborted before scaffolding: the pre-existing file is untouched.
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
--force error, which is reserved for non-interactive EOF. Guards the
regression where Abort was caught unconditionally and every cancel became
an exit-1 --force error."""
from typer.testing import CliRunner
from specify_cli import app
import specify_cli.commands.init as init_mod
# Simulate an interactive terminal so the Abort is treated as a cancel.
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
project = tmp_path / "cancel-here"
project.mkdir()
(project / "existing.txt").write_text("keep me", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "cancelled" in result.output.lower()
assert "--force" not in result.output # not the missing-input error
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
def test_integration_copilot_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
@@ -835,7 +892,8 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
"""E2E: confirming the merge with piped "y" (no --force) preserves
existing shared infra files (unlike --force, which overwrites them)."""
from typer.testing import CliRunner
from specify_cli import app

View File

@@ -42,6 +42,9 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".kiro/prompts"
def test_declares_multi_install_safe(self):
assert get_integration(self.KEY).multi_install_safe is True
def test_registrar_config(self):
"""Override base assertion: kiro-cli uses a prose fallback for args
because Kiro CLI file-based prompts do not natively substitute

View File

@@ -2675,6 +2675,27 @@ class TestParseIntegrationOptionsEqualsForm:
assert result_space["commands_dir"] == "./mydir"
assert result_equals["commands_dir"] == "./mydir"
def test_unbalanced_quote_exits_cleanly(self, capsys):
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
shlex.split() raises ValueError("No closing quotation") on an unbalanced
quote; the parser must translate that into the same clean typer.Exit(1)
UX as unknown-option / missing-value, rather than letting the traceback
escape (issue #3457).
"""
import typer
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")
assert integration is not None
with pytest.raises(typer.Exit) as excinfo:
_parse_integration_options(integration, '--commands-dir "foo')
assert excinfo.value.exit_code == 1
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):

View File

@@ -272,6 +272,20 @@ class TestMultiInstallSafeContracts:
f"these files: {sorted(overlap)}"
)
def test_kiro_cli_is_declared_multi_install_safe(self):
"""kiro-cli confines itself to an isolated ``.kiro/`` root that no
other integration touches, so it must be declared multi-install safe
(issue #3471).
Before the fix, co-installing kiro-cli alongside another integration
left ``specify integration status`` permanently in ERROR
(``unsafe-multi-install``) with no way to acknowledge it. The
parametrized isolation/manifest contracts above already exercise
kiro-cli once the flag is set; this pins the declaration itself so a
future edit cannot silently drop it and reintroduce the error.
"""
assert INTEGRATION_REGISTRY["kiro-cli"].multi_install_safe is True
class TestCatalogParity:
"""The discovery catalog must list every registered integration."""

View File

@@ -911,6 +911,65 @@ class TestExtensionSkillRegistration:
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content
def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
"""Auto-registered skills should resolve extension-relative subdir
references (agents/, knowledge-base/) to their installed location,
matching the rewrite already applied by register_commands() (#2101)."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = temp_dir / "path-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "path-ext",
"name": "Path Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.path-ext.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
(ext_dir / "templates").mkdir()
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
"Use templates/kill-report.md as the report template.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
# extension's own templates/ dir must resolve under the extension,
# not the project-level .specify/templates/
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
assert "Read agents/control" not in content
assert "and knowledge-base/" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -2376,6 +2376,185 @@ Run {SCRIPT}
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
assert ".specify/scripts/bash/setup-plan.sh" not in content
@staticmethod
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
"""Create an extension whose command body references bundled subdirs."""
import yaml
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "templates").mkdir()
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
command = {
"name": f"speckit.{ext_id}.run",
"file": "commands/run.md",
"description": "Run",
}
if aliases:
command["aliases"] = aliases
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Echelon",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"commands": [command]},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text(
"---\ndescription: Run\n---\n\n"
"Read agents/control/commander.md for instructions.\n"
"Load knowledge-base/agent-scores.yaml for calibration.\n"
"Use templates/kill-report.md as output format.\n"
"Artifacts go to specs/001-internal/plan.md.\n"
"See commands/run.md for the source.\n"
)
return ext_dir
def test_codex_skill_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Extension-relative subdir refs must point at the installed location."""
ext_dir = self._make_subdir_extension(temp_dir)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
assert ".specify/extensions/echelon/agents/control/commander.md" in content
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
assert ".specify/extensions/echelon/templates/kill-report.md" in content
assert "Read agents/" not in content
# specs/ refs point at the user's project artifacts, never the extension
assert "to specs/001-internal/plan.md" in content
assert ".specify/extensions/echelon/specs/" not in content
# commands/ refs are slash-command sources, not runtime reads
assert "See commands/run.md" in content
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
self, project_dir, temp_dir
):
"""Alias skills reuse the rewritten body."""
ext_dir = self._make_subdir_extension(
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
alias_content = (
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
).read_text()
assert (
".specify/extensions/ext-alias-paths/agents/control/commander.md"
in alias_content
)
assert "Read agents/" not in alias_content
def test_markdown_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Markdown-format agents get the same rewrite via the shared path."""
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
amp_dir = project_dir / ".agents" / "commands"
amp_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
assert "Read agents/" not in content
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
"""Only directories present in the extension are rewritten."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-existing"
(ext_dir / "agents").mkdir(parents=True)
(ext_dir / ".hidden").mkdir()
text = (
"Read agents/one.md then knowledge-base/two.md.\n"
"Also ./agents/three.md but not /agents/abs.md.\n"
"Keep .hidden/secret.md alone.\n"
)
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext-existing", ext_dir
)
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
# absolute paths keep their meaning
assert "not /agents/abs.md" in rewritten
# knowledge-base/ does not exist in this extension: left untouched
assert "then knowledge-base/two.md" in rewritten
assert ".hidden/secret.md" in rewritten
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
self, temp_dir
):
"""subdir/extension_id containing regex-replacement-special characters
(e.g. backslash / group references) must not raise or be misinterpreted
by re.sub's replacement template (#2101).
The subdir name uses brackets rather than a backslash: on Windows,
"\\" is a path separator, so a subdir literally named "assets\\q"
would create nested directories "assets/q" instead of a single
directory, and iterdir() would then only discover "assets" - never
exercising the intended replacement text. extension_id isn't used to
create a directory, so it's free to contain a real backslash/"\\1"
to verify the callable replacement treats it literally.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-backslash"
weird_subdir = "assets[q]"
(ext_dir / weird_subdir).mkdir(parents=True)
# sanity-check the cross-platform assumption above
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext\\1", ext_dir
)
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
# absolute paths are still left untouched
assert f"/{weird_subdir}/abs.md" in rewritten
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
"""A missing extension directory leaves the text unchanged."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
text = "Read agents/one.md."
assert (
AgentCommandRegistrar.rewrite_extension_paths(
text, "gone", temp_dir / "does-not-exist"
)
== text
)
def test_register_commands_for_copilot(self, extension_dir, project_dir):
"""Test registering commands for Copilot agent with .agent.md extension."""
# Create .github/agents directory (Copilot project)

View File

@@ -884,6 +884,186 @@ class TestPresetResolver:
assert result is not None
assert "Custom Spec Template" in result.read_text()
def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False):
"""Create a pack whose manifest declares a NON-convention file: path.
Returns the pack dir under the project. The declared file lives at
custom/spec.md (not the convention templates/spec-template.md).
"""
presets_dir = project_dir / ".specify" / "presets"
pack_dir = presets_dir / "mypack"
(pack_dir / "custom").mkdir(parents=True)
(pack_dir / "custom" / "spec.md").write_text(
"# Manifest-declared Spec\n", encoding="utf-8"
)
if extra_file:
# An undeclared convention-path file the manifest points away from.
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text(
"# Stray Convention Spec\n", encoding="utf-8"
)
manifest = {
"schema_version": "1.0",
"preset": {
"id": "mypack",
"name": "My Pack",
"version": "1.0.0",
"description": "declares a non-convention file path",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "custom/spec.md",
"strategy": "replace",
}
]
},
}
with open(pack_dir / "preset.yml", "w") as f:
yaml.dump(manifest, f)
PresetRegistry(presets_dir).add(
"mypack", {"version": "1.0.0", "priority": 10}
)
return pack_dir
def test_resolve_uses_manifest_declared_file_path(self, project_dir):
"""resolve() must honor a manifest-declared non-convention file: path.
Previously the tier-2 loop was convention-only, so it returned the
core template and resolve_with_source() misattributed source='core',
diverging from collect_all_layers()/resolve_content().
"""
pack_dir = self._install_pack_with_manifest_file(project_dir)
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result == pack_dir / "custom" / "spec.md"
assert "Manifest-declared Spec" in result.read_text()
sourced = resolver.resolve_with_source("spec-template")
assert sourced is not None
assert "mypack" in sourced["source"]
# resolve() must agree with collect_all_layers()'s top layer.
layers = resolver.collect_all_layers("spec-template")
assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md"
def test_resolve_manifest_file_wins_over_undeclared_convention_file(
self, project_dir
):
"""A stray convention-path file must not shadow the manifest's file:."""
pack_dir = self._install_pack_with_manifest_file(
project_dir, extra_file=True
)
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result == pack_dir / "custom" / "spec.md"
assert "Manifest-declared Spec" in result.read_text()
def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir):
"""When the manifest declares a file: that does not exist, resolve()
must NOT fall back to a convention file in the same pack (that would
mask a typo) — it skips the pack and resolves core instead."""
presets_dir = project_dir / ".specify" / "presets"
pack_dir = presets_dir / "mypack"
# Manifest declares custom/spec.md (MISSING); a convention file exists
# in the pack and must NOT be used.
(pack_dir / "templates").mkdir(parents=True)
(pack_dir / "templates" / "spec-template.md").write_text(
"# Stray Convention Spec\n", encoding="utf-8"
)
manifest = {
"schema_version": "1.0",
"preset": {
"id": "mypack",
"name": "My Pack",
"version": "1.0.0",
"description": "declares a missing file path",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "custom/spec.md",
"strategy": "replace",
}
]
},
}
with open(pack_dir / "preset.yml", "w") as f:
yaml.dump(manifest, f)
PresetRegistry(presets_dir).add(
"mypack", {"version": "1.0.0", "priority": 10}
)
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
content = result.read_text()
assert "Stray Convention Spec" not in content # pack convention skipped
assert "Core Spec Template" in content # fell through to core
def test_resolve_skips_convention_when_manifest_file_is_directory(
self, project_dir
):
"""When the manifest's file: path resolves to a DIRECTORY (not a regular
file), resolve()/collect_all_layers() must treat it as missing — exists()
would accept it and downstream read_text() on a directory would crash.
The pack is skipped (no convention fallback), so core wins."""
presets_dir = project_dir / ".specify" / "presets"
pack_dir = presets_dir / "mypack"
# Declared file: custom/spec.md is created as a DIRECTORY.
(pack_dir / "custom" / "spec.md").mkdir(parents=True)
# A convention file also exists and must NOT be used.
(pack_dir / "templates").mkdir(parents=True)
(pack_dir / "templates" / "spec-template.md").write_text(
"# Stray Convention Spec\n", encoding="utf-8"
)
manifest = {
"schema_version": "1.0",
"preset": {
"id": "mypack",
"name": "My Pack",
"version": "1.0.0",
"description": "declares a file: that is actually a directory",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "custom/spec.md",
"strategy": "replace",
}
]
},
}
with open(pack_dir / "preset.yml", "w") as f:
yaml.dump(manifest, f)
PresetRegistry(presets_dir).add(
"mypack", {"version": "1.0.0", "priority": 10}
)
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert result.is_file() # never a directory
content = result.read_text()
assert "Stray Convention Spec" not in content # pack convention skipped
assert "Core Spec Template" in content # fell through to core
# collect_all_layers() must agree: the directory is not a layer.
layers = resolver.collect_all_layers("spec-template")
assert all(Path(layer["path"]).is_file() for layer in layers)
assert all(
Path(layer["path"]) != pack_dir / "custom" / "spec.md"
for layer in layers
)
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
"""Test that overrides take priority over installed packs."""
# Install the pack
@@ -2620,7 +2800,7 @@ class TestSelfTestPreset:
assert manifest.id == "self-test"
assert manifest.name == "Self-Test Preset"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 8 # 6 templates + 2 commands
assert len(manifest.templates) == 7 # 5 templates + 2 commands
def test_self_test_provides_all_core_templates(self):
"""Verify the self-test preset provides an override for every core template."""
@@ -3663,6 +3843,8 @@ class TestPresetSkills:
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\n"
"description: Extension fakeext cmd\n"
@@ -3671,6 +3853,7 @@ class TestPresetSkills:
"---\n\n"
"extension:fakeext\n"
"Run {SCRIPT}\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
@@ -3736,8 +3919,92 @@ class TestPresetSkills:
assert "source: extension:fakeext" in content
assert "extension:fakeext" in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
# Extension-relative subdir references must resolve to their
# installed location on restore too (#2101), not just on first
# registration.
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "# Fakeext Cmd Skill" in content
def test_skill_composed_over_extension_base_rewrites_subdir_paths(
self, project_dir, temp_dir
):
"""When a preset composes (append) over an extension-provided base
command, the resulting skill (read from the .composed output) must
still resolve the extension's own subdir references (#2101), not
just when the extension wins outright (replace)."""
self._write_init_options(project_dir, ai="codex")
skills_dir = project_dir / ".agents" / "skills"
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / "ext-base-append-skill"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Preset overlay\n---\n\n## Extra\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-base-append-skill",
"name": "Ext Base Append Skill",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": "append",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
content = skill_file.read_text()
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
self._write_init_options(project_dir, ai="codex")
@@ -5972,6 +6239,86 @@ class TestResolveContent:
content = resolver.resolve_content("spec-template")
assert content == "# Replaced content\n"
@pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"])
def test_resolve_content_rewrites_extension_base_subdir_paths(
self, project_dir, temp_dir, strategy
):
"""Composing over an extension-provided base command must resolve the
extension's own subdir references (agents/, knowledge-base/) to their
installed location (#2101), not just when the extension wins outright.
"""
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / f"ext-base-{strategy}"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
overlay_body = (
"{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n"
)
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
f"---\ndescription: Preset overlay\n---\n\n{overlay_body}"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": f"ext-base-{strategy}",
"name": "Ext Base",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": strategy,
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.fakeext.cmd", "command")
assert content is not None
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
class TestCollectAllLayers:
"""Test PresetResolver.collect_all_layers() method."""
@@ -6052,6 +6399,172 @@ class TestCollectAllLayers:
class TestRemoveReconciliation:
"""Test that removing a preset re-registers the next layer's command."""
def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent(
self, project_dir, temp_dir
):
"""When a preset override of an extension command is removed, the
reconciled non-skill-agent command file should have the extension's
own subdir references rewritten to their installed location (#2101),
not left as bare, unresolvable paths."""
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
manager = PresetManager(project_dir)
preset_dir = temp_dir / "ext-cmd-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\n\npreset override content\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-cmd-override",
"name": "Ext Cmd Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should exist in gemini dir"
assert "preset override content" in cmd_files[0].read_text()
manager.remove("ext-cmd-override")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should still exist after removal"
content = cmd_files[0].read_text()
assert "preset override content" not in content
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent(
self, project_dir, temp_dir
):
"""When a preset overlays (append) an extension-provided base command,
the initial composed non-skill-agent command file must have the
extension's own subdir references rewritten to their installed
location (#2101), matching the live repro: extension body
'Read agents/control/commander.md', preset appends to
speckit.fakeext.cmd, generated Gemini content retains the bare path."""
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(extension_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Extension fakeext cmd\n---\n\n"
"Read agents/control/commander.md for context.\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / "ext-cmd-append"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Append fakeext cmd\n---\n\n## Extra\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-cmd-append",
"name": "Ext Cmd Append",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"strategy": "append",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_files = list(gemini_dir.glob("*fakeext*"))
assert cmd_files, "Command file should exist in gemini dir"
content = cmd_files[0].read_text()
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
assert "Read agents/control" not in content
assert "## Extra" in content
def test_remove_restores_lower_priority_command(
self, project_dir, temp_dir, valid_pack_data
):

View File

@@ -460,6 +460,34 @@ class TestExpressions:
assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True
assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b"
def test_membership_against_non_iterable_is_false_not_error(self):
from specify_cli.workflows.expressions import (
evaluate_condition,
evaluate_expression,
)
from specify_cli.workflows.base import StepContext
# A non-iterable right operand (int, bool, None, float) makes a raw
# `x in y` raise TypeError in Python. The evaluator must treat it as
# "not contained" (False, and `not in` as True) instead of leaking the
# TypeError and crashing the whole workflow run. This generalizes the
# previous `right is not None` guard and mirrors _safe_compare, which
# already swallows TypeError for the ordering operators.
ctx = StepContext(inputs={"tag": "x", "count": 5, "ratio": 1.5, "flag": True})
assert evaluate_expression("{{ inputs.tag in inputs.count }}", ctx) is False
assert evaluate_expression("{{ inputs.tag not in inputs.count }}", ctx) is True
assert evaluate_expression("{{ 'a' in inputs.ratio }}", ctx) is False
assert evaluate_expression("{{ 'a' in inputs.flag }}", ctx) is False
assert evaluate_expression("{{ inputs.tag in inputs.missing }}", ctx) is False
# A condition that would otherwise crash the run now evaluates cleanly.
assert evaluate_condition("{{ inputs.tag in inputs.count }}", ctx) is False
# Regression: genuine membership over a real iterable still works.
ok = StepContext(inputs={"items": ["x", "y"], "s": "xyz"})
assert evaluate_expression("{{ 'x' in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'z' not in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'y' in inputs.s }}", ok) is True
def test_filter_default(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -925,6 +953,33 @@ class TestCommandStep:
errors = step.validate({"id": "test"})
assert any("missing 'command'" in e for e in errors)
def test_validate_rejects_non_mapping_input_and_options(self):
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
step = CommandStep()
# execute() does input.items() / options.update(); a non-mapping must be
# reported by validate(), not crash at run time (like switch 'cases').
for bad in (None, "args", ["a", "b"], 5):
errs = step.validate({"id": "c", "command": "/x", "input": bad})
assert any("'input' must be a mapping" in e for e in errs), bad
errs = step.validate({"id": "c", "command": "/x", "options": 42})
assert any("'options' must be a mapping" in e for e in errs)
# a valid mapping config is still accepted
assert step.validate({"id": "c", "command": "/x", "input": {"args": "y"}, "options": {"k": 1}}) == []
# execute() has no auto-validation guarantee (the engine may skip
# validate), so a non-mapping input/options FAILS the step with the same
# contract error — it does not silently coerce to empty and report
# COMPLETED (which would defeat continue_on_error).
res_in = step.execute({"id": "c", "command": "echo", "input": None}, StepContext())
assert res_in.status is StepStatus.FAILED
assert "'input' must be a mapping" in (res_in.error or "")
res_opt = step.execute(
{"id": "c", "command": "echo", "input": {}, "options": 42}, StepContext()
)
assert res_opt.status is StepStatus.FAILED
assert "'options' must be a mapping" in (res_opt.error or "")
def test_step_override_integration(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
@@ -1353,107 +1408,6 @@ class TestShellStep:
assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_timeout_is_configurable(self, monkeypatch):
"""A 'timeout' field overrides the 300s default (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
step = ShellStep()
result = step.execute(
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 1800
def test_timeout_defaults_to_300(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
def test_timeout_error_reports_configured_value(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def raise_timeout(*args, **kwargs):
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
)
result = ShellStep().execute(
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "7 seconds" in result.error
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
"""execute() falls back to 300 when config skipped validation (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute(
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_validate_rejects_bad_timeout(self, bad):
from specify_cli.workflows.steps.shell import ShellStep
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
assert any("'timeout'" in e for e in errors)
def test_validate_accepts_positive_int_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
assert (
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
)
def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
@@ -1510,6 +1464,130 @@ class TestShellStep:
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
assert any("'output_format' must be 'json'" in e for e in errors)
def test_configured_timeout_is_passed_to_subprocess(self, monkeypatch):
"""A ``timeout:`` value on the step overrides the 300s default and is
threaded through to ``subprocess.run`` (issue #3327)."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(
args=args[0] if args else "", returncode=0, stdout="", stderr=""
)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": 1800}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert captured["timeout"] == 1800
def test_default_timeout_preserved_when_omitted(self, monkeypatch):
"""Omitting ``timeout:`` preserves the historical 300s default."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(
args=args[0] if args else "", returncode=0, stdout="", stderr=""
)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
step.execute({"id": "qa", "run": "echo hi"}, StepContext())
assert captured["timeout"] == 300
def test_timeout_error_reports_configured_value(self, monkeypatch):
"""The timeout failure message reflects the configured duration, not a
hardcoded 300."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def fake_run(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd="echo hi", timeout=5)
monkeypatch.setattr(subprocess, "run", fake_run)
step = ShellStep()
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": 5}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "5 seconds" in (result.error or "")
def test_execute_fails_cleanly_on_invalid_timeout(self, monkeypatch):
"""execute() must fail the step (not raise) on an invalid timeout even
when validate() was skipped — the engine does not auto-validate step
config, so an unvalidated string/bool/non-finite timeout would
otherwise crash subprocess.run() and take down the whole run."""
import subprocess
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def fail_if_called(*args, **kwargs):
raise AssertionError("subprocess.run should not run on invalid timeout")
monkeypatch.setattr(subprocess, "run", fail_if_called)
step = ShellStep()
# A string would raise TypeError; ``True`` would silently become a 1s
# timeout (bool is an int subclass); ``.inf`` would raise at runtime.
for bad in ("30", True, float("inf"), 0):
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "'timeout' must be a positive number" in (result.error or "")
def test_validate_rejects_non_positive_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
for bad in (0, -30):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_rejects_non_numeric_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
# A string and a bool are both invalid (bool is an int subclass but a
# config error, not a duration).
for bad in ("30", True):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_rejects_non_finite_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
# inf/nan are floats and slip past a plain ``> 0`` check (``nan <= 0``
# is False), but ``subprocess.run(timeout=...)`` would then fail at
# runtime. YAML ``.inf``/``.nan`` scalars parse to these via safe_load.
for bad in (float("inf"), float("-inf"), float("nan")):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)
def test_validate_accepts_positive_numeric_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
for good in (1, 300, 1800, 12.5):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": good})
assert not any("'timeout'" in e for e in errors)
class _StubStdin:
"""Stdin stub exposing only a fixed ``isatty`` result.
@@ -1988,6 +2066,46 @@ class TestIfThenStep:
errors = step.validate({"id": "test", "then": []})
assert any("missing 'condition'" in e for e in errors)
@pytest.mark.parametrize("bad_else", [False, 0, "", {}, 42])
def test_validate_rejects_non_list_else(self, bad_else):
"""A non-list 'else' must be rejected even when it is falsy.
The original guard used ``if else_branch and ...`` which
short-circuits for falsy non-list values (False/0/''/{}), letting a
malformed else-branch pass validation only to be silently skipped at
runtime. ``then`` is already strictly validated; ``else`` must match.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate(
{"id": "i", "condition": "true", "then": [], "else": bad_else}
)
assert any("'else' must be a list of steps" in e for e in errors)
@pytest.mark.parametrize("ok_else", [None, [], [{"id": "x", "command": "/y"}]])
def test_validate_accepts_valid_else(self, ok_else):
"""An explicit 'else' of None or a list stays valid.
``else`` is set explicitly here (including ``else: None``) so the
explicit-None case is exercised, not just the missing-key case.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate(
{"id": "i", "condition": "true", "then": [], "else": ok_else}
)
assert not any("'else'" in e for e in errors)
def test_validate_accepts_missing_else(self):
"""A missing 'else' key stays valid (no else branch)."""
from specify_cli.workflows.steps.if_then import IfThenStep
step = IfThenStep()
errors = step.validate({"id": "i", "condition": "true", "then": []})
assert not any("'else'" in e for e in errors)
class TestSwitchStep:
"""Test the switch step type."""
@@ -2052,6 +2170,35 @@ class TestSwitchStep:
assert result.output["matched_case"] == "__default__"
assert result.next_steps == []
def test_execute_non_dict_cases_fails_loudly(self):
"""A non-mapping ``cases`` must fail the step, not crash the run.
``validate`` rejects a non-dict ``cases``, but the engine's
``execute()`` does not auto-validate (see ``WorkflowEngine.load_workflow``
docstring). Before the guard, ``execute`` called ``cases.items()`` on the
raw value, so an unvalidated run with a list/scalar ``cases`` raised
AttributeError and took down the whole run instead of failing this step.
Mirrors the fan-out step's non-list ``items`` handling.
"""
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext, StepStatus
step = SwitchStep()
ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}})
for bad_cases in (["approve"], "approve", 5):
result = step.execute(
{
"id": "route",
"expression": "{{ steps.review.output.choice }}",
"cases": bad_cases,
},
ctx,
)
assert result.status == StepStatus.FAILED
assert "'cases' must be a mapping" in (result.error or "")
# expression is still evaluated, so its value is surfaced for context.
assert result.output["expression_value"] == "approve"
def test_validate_missing_expression(self):
from specify_cli.workflows.steps.switch import SwitchStep
@@ -2355,6 +2502,29 @@ class TestFanInStep:
result = step.execute(config, ctx)
assert result.output["results"] == [{}]
@pytest.mark.parametrize("bad_wait_for", ["stepA", 5, None, {"a": 1}])
def test_execute_non_list_wait_for_fails_loudly(self, bad_wait_for):
"""A non-list ``wait_for`` must fail the step, not crash the run or
silently produce a bogus join.
``validate`` rejects a non-list ``wait_for``, but the engine's
``execute()`` does not auto-validate. Before the guard, ``execute``
iterated the raw value: a scalar (int/None) raised TypeError and took
down the whole run, while a string silently iterated its characters and
returned a join of empty results with a COMPLETED status — the exact
"silent empty result + COMPLETED" wiring bug the engine's fan-in
validation warns against. Mirrors the fan-out non-list ``items`` guard.
"""
from specify_cli.workflows.steps.fan_in import FanInStep
from specify_cli.workflows.base import StepContext, StepStatus
step = FanInStep()
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
result = step.execute({"id": "collect", "wait_for": bad_wait_for}, ctx)
assert result.status == StepStatus.FAILED
assert "'wait_for' must be a list" in (result.error or "")
assert result.output["results"] == []
def test_validate_empty_wait_for(self):
from specify_cli.workflows.steps.fan_in import FanInStep

View File

@@ -112,8 +112,14 @@ Run a shell command and capture output:
- id: run-tests
type: shell
run: "cd {{ inputs.project_dir }} && npm test"
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
```
`timeout` is the maximum time in seconds the command may run before it is
killed and the step fails; it must be a positive number and defaults to
`300` (five minutes) when omitted. Raise it for long-running gates such as
full builds, linter aggregators, or integration-test targets.
### Init Steps
Bootstrap a project the same way `specify init` does — scaffolding