Compare commits

...

45 Commits

Author SHA1 Message Date
github-actions[bot]
1d2fbf90ce chore: bump version to 0.12.1 2026-06-30 11:45:15 +00:00
Manfred Riem
4038d370bf chore: align CI Python matrix with devguide lifecycle + fix bash 3.2 portability (#3244)
* chore: align CI Python matrix with devguide release lifecycle

Run the pytest matrix only on the bugfix (maintenance) releases — 3.13
and 3.14 — instead of 3.11/3.12/3.13, and point the ruff lint job at the
latest interpreter (3.14). The supported floor stays at requires-python
>= 3.11 (oldest non-EOL security release): older security versions are
supported by claim and fixed reactively rather than gated on a wide
per-commit matrix. Also add macos-latest to the OS matrix so macOS
regressions are caught.

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

* fix: make bash scripts portable to bash 3.2 (macOS system /bin/bash)

Adding macos-latest to the CI matrix surfaced two pre-existing bash 3.2
incompatibilities (macOS ships bash 3.2 as /bin/bash):

1. update-agent-context.sh embedded Python heredocs inside $(...) command
   substitution. bash 3.2 mis-parses an apostrophe in a heredoc body
   nested in $(...), failing with "unexpected EOF while looking for
   matching `''". Removed the apostrophes from the affected $()-nested
   heredoc body and documented the constraint to prevent regressions.

2. create-new-feature-branch.sh and create-new-feature.sh used the
   bash 4+ ${word^^} uppercase parameter expansion, which errors as a
   "bad substitution" on bash 3.2 and caused short uppercase acronyms
   (e.g. "GO") to be dropped from derived branch names. Replaced with a
   portable `tr '[:lower:]' '[:upper:]'` pipeline.

Verified the full test suite passes under bash 3.2.57 and shellcheck
(--severity=error) is clean.

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

* fix: address review feedback on bash 3.2 portability changes

- create-new-feature.sh: replace the non-portable `\b...\b` grep
  word-boundary (BSD grep treats `\b` as a backspace, so the acronym
  branch could silently fail) with `grep -qw`, matching its twin
  create-new-feature-branch.sh, and pipe the description via
  `printf '%s'` instead of `echo`.
- create-new-feature-branch.sh: switch the acronym check to
  `printf '%s'` as well so both twins are identical and avoid `echo`
  on user-provided text.
- update-agent-context.sh: reword the apostrophe-free self-seeding
  comment to be clearer and less easy to misread.

Verified under bash 3.2.57 (full bash-script suite green) and
shellcheck --severity=error.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 06:43:48 -05:00
Noor ul ain
ea1827769a fix: stop check-prerequisites --paths-only from writing feature.json (#3025) (#3190)
* fix: stop check-prerequisites --paths-only from writing feature.json (#3025)

check-prerequisites --paths-only / -PathsOnly is documented as pure,
read-only path resolution, but when SPECIFY_FEATURE_DIRECTORY was set it
called the persist routine and rewrote .specify/feature.json. That dirtied
the working tree and overwrote a pinned feature directory during what should
be a no-op.

Add an explicit opt-out at the resolver boundary instead of a global env
back-channel:

- bash: get_feature_paths accepts a leading --no-persist flag that skips
  _persist_feature_json; check-prerequisites.sh passes it in --paths-only mode.
- PowerShell: Get-FeaturePathsEnv gains a -NoPersist switch that skips
  Save-FeatureJson; check-prerequisites.ps1 passes it in -PathsOnly mode.

Normal (non-paths-only) invocations are unchanged and still persist the
override, so future sessions without the env var keep working.

Add regression tests asserting --paths-only/-PathsOnly leaves a pinned
feature.json untouched even when the env override differs, plus a guard that
normal mode still persists.

* fix: use ASCII hyphen in common.ps1 comment for PS 5.1 compatibility

The em-dash in the persist comment introduced non-ASCII bytes, failing
test_ps1_file_is_ascii_only which enforces ASCII-only PowerShell sources
for Windows PowerShell 5.1 compatibility.

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

* test: add PowerShell normal-mode persistence guard (#3025)

Addresses Copilot review feedback on #3190: the bash side had a
`test_normal_mode_still_persists_feature_json` guard, but there was no
symmetric PowerShell test asserting that running check-prerequisites.ps1
*without* -PathsOnly still persists the SPECIFY_FEATURE_DIRECTORY override
into .specify/feature.json.

Add test_ps_normal_mode_still_persists_feature_json, which guards against
accidentally passing -NoPersist unconditionally (or flipping the default)
in a future refactor. Verified it fails when -NoPersist is passed in the
non -PathsOnly branch and passes with the current conditional.

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-06-30 06:38:59 -05:00
Quratulain-bilal
00f6a80201 docs: document integration catalog subcommands (#3206)
* docs: document integration catalog subcommands

the integration reference omits the 'specify integration catalog'
subcommand group (list/add/remove) that exists in code, while the
extension, preset, and workflow references all document their catalog
equivalents. add a catalog management section matching that structure.

* docs: address review feedback on integration catalog section

- catalogs are consulted by the discovery commands (search/info), not
  install; install resolves from the built-in registry
- 'catalog list' shows project sources as removable only when configured,
  otherwise active sources are non-removable
2026-06-30 06:13:17 -05:00
Ali jawwad
4badf3b5b1 fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin) (#3231)
* fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin)

initialize-repo.sh printed its success line with a Unicode checkmark ('✓ Git repository initialized'), while the PowerShell twin initialize-repo.ps1 and both auto-commit scripts use the ASCII marker '[OK]'. That is an output-text divergence across the bash/PowerShell twins and an inconsistency among sibling extension scripts. Use '[OK]' to match.

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

* test: assert full [OK] init line and surface stderr on failure

Address Copilot review: assert the full success line '[OK] Git repository initialized' (not just the '[OK]' substring, which could pass if unrelated [OK] output is added later) and include result.stderr in the assertion message so a failure is debuggable.

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-06-29 16:56:06 -05:00
Noor ul ain
9dfef8629e docs: document integration search/info/scaffold subcommands (#3174) (#3194)
* docs: document integration search/info/scaffold subcommands (#3174)

docs/reference/integrations.md omitted three subcommands that exist in
code, breaking parity with the extension/preset/bundle/workflow
references which all document their search/info equivalents.

Added sections for:
- `specify integration search [query]` (--tag, --author)
- `specify integration info <integration_id>`
- `specify integration scaffold <key>` (--type: markdown/skills/toml/yaml)

Content mirrors the command docstrings, arguments, and options in
src/specify_cli/integrations/_query_commands.py and _scaffold_commands.py.

Fixes #3174.

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>

* 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-06-29 16:52:01 -05:00
Noor ul ain
5a29e4b659 docs: remove Cursor from specify check agent list (#3178) (#3193)
* docs: remove Cursor from specify check agent list (#3178)

Cursor is registered as an IDE-based integration (requires_cli=False),
so `specify check` never probes for a "Cursor CLI". Listing it in the
README's check description misled users into expecting a check that
does not happen. Removed it from the list; the remaining entries all
correspond to integrations with requires_cli=True.

Fixes #3178.

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>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

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-06-29 16:50:55 -05:00
Ben Buttigieg
b1bd9180ca fix(goose): repoint install_url and docs to goose-docs.ai (#3171) (#3215)
* fix(goose): repoint install_url and docs to goose-docs.ai (#3171)

Goose moved to the Agentic AI Foundation; docs moved from block.github.io/goose
to goose-docs.ai. Update install_url and the docs reference link.

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

* docs(goose): restore table column alignment

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 16:43:06 -05:00
Ali jawwad
804e7329b8 fix(scripts): route 'Plan template not found' per --json in setup-plan.ps1 (parity with bash) (#3241)
The 'template not found' fallback used Write-Warning, which emits 'WARNING: Plan template not found' on the warning stream -- diverging from the bash twin (echo 'Warning: Plan template not found' to stderr in --json, stdout in text mode) in both wording and routing, and inconsistent with the sibling 'Copied plan template' message (#3198) in the same block. Route it the same way so the two scripts share one status-output contract.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:37:40 -05:00
Ali jawwad
c5fb3dc86f fix(bundle): send command errors to stderr so --json stdout stays parseable (#3235)
The bundle command group's _fail() helper is documented as printing 'to stderr', and the module contract is 'human logs go to stderr/console' while --json 'emits machine-readable data on stdout'. But it called console.print(), and the shared console writes to STDOUT, so every bundle error (every command routes through _fail) landed on stdout -- corrupting the JSON stream that --json consumers parse.

Add a stderr-bound err_console to _console.py (its documented role as the single Console source) and use it in _fail. stdout now carries only the JSON payload.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:46:56 -05:00
Manfred Riem
5a7d84311b chore: release 0.12.0, begin 0.12.1.dev0 development (#3243)
* chore: bump version to 0.12.0

* chore: begin 0.12.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-29 15:46:35 -05:00
Manfred Riem
53d9543355 feat: make agent-context extension a full opt-in (#3097)
* docs: add Spec Kit spec for agent-context full opt-in

Use Spec Kit's own specify workflow to author the spec that makes the
agent-context extension a full opt-in, removing all agent-context
configuration/support from the Python codebase and removing the
deprecation message. Force-added despite specs/ being gitignored; the
generated artifact will be purged prior to merge.

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

* docs: add Spec Kit plan artifacts for agent-context full opt-in

Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md,
quickstart.md, and contracts/cli-behavior.md. Constitution Check is a
documented no-op (repo has no ratified constitution). Force-added despite
specs/ being gitignored; generated artifacts will be purged prior to merge.

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

* docs: correct Constitution Check against ratified v1.0.0

Earlier draft wrongly treated the gate as a no-op; the fork's main is 16
commits behind upstream/main, which carries .specify/memory/constitution.md.
Re-evaluate the feature against Principles I-V (all PASS) and note that
Principle I mandates keeping context_file as a declared class attribute,
validating the R1 metadata decision.

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

* docs: refresh plan artifacts against synced upstream/main

After syncing fork main to upstream and rebasing, re-scan the current
agent-context surface. Upstream generalized the single context_file into a
plural context_files concept with new resolver helpers
(_resolve_context_files, _resolve_context_file_values,
_format_context_file_values) and upsert/remove now loop over multiple
files. Update research.md, data-model.md, contracts, quickstart grep
guards, and the plan summary to cover the expanded removal scope.

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

* docs: add Spec Kit tasks for agent-context full opt-in

Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the
three user stories, with mandatory test tasks (Constitution Principle II)
and a foundational phase decoupling __CONTEXT_FILE__ resolution from the
extension config. Includes the extension self-seeding task (T015) and a
static guard test (T002) enforcing zero agent-context references in the CLI.

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

* feat!: remove agent-context lifecycle from the Specify CLI

Make the agent-context extension a full opt-in. The CLI no longer
installs the extension during init, writes agent-context-config.yml,
or creates/updates/removes the managed Spec Kit section in agent
context files. Context-section upsert/remove, marker resolution,
extension-enabled gating, the config helpers, and the obsolete inline
deprecation warning are all removed. Integration context_file stays as
inert metadata; __CONTEXT_FILE__ now resolves from registry metadata.

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

* feat(agent-context): self-seed context file from the active integration

When agent-context-config.yml has no context_file/context_files, the
bundled bash and PowerShell update scripts now resolve the context file
from the active integration in .specify/init-options.json via the
integration registry, so the extension no longer depends on the CLI
writing its config.

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

* test+docs: update suite and docs for agent-context opt-in

Update integration/extension tests to expect no agent-context install,
config, or context-section writes during init. Add a static guard test
(test_agent_context_cli_free.py) asserting the CLI source is free of
agent-context lifecycle symbols, plus backward-compatibility tests for
legacy projects. Refresh AGENTS.md, the extension README, and add a
CHANGELOG entry describing the opt-in behavior change.

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

* fix(agent-context): warn on self-seed failure, correct docs, speed up guard test

Address PR review feedback:
- Self-seed scripts (bash + PowerShell) now emit an actionable warning when
  an active integration is configured but specify_cli cannot be imported by
  the chosen Python (e.g. pipx installs), or when the integration declares no
  context file, instead of silently falling through to 'nothing to do'.
- Correct the extension README disable note: command rendering never reads the
  extension config; __CONTEXT_FILE__ is always substituted from integration
  metadata, so a stale context_files value cannot affect rendering.
- Cache CLI source reads in the static guard test via a module-scoped fixture
  so the directory walk happens once instead of once per forbidden symbol.

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

* feat(agent-context): ship self-owned per-agent context-file defaults

The extension now bundles agent-context-defaults.json (key→context_file
map) and self-seeds from it, dropping any dependency on the Specify CLI
registry. Both the bash and PowerShell update scripts read the bundled
JSON map keyed by the active integration from init-options.json.

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

* feat!: remove all agent-context state from the Specify CLI

Strip every context_file reference from the CLI: the field on all 35
integration classes, the IntegrationBase plumbing (process_template
param/step, _context_file_display, docstrings), the __CONTEXT_FILE__
resolution in agents.py, the legacy context_file/context_markers
popping in _helpers.py, and the context_file template in
integration_scaffold.py. Also drop the Agent context update step and
__CONTEXT_FILE__ placeholder from templates/commands/plan.md.

The agent-context extension now solely owns all context-file knowledge,
including the per-agent default mapping.

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

* test: drop context_file coverage and guard against CLI reintroduction

Remove CONTEXT_FILE attrs and context_file assertions across the base
mixins, all 35 per-integration test files, shared integration tests, and
conftest stubs. Rewrite the base-mixin context tests to assert no managed
section is written and no __CONTEXT_FILE__ placeholder survives. Extend
the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and
_context_file_display in src/specify_cli, and have the extension tests
copy the bundled defaults JSON so self-seed runs without the CLI.

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

* docs: reflect full removal of agent-context state from the CLI

Update AGENTS.md (integration examples, required-fields table, context
behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts
(FR-007, SC-002, data-model) to state that the CLI carries no
context_file and the extension fully owns the per-agent default mapping
via agent-context-defaults.json.

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

* docs: align SDD artifacts with full context_file removal

Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md
(C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source
map), and checklists/requirements.md so the spec artifacts reflect the
implemented decision: the CLI carries no context_file attribute or
__CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the
extension. Resolves PR review #4548130110.

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

* docs: scrub stale context-file mentions from CLI docstrings

Update the multi_install_safe docstring (drop the removed "context file"
invariant), the RovoDev setup docstring (no longer upserts a context
section), the Copilot module docstring (drop the context-file line), and
tighten the _update_init_options_for_integration note. Pure docstring
changes — no behavioral impact. Resolves PR review #4548237085.

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

* test+docs: harden agent-context test helper and fix stale docs

- base.py: document multi_install_safe as an optional subclass attribute
  in the IntegrationBase docstring.
- test_cli.py: clarify the init-options assertion is guarding against
  leftover legacy agent-context keys, not relocation.
- test_extension_agent_context.py: _install_agent_context_config now
  asserts the bundled agent-context-defaults.json exists and always
  copies it, so self-seeding tests fail loudly instead of silently
  skipping when the map is missing.
- test_integration_cursor_agent.py: drop Path/IntegrationManifest imports
  left unused after removing the context-section frontmatter tests.

Resolves PR review #4548293116.

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

* chore: remove gitignored SDD artifacts from specs/

The specs/001-agent-context-full-optin/ artifacts were force-added for
dogfooding visibility, but specs/ is gitignored and these were always
intended to be purged before merge. Remove them so merging does not add
an intentionally-untracked directory to repo history.

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

* chore: keep CHANGELOG.md identical to upstream

CHANGELOG.md is auto-generated at release time, so the branch should not
carry a manual entry. Restore it to match upstream/main exactly.

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

* fix: preserve Cursor .mdc frontmatter in agent-context updater scripts

The bundled agent-context updater scripts wrote the managed section as
plain text. For Cursor-style `.mdc` targets this dropped the required
`---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading
bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic
into both the bash and PowerShell updaters: prepend frontmatter when
missing, repair `alwaysApply` when set to the wrong value, and leave
non-`.mdc` targets untouched. Add regression tests covering both shells.

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

* test: scope CLI-free guard to agent-context-specific symbols

Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the
guard no longer fails on unrelated future CLI fields named context_file.
The list still covers agent-context-specific identifiers (__CONTEXT_FILE__,
_context_file_display, _resolve_context_files, _resolve_context_file_values).

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

* fix: harden agent-context bash self-seed against malformed init JSON

Two robustness fixes in the embedded Python self-seed logic:
- Coerce the integration value from init-options.json to a string only when
  it is actually a string; otherwise treat it as unset so a corrupted
  dict/list value degrades to the existing nothing-to-do behavior instead of
  breaking the agents-map lookup.
- Normalize agent-context-defaults.json: only use 'agents' when both the JSON
  root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON
  falls back to the warning path instead of raising on .get.

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

* fix: correct PowerShell hyphenated key lookup and regex replace count

- Self-seed now reads the defaults mapping via
  $defaults.agents.PSObject.Properties[$integrationKey].Value instead of
  member access ($defaults.agents.$integrationKey), which parsed hyphenated
  keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve.
- Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was
  interpreted as RegexOptions.IgnoreCase rather than a replacement count, with
  an instance Regex whose Replace(input, replacement, 1) limits to the first
  match as intended.

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

* fix: make bash .mdc frontmatter guard case-insensitive

The bash updater only injected Cursor .mdc frontmatter when ctx_path ended
in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC)
was skipped and Cursor would not auto-load the rule file. Compare against the
casefolded path. The PowerShell variant already uses -match, which is
case-insensitive by default, so no change is needed there.

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

* docs: document separator-agnostic agent-context update invocation

The README hard-coded the dot-notation slash command
(/speckit.agent-context.update), which hyphen-separator agents like Forge and
Cline do not recognize. Document the canonical command ID plus both slash
invocations so users copy the form their agent accepts.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 15:27:26 -05:00
Ali jawwad
5367f69f6c docs(workflows): add the built-in 'init' step type to the Step Types table (#3234)
The Step Types table in docs/reference/workflows.md listed command, prompt, shell, gate, if, switch, while, do-while, fan-out, and fan-in, but omitted 'init' -- which IS a registered built-in (workflows/__init__.py _register_builtin_steps registers InitStep) and is documented in steps/init/__init__.py as bootstrapping a project (equivalent to 'specify init'). Add the missing row so the reference matches the registry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:08:49 -05:00
Ali jawwad
876dca8659 fix(workflows): gate validate() must not crash on non-string options (#3233)
GateStep.validate() reports non-string options as an error, but then -- when on_reject is 'abort'/'retry' -- still runs the reject-choice check 'any(o.lower() in ... for o in options)'. For a non-string option (e.g. options: [123]) o.lower() raised AttributeError, which escaped validate() and broke validate_workflow's documented 'return a list of errors, never raise' contract. Guard the check so it only runs when every option is a string (the non-string case is already reported above).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:07:50 -05:00
Ali jawwad
9ece347a77 fix(workflows): make pipe-filter detection quote-aware in expressions (#3232)
_evaluate_simple_expression used 'if "|" in expr' / expr.split("|", 1) to detect a filter pipe, so a literal '|' inside a quoted operand (e.g. inputs.x == 'a|b') was mistaken for a filter separator and raised a spurious ValueError ('unknown filter') instead of comparing the string. Use the existing quote/bracket-aware _find_top_level helper (added for the operator-splitting fix) so only a top-level pipe is treated as a filter separator.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:55:45 -05:00
Huy Do
3036fe6954 fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225)
* fix(workflows): reject a fan-in wait_for that names an unknown step at validation

* fix(workflows): reject fan-in wait_for self-reference and non-string entries

Address review feedback on the fan-in wait_for validator:

- A fan-in's own id is added to seen_ids before the wait_for check, so
  `wait_for: [<self>]` passed validation while producing a silent empty
  join at runtime. Reject self-references explicitly.
- Non-string entries (e.g. YAML `wait_for: [123]`) were skipped by the
  isinstance(str) guard and validated even though they can never match a
  real step id. Flag them as wiring errors.

Add coverage for both cases.
2026-06-29 14:52:08 -05:00
Ali jawwad
a473955e3e fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230)
* fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash)

create-new-feature.sh prints 'Warning: Spec template not found; created empty spec file' to stderr when no spec template resolves, then touches an empty spec. The PowerShell twin created the empty file silently with no warning, so on Windows a missing/broken template tree gave no signal. Emit the same warning on stderr (keeps stdout/JSON pure), matching the bash wording and stream.

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

* test: assert create-new-feature.ps1 warns on missing spec template

Regression test for the bash/PowerShell parity fix: with no resolvable spec template, the PowerShell script must emit 'Spec template not found' on stderr (matching bash) while keeping stdout parseable JSON and still creating the empty spec file. Gated on pwsh; decodes stdout/stderr as UTF-8.

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-06-29 14:46:35 -05:00
Ali jawwad
a4972da717 fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137)
* fix(scripts): count subdirectory-only dirs as non-empty in PowerShell

Test-DirHasFiles (the documented PowerShell twin of bash check_dir) tested
non-emptiness with `Get-ChildItem | Where-Object { -not $_.PSIsContainer }`,
counting only top-level FILES and ignoring subdirectories. Bash check_dir
(`-n $(ls -A ...)`) and the PowerShell JSON-path contracts checks
(check-prerequisites.ps1 / setup-tasks.ps1, no PSIsContainer filter) both
count ANY entry. So a contracts/ directory whose only contents are
subdirectories (e.g. contracts/v1/openapi.yaml) was reported present by
bash, by bash JSON, and by PowerShell JSON, but [FAIL]/absent by PowerShell
text mode — the lone outlier.

Drop the PSIsContainer filter so Test-DirHasFiles counts any entry, matching
the other three code paths.

Add bash + PowerShell parity tests asserting a subdir-only contracts/ dir is
reported non-empty in both shells.

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

* review: accurate non-empty comment + drop doubled test prefix

Address review feedback on Test-DirHasFiles parity fix:

- Reword the common.ps1 comment so it no longer claims exact `ls -A` parity (Get-ChildItem omits hidden entries without -Force); it now points at the in-repo PowerShell JSON contracts checks as the matching reference and keeps the subdir-only-is-non-empty rationale.

- Rename test_test_dir_has_files_ps_... -> test_dir_has_files_ps_... to drop the doubled 'test_' prefix.

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

* test: assert dir-non-emptiness via stdout marker, not exit code

Address Copilot review: check_dir always exits 0 (it echoes the marker rather than setting an exit code) and Test-DirHasFiles returns a boolean (pwsh still exits 0 when it returns $false), so 'result.returncode == 0' validated nothing. Drop the misleading assertion and rely on the [OK]/checkmark marker in stdout, which is the actual behavioral signal; document why inline.

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

* fix: keep common.ps1 ASCII-only (PowerShell 5.1 compatibility)

My reworded Test-DirHasFiles comment introduced an em dash (U+2014), which tripped tests/test_ps1_encoding.py::test_ps1_file_is_ascii_only -- .ps1 files must stay ASCII for Windows PowerShell 5.1. Replace it with '--', matching the existing comment style in this file (e.g. the Resolve-SpecifyInitDir docstring).

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

* test: decode dir-parity subprocess output as UTF-8 explicitly

Address Copilot review: check_dir echoes the non-ASCII markers ✓/✗, and subprocess.run with text=True but no encoding decodes via the platform locale (cp1252 on Windows), which can raise UnicodeDecodeError or mangle stdout. Pin encoding='utf-8' on both the bash and PowerShell dir-parity helpers so decoding is deterministic across CI runners.

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-06-29 14:38:39 -05:00
Ali jawwad
7b687d8bbd fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195)
* fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash)

create-new-feature-branch.ps1 emitted a HAS_GIT key in its JSON output and a 'HAS_GIT:' line in text output that the bash twin never emits. The bash output contract is {BRANCH_NAME, FEATURE_NUM} (+ DRY_RUN) only, so a tool parsing the machine-readable output got a different shape on Windows/PowerShell vs macOS/Linux -- a cross-platform contract divergence.

$hasGit is still computed and used internally for branch-creation logic; only its two output emissions are removed, restoring parity. Added regression tests asserting neither the PS nor the bash output contains HAS_GIT (JSON and text). Noted as a follow-up in #3129.

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

* docs: note DRY_RUN in the HAS_GIT-omission comment (parity)

Address Copilot review: the comment described the output contract as {BRANCH_NAME, FEATURE_NUM} without mentioning that DRY_RUN is still conditionally added in JSON mode on dry runs. Clarify so the contract description is complete for future maintainers. Comment-only.

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-06-29 14:36:17 -05:00
github-actions[bot]
7621e1ceba Update Product Spec Extension to v1.0.1 (#3226)
Update product extension submitted by @d0whc3r:
- extensions/catalog.community.json (version, download_url, provides.commands)

Closes #3200

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-06-29 11:42:30 -05:00
Manfred Riem
92cb2699eb chore: release 0.11.10, begin 0.11.11.dev0 development (#3240)
* chore: bump version to 0.11.10

* chore: begin 0.11.11.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-29 11:35:22 -05:00
Manfred Riem
bbc5f176e3 fix(extensions): apply GHES auth and resolve release assets for extension add --from (#3217)
* fix(extensions): apply GHES auth and resolve release assets for --from

The 'specify extension add --from <url>' path fetched ZIPs via a bare
open_url with no GitHub release-asset resolution and no Accept header,
diverging from the catalog download path. Against GHES it received an
HTML login page and failed obscurely with zipfile.BadZipFile.

Route --from through ExtensionCatalog so configured GHES credentials
apply and release-download URLs resolve via /api/v3, and reject non-ZIP
content with a clear error pointing at auth.json.

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

* fix(extensions): use zipfile.is_zipfile for --from content guard

Replace the weak zip_data.startswith(b"PK") prefix check with
zipfile.is_zipfile() on a BytesIO so any non-ZIP payload (not just
those lacking the PK magic) is rejected with the friendly error before
install_from_zip can raise BadZipFile. Addresses PR review feedback.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 11:31:10 -05:00
Ben Buttigieg
ac47178f65 fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214)
The @mariozechner/pi-coding-agent npm package is deprecated in favor of
@earendil-works/pi-coding-agent. Pi Coding Agent is still active under the
new org, so update the install_url rather than removing the integration.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 11:25:38 -05:00
Quratulain-bilal
5bdcb4ad14 fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210)
the shared CatalogStackBase validator and PresetCatalog validator
checked parsed.netloc to enforce 'a valid URL with a host'. but netloc
is truthy for host-less URLs like https://:8080 or https://user@, so
those slipped through even though they have no host - contradicting the
error message. the workflow, step, and bundler validators already check
parsed.hostname (which is None in those cases); this aligns the two
stragglers with that. add regression tests covering port-only and
userinfo-only URLs.
2026-06-29 10:29:14 -05:00
WOLIKIMCHENG
9a40ed0b6e fix: update CodeBuddy install docs URL (#3187)
* fix: update CodeBuddy install docs URL

* test: assert codebuddy integration is registered before checking install_url

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-06-29 10:26:10 -05:00
Ali jawwad
d378485696 fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199)
WorkflowEngine._coerce_input normalizes a whole-valued number to int via int(value). For an infinite float (e.g. a 'type: number' input with YAML 'default: .inf') int(inf) raises OverflowError, which is not in the except (ValueError, TypeError) tuple. validate_workflow eager-coerces declared defaults and is documented to RETURN a list of errors, but it only catches ValueError -- so the OverflowError escaped and validate_workflow raised instead of reporting, breaking its contract. (NaN already surfaced cleanly because int(nan) raises ValueError.)

Add OverflowError to the except tuple so an infinite default surfaces as the same clean 'expected a number' ValueError as NaN, consistent with the function's existing fail-fast-on-authoring-mistakes design. Finite values (5.0 -> 5, 3.5 -> 3.5) are unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:05:51 -05:00
Ali jawwad
96f73d192c fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198)
setup-plan.sh prints 'Copied plan template to $IMPL_PLAN' after copying the template (to stderr in --json mode, stdout otherwise), but the PowerShell twin emitted nothing on the successful-copy path -- only the 'Plan already exists' skip message and the 'Plan template not found' warning existed. So the two scripts had a divergent status-output contract on a fresh run.

Emit the same message after WriteAllText, routed like the sibling skip message ([Console]::Error.WriteLine in -Json so stdout stays pure JSON, Write-Output in text mode). Mirrors the bash wording and stream routing exactly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:54:38 -05:00
Ali jawwad
2a9db1d350 fix(workflows): make expression operator/literal parsing quote-aware (#3197)
_evaluate_simple_expression split on operator keywords using naive str.find/split, so a keyword INSIDE a quoted operand was treated as an operator: `inputs.mode == 'read and write'` split on the inner ' and ' and evaluated as `(inputs.mode == 'read) and (write')`. The literal short-circuit was also too greedy -- `'a' == 'b'` matched startswith("'")/endswith("'") and was stripped to the garbage truthy string `a' == 'b`, so `'done' == 'failed'` evaluated truthy and gated the wrong branch.

Add a quote/bracket-aware _find_top_level helper (mirroring the existing _split_top_level_commas) and use it for the and/or/comparison/in/not-in splits; tighten the literal short-circuit to fire only when the opening quote's match is the final char. The docstring already lists comparisons + and/or/not + in/not-in + string literals as supported, so this restores the documented contract.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:53:35 -05:00
Ali jawwad
fd185c1fd8 fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196)
Get-BranchName used `[long]$Number = 0` as both the default and the 'auto-detect' sentinel (`if ($Number -eq 0)`), so an explicitly-passed `-Number 0` was indistinguishable from 'not supplied' and silently auto-incremented. The bash twin keys off whether the value is non-empty (`[ -z "$BRANCH_NUMBER" ]`), so `--number 0` is honored and yields FEATURE_NUM 000 -- a cross-platform divergence for identical input.

Use $PSBoundParameters.ContainsKey('Number') instead, so an explicit value (including 0) is honored and only a missing -Number auto-detects -- mirroring bash. This also aligns the -Timestamp+-Number warning, which bash emits for `--number 0 --timestamp` (non-empty check) but PowerShell previously skipped.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:52:22 -05:00
siC@r10-mw
b7e67f55bf Add community bundle submission path (#3162)
* Add community bundle submission path

* Address bundle submission review feedback

* Align bundle submission triage label

* Clarify bundle submission review scope

* Clarify community bundle catalog listing
2026-06-26 16:56:34 -05:00
Dyan Galih
3e97b10693 Docs: Document /speckit.converge command (#3181)
* docs: document /speckit.converge command

* docs: clarify converge and implement loop
2026-06-26 12:32:35 -05:00
Manfred Riem
b540ff4e78 chore: release 0.11.9, begin 0.11.10.dev0 development (#3189)
* chore: bump version to 0.11.9

* chore: begin 0.11.10.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 12:27:17 -05:00
Dyan Galih
465d29910e Docs: add cline and zcode to multi-install-safe table (#3180)
Fixes #3175
2026-06-26 12:21:38 -05:00
Dyan Galih
916e29b27b Docs: document missing flags --force and --refresh-shared-infra (#3179)
* Docs: document missing flags --force and --refresh-shared-infra

Fixes #3177

* Address review: Reorder flags to match CLI help output
2026-06-26 12:20:51 -05:00
Manfred Riem
c49966da4d fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188)
PR #2511 added `context: fork` + `agent: general-purpose` to the generated
speckit-analyze SKILL.md on the assumption that its heavy reads collapse to a
short summary. In practice /speckit-analyze returns a 300-500 line report that
is injected back into the main conversation. In long sessions each subsequent
fork inherits that growing context, compounding overhead until the chat
freezes (#3185).

Empty FORK_CONTEXT_COMMANDS so no command opts into context: fork, restoring
direct in-session execution for analyze. The injection mechanism is retained
so a command can be re-enabled once it genuinely returns a compact result.

Fixes #3185

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 12:11:54 -05:00
Amirreza Alibeigi
49cc05384a fix: derive plan path from feature.json in update-agent-context (#3069)
* fix: derive plan path from feature.json in update-agent-context

When `plan_path` is omitted, prefer `.specify/feature.json`
(written by /speckit-specify) over the mtime heuristic. The
old approach picked the most recently modified `specs/*/plan.md`,
which could inject an unrelated plan into CLAUDE.md if another
spec's plan was touched after the active feature directory was
created but before its own plan.md existed.

Bash: handle both relative and absolute feature_directory values,
normalizing absolute paths back to project-relative for the
context file. Fall back to mtime only when feature.json is absent
or the derived plan.md does not yet exist.

PowerShell: same logic, PS 5.1-compatible (nested Join-Path,
IsPathRooted guard to avoid Unix Join-Path mis-joining absolute
ChildPaths, manual prefix-strip instead of GetRelativePath).

Fixes #3067

* fix: address Copilot review feedback on update-agent-context

- bash: add explicit encoding="utf-8" to feature.json open() call
- powershell: replace GetRelativePath (.NET 5+ only) with manual
  prefix-strip in mtime fallback for PS 5.1 compatibility
- tests: add coverage for absolute feature_directory values
  (under and outside PROJECT_ROOT)

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test: replace time.sleep with os.utime and strengthen PS normalization assertion

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: normalize trailing slash and guard non-string feature_directory in PS script

* Fix: use .resolve().as_posix().

Valid. The PS tests run on Windows where str(tmp_path) uses backslashes, but the PS script normalizes output to forward slashes. Assertions like assert str(tmp_path) not in ctx become false negatives on Windows CI.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: use context manager for feature.json open() in bash heredoc

* test: add PS coverage for absolute feature_directory outside project root

* fix: guard null feature_directory, re-check empty after trailing-slash strip, fix blank line

* test: add stale plan to absolute-path tests so feature.json preference is actually exercised

* test: convert absolute paths to MSYS2 style for Git-for-Windows bash compatibility

* fix: revert PS test to native path, fix bash outside-root assertion for Git bash

* fix: use _to_bash_path in not-in assertion for Git bash Windows compat

* fix: add ConvertFrom-Json fallback in PS script, write test config as JSON

* fix: use OS-appropriate StringComparison in PS prefix-strip (matches common.ps1)

* fix: emit project-relative POSIX path from mtime fallback; use upstream test helpers

* fix: write config as JSON directly, drop _install_agent_context_config

* fix: normalize backslashes to forward slashes in feature_directory before path ops

* fix: treat drive-qualified paths (C:/...) as absolute after backslash normalization

* fix: resolve symlinks when computing relative plan path; use UTF8 encoding in PS ConvertFrom-Yaml path

* fix: use bash-side path for outside-root case to avoid WindowsPath backslashes

* fix: use .as_posix() instead of PurePosixPath() to avoid backslashes on native Windows Python

* fix: resolve ./.. segments in PS feature_directory via GetFullPath before relativizing

* fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.1 StrictMode compat

* fix: guard empty relDir to avoid leading slash in PlanPath when feature_directory is project root

* fix: remove unused PurePosixPath import; fix stale PS comment after ConvertFrom-Json fallback was added

* fix: use cand.as_posix() for outside-root path instead of raw bash-side argv

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-26 12:07:44 -05:00
Alfredo Perez
5f9791b524 fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954)
* fix(catalog): point companion documentation at README.md so it renders

The companion entry's documentation URL pointed at a directory
(speckit-extension/docs/), which the community site can't fetch as
markdown — its extension page renders an empty README (readmeContent:
null). Every other catalog entry points documentation at a specific
README.md (or .md file). Point companion at its extension README so the
page renders.

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

* fix(catalog): companion → stable companion-latest download_url, v0.4.1, sharper tags

- download_url now points at the rolling companion-latest asset so by-name install always serves the newest build (no per-release catalog PR)
- version 0.3.0 → 0.4.1
- tags: drop redundant 'companion'/'progress'/'lifecycle', add spec-driven-development, spec-kit, turbo, capture

* fix(catalog): companion tags → capability-first (vscode, progress, status, resume, configurable, extensible)

Tags now name what Companion adds over stock spec-kit, in browse-able terms — dropped catalog-noise (spec-kit, spec-driven-development) and insider jargon (turbo, capture).

* fix(catalog): pin companion to speckit-ext-v0.8.0 asset; sync entry

Pin download_url to the version-matched release asset (every other catalog
entry pins to a tag; the floating companion-latest URL made installs
non-reproducible). Bring the entry up to v0.8.0: version 0.4.1 -> 0.8.0,
commands 10 -> 12, speckit_version floor >=0.9.5.dev0, and drop the removed
"turbo pipeline profile" from the description in favor of the hooks/recipes
customization that shipped.

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

* fix(catalog): bump companion to v0.11.0 (latest released asset, 13 commands)

* fix(catalog): companion speckit_version floor >=0.9.5 (drop pointless .dev0)

* fix(catalog): align companion updated_at with catalog root (2026-06-24)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:59:32 -05:00
dependabot[bot]
1d989b90d5 chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](a309ff8b42...ece7cb06ca)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 09:05:38 -05:00
github-actions[bot]
e7ec7c190f Update SicarioSpec Core preset to v0.5.1 (#3165)
Update sicario-core preset submitted by @SiCar10mw:
- presets/catalog.community.json (version, download_url, description, tags)
- docs/community/presets.md community presets table

Closes #3164

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-06-25 14:10:23 -05:00
Si Zengyu
1add20341d fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
* feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions): resolve GHES release assets via /api/v3

Generalizes resolve_github_release_asset_api_url to GitHub Enterprise
Server hosts (gated by auth.json github hosts), fixing private GHES
extension/preset downloads. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions,presets): pass auth.json github hosts into release resolver

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* docs(auth): document GHES private catalog + release-asset auth

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(presets,workflows): pass auth.json github hosts into remaining release resolvers

Wires preset add --from and workflow add through github_provider_hosts()
so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* test(presets): use module-level io.BytesIO in GHES preset test

Addresses Copilot review on PR #3157: drop unnecessary __import__("io")
in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is
already imported at module level.

* fix(github-http): pass through GHES asset API URLs by path shape

Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset
URL was only returned as already-resolved when its host was in the
allowlist; otherwise the resolver returned None and the caller downloaded
the same URL without 'Accept: application/octet-stream', fetching JSON
metadata instead of the binary.

Gate the passthrough on path shape alone, mirroring the github.com case.
This is safe: passthrough returns the input URL unchanged and the caller
fetches it either way, so no new request to an arbitrary host is induced;
the token stays independently gated by auth.json in open_url. The
allowlist remains the anti-SSRF gate on the tag-lookup resolving path.

Add test_passthrough_for_unlisted_ghes_api_asset_url.
2026-06-25 10:44:30 -05:00
WOLIKIMCHENG
7624dd6582 Update preset composition strategy reference (#3143)
* docs: update preset composition strategy reference

* docs: clarify preset command composition timing

* docs: clarify preset command reconciliation timing

* docs: clarify preset file resolution behavior

* docs: clarify preset command reconciliation wording

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-06-25 10:13:14 -05:00
Ali jawwad
9fe1c4cc5c fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129)
* fix(scripts): keep PowerShell branch-name acronym match case-sensitive

Get-BranchName keeps a sub-3-character word only when it appears as an
UPPERCASE acronym in the description. The bash twin checks this
case-sensitively (grep "\b${word^^}\b" / grep -qw -- "${word^^}"), but the
PowerShell twin used -match, which is case-INSENSITIVE, so it kept EVERY
short word regardless of case -- contradicting its own comment and diverging
from bash. The same description then produced different spec-directory and
branch names on Windows/PowerShell vs macOS/Linux (e.g. "Add go support" ->
001-go-support instead of 001-support), desyncing specs/, feature.json, and
git branches across a mixed-OS team.

Use the case-sensitive -cmatch so a short word is kept only for a genuine
uppercase acronym, matching bash. Applied to both the core
scripts/powershell/create-new-feature.ps1 and the git extension's
create-new-feature-branch.ps1.

Add bash + PowerShell regression tests (core and git-extension) asserting a
lowercase short word is dropped while an uppercase acronym is kept.

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

* test: fix article grammar in branch-name docstrings

Address review: 'an UPPERCASE acronym' -> 'an acronym in UPPERCASE' across the four branch-name case-sensitivity test docstrings (the indefinite article reads cleanly before 'acronym'). Docstring-only; no behavior change.

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-06-25 07:56:59 -05:00
Quratulain-bilal
bb37b180d6 fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901)
In agent-direct invocations nothing watches agent output for the
EXECUTE_COMMAND: directive, so a mandatory hook that is only emitted
never runs and the failure is silent (#2730). Add one line after each
mandatory-hook block instructing the agent to actually invoke the hook
and wait for it before continuing.

The instruction tells the agent to run the hook the way it would run
the command itself in the current agent/session, and notes the
invocation may differ from the literal {command} id shown in the block
(e.g. skills-mode agents run it as /skill:speckit-... or $speckit-...),
so it stays correct outside the default slash-command form.

Fixes #2730
2026-06-25 07:48:23 -05:00
siC@r10-mw
77e6f43b82 Point sicario-core docs to preset README (#3120)
* Point sicario-core docs to preset README

* Update sicario-core catalog timestamps

Assisted-by: OpenAI Codex (GPT-5, autonomous)
2026-06-25 07:25:41 -05:00
Manfred Riem
d65f6bd335 chore: release 0.11.8, begin 0.11.9.dev0 development (#3156)
* chore: bump version to 0.11.8

* chore: begin 0.11.9.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 17:42:49 -05:00
163 changed files with 3441 additions and 2814 deletions

View File

@@ -56,7 +56,7 @@ run_command "npm install -g @jetbrains/junie-cli@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Pi Coding Agent..."
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
run_command "npm install -g @earendil-works/pi-coding-agent@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Kiro CLI..."

View File

@@ -0,0 +1,293 @@
name: Bundle Submission
description: Submit your bundle metadata for community catalog validation
title: "[Bundle]: Add "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for contributing a bundle! This template captures metadata for maintainers to validate formatting, links, component resolution, and installation evidence. Maintainers do not audit, endorse, or support bundle code or installed components.
**Before submitting:**
- Review the [Bundles reference](https://github.com/github/spec-kit/blob/main/docs/reference/bundles.md)
- Ensure your bundle has a valid `bundle.yml` manifest
- Create a GitHub release with a versioned bundle artifact
- Test installation from a downloaded artifact: `specify bundle install ./your-bundle-1.0.0.zip`
- If you host a bundle catalog, test catalog installation with `specify bundle catalog add <catalog-url> --id <catalog-id> --policy install-allowed` and `specify bundle install <bundle-id>`
- If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project
- type: input
id: bundle-id
attributes:
label: Bundle ID
description: Unique bundle identifier; must start and end with a lowercase letter or digit and may contain lowercase letters, digits, dots, underscores, and hyphens between
placeholder: "e.g., security-governance-stack"
validations:
required: true
- type: input
id: bundle-name
attributes:
label: Bundle Name
description: Human-readable bundle name
placeholder: "e.g., Security Governance Stack"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Semantic version number
placeholder: "e.g., 1.0.0"
validations:
required: true
- type: input
id: role
attributes:
label: Role or Team
description: Primary role, team, or persona this bundle provisions
placeholder: "e.g., security-engineer, product-manager, platform-team"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Brief description of the stack this bundle installs
placeholder: Installs a security governance stack with compliance presets, review commands, and evidence workflows
validations:
required: true
- type: input
id: author
attributes:
label: Author
description: Your name or organization
placeholder: "e.g., Jane Doe or Acme Corp"
validations:
required: true
- type: input
id: repository
attributes:
label: Repository URL
description: GitHub repository URL for your bundle source
placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle"
validations:
required: true
- type: input
id: download-url
attributes:
label: Download URL
description: URL to the versioned bundle artifact generated by `specify bundle build`
placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip"
validations:
required: true
- type: input
id: documentation
attributes:
label: Documentation URL
description: Link to documentation that explains what the bundle installs and how to use it
placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/blob/main/README.md"
validations:
required: true
- type: input
id: license
attributes:
label: License
description: Open source license type
placeholder: "e.g., MIT, Apache-2.0"
validations:
required: true
- type: input
id: speckit-version
attributes:
label: Required Spec Kit Version
description: Minimum Spec Kit version required by the bundle
placeholder: "e.g., >=0.9.0"
validations:
required: true
- type: input
id: integration
attributes:
label: Integration Target (optional)
description: Integration ID if the bundle pins one; leave empty if integration-agnostic
placeholder: "e.g., claude, copilot, gemini"
- type: textarea
id: components-provided
attributes:
label: Components Provided
description: List the extensions, presets, workflows, and steps this bundle installs
placeholder: |
- extensions: sicario-guard@0.5.1
- presets: sicario-core@0.5.1, sicario-ai-governance@0.5.1
- workflows: evidence-review@1.0.0
- steps: threat-model
validations:
required: true
- type: textarea
id: required-catalogs
attributes:
label: Required Component Catalogs
description: List any non-default catalogs users must add before this bundle can resolve its components; enter "None" if every component resolves from built-in or bundled catalogs
placeholder: |
- Presets: https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json
- Extensions: https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json
validations:
required: true
- type: textarea
id: tags
attributes:
label: Tags
description: 2-5 relevant tags (lowercase, separated by commas)
placeholder: "security, governance, compliance"
validations:
required: true
- type: textarea
id: features
attributes:
label: Key Features
description: List the main capabilities this bundle provides
placeholder: |
- Installs evidence-first security governance templates
- Adds automated bundle verification commands
- Pins all components to release-tested versions
validations:
required: true
- type: checkboxes
id: testing
attributes:
label: Testing Checklist
description: Confirm that your bundle has been tested
options:
- label: Validation succeeds with `specify bundle validate --path <bundle-directory>`
required: true
- label: Build succeeds with `specify bundle build --path <bundle-directory>` and produces the submitted artifact
required: true
- label: Bundle installs successfully from the built artifact
required: true
- label: The submitted distribution path was tested end to end, including bundle-ID installation from an install-allowed catalog when a catalog entry is proposed
required: true
- label: Installation was tested in a clean Spec Kit project
required: true
- label: Required component catalogs are documented and were included in testing, or no extra catalogs are required
required: true
- label: Documentation is complete and accurate
required: true
- type: checkboxes
id: requirements
attributes:
label: Submission Requirements
description: Verify your bundle meets all requirements
options:
- label: Valid `bundle.yml` manifest included
required: true
- label: README.md explains the bundle's intended role, installed components, and installation steps
required: true
- label: LICENSE file included
required: true
- label: GitHub release created with a version tag
required: true
- label: Bundle ID matches the manifest and follows naming conventions
required: true
- label: Every extension, preset, workflow, and step reference is pinned where the manifest requires a version
required: true
- type: textarea
id: testing-details
attributes:
label: Testing Details
description: Describe how you tested your bundle
placeholder: |
**Tested on:**
- macOS 15 with Spec Kit v0.9.0
- Ubuntu 24.04 with Spec Kit v0.9.0
**Test project:** [Link or description]
**Test scenarios:**
1. Added required catalogs
2. Validated bundle manifest
3. Built release artifact
4. Installed bundle in a clean project
5. Ran the installed commands or workflows
validations:
required: true
- type: textarea
id: example-usage
attributes:
label: Example Usage
description: Provide a simple example of installing and using your bundle
render: markdown
placeholder: |
```bash
# Add any required component catalogs first
specify preset catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json --name your-bundle --install-allowed
specify extension catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json --name your-bundle --install-allowed
# Install the downloaded bundle artifact
curl -L -o your-bundle-1.0.0.zip https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip
specify bundle install ./your-bundle-1.0.0.zip
# Or test through an install-allowed bundle catalog
specify bundle catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/bundles.json --id your-bundle-catalog --policy install-allowed
specify bundle install your-bundle
```
validations:
required: true
- type: textarea
id: catalog-entry
attributes:
label: Proposed Catalog Entry
description: Provide the JSON entry that would appear under the top-level `bundles` object in a bundle catalog (helps reviewers)
render: json
placeholder: |
{
"your-bundle": {
"name": "Your Bundle",
"id": "your-bundle",
"version": "1.0.0",
"role": "security-engineer",
"description": "Brief description of the stack",
"author": "Your Name",
"license": "MIT",
"download_url": "https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip",
"repository": "https://github.com/your-org/your-bundle",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"extensions": 1,
"presets": 2,
"steps": 0,
"workflows": 1
},
"tags": ["security", "governance"],
"verified": false
}
}
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that would help reviewers
placeholder: Screenshots, demo videos, links to related projects, dependency-resolution notes, etc.

View File

@@ -35,7 +35,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"

View File

@@ -19,9 +19,9 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
python-version: "3.14"
- name: Run ruff check
run: uvx ruff check src/
@@ -30,8 +30,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -40,7 +40,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}

View File

@@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
```
**TOML agent (Gemini):**
@@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
```
**Skills agent (Codex):**
@@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration):
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
@@ -175,9 +171,11 @@ def _register_builtins() -> None:
### 4. Context file behavior
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling.
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file.
The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
```yaml
# Path to the coding agent context file managed by this extension
@@ -189,10 +187,10 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
@@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
## Branch Naming Convention
@@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.

View File

@@ -2,6 +2,85 @@
<!-- insert new changelog below this comment -->
## [0.12.1] - 2026-06-30
### Changed
- chore: align CI Python matrix with devguide lifecycle + fix bash 3.2 portability (#3244)
- fix: stop check-prerequisites --paths-only from writing feature.json (#3025) (#3190)
- docs: document integration catalog subcommands (#3206)
- fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin) (#3231)
- docs: document integration `search`/`info`/`scaffold` subcommands (#3174) (#3194)
- docs: remove Cursor from `specify check` agent list (#3178) (#3193)
- fix(goose): repoint install_url and docs to goose-docs.ai (#3171) (#3215)
- fix(scripts): route 'Plan template not found' per --json in setup-plan.ps1 (parity with bash) (#3241)
- fix(bundle): send command errors to stderr so --json stdout stays parseable (#3235)
- chore: release 0.12.0, begin 0.12.1.dev0 development (#3243)
## [0.12.0] - 2026-06-29
### Changed
- feat: make agent-context extension a full opt-in (#3097)
- docs(workflows): add the built-in 'init' step type to the Step Types table (#3234)
- fix(workflows): gate validate() must not crash on non-string options (#3233)
- fix(workflows): make pipe-filter detection quote-aware in expressions (#3232)
- fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225)
- fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230)
- fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137)
- fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195)
- Update Product Spec Extension to v1.0.1 (#3226)
- chore: release 0.11.10, begin 0.11.11.dev0 development (#3240)
## [0.11.10] - 2026-06-29
### Changed
- fix(extensions): apply GHES auth and resolve release assets for `extension add --from` (#3217)
- fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214)
- fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210)
- fix: update CodeBuddy install docs URL (#3187)
- fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199)
- fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198)
- fix(workflows): make expression operator/literal parsing quote-aware (#3197)
- fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196)
- Add community bundle submission path (#3162)
- Docs: Document /speckit.converge command (#3181)
- chore: release 0.11.9, begin 0.11.10.dev0 development (#3189)
## [0.11.9] - 2026-06-26
### Changed
- Docs: add cline and zcode to multi-install-safe table (#3180)
- Docs: document missing flags --force and --refresh-shared-infra (#3179)
- fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188)
- fix: derive plan path from feature.json in update-agent-context (#3069)
- fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954)
- chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173)
- Update SicarioSpec Core preset to v0.5.1 (#3165)
- fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
- Update preset composition strategy reference (#3143)
- fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129)
- fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901)
- Point sicario-core docs to preset README (#3120)
- chore: release 0.11.8, begin 0.11.9.dev0 development (#3156)
## [0.11.8] - 2026-06-24
### Changed
- docs: add SpecKit Assistant npm package to Community Friends (#3142)
- Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104)
- [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152)
- Add Spec Roadmap extension to community catalog (#3153)
- feat(integration): update Kimi integration for Kimi Code CLI (#2979)
- [extension] Add Golden Demo extension to community catalog (#3151)
- docs: run /speckit.checklist after /speckit.plan in quickstart (#3108)
- fix(workflows): preserve commas inside quoted list-literal elements (#3134)
- ci: pin actions to commit SHAs and add shellcheck (#3126)
- chore: release 0.11.7, begin 0.11.8.dev0 development (#3154)
## [0.11.7] - 2026-06-24
### Changed

View File

@@ -134,13 +134,14 @@ Explore community-contributed resources on the [Spec Kit docs site](https://gith
- [Extensions](https://github.github.io/spec-kit/community/extensions.html) — commands, hooks, and capabilities
- [Presets](https://github.github.io/spec-kit/community/presets.html) — template and terminology overrides
- [Bundles](https://github.github.io/spec-kit/community/bundles.html) — role and team stacks composed from existing components
- [Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) — end-to-end SDD scenarios
- [Friends](https://github.github.io/spec-kit/community/friends.html) — projects that extend or build on Spec Kit
> [!NOTE]
> Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion.
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md).
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md), the [Presets Publishing Guide](presets/PUBLISHING.md), or the [Community Bundles guide](docs/community/bundles.md).
## 🤖 Supported AI Coding Agent Integrations
@@ -262,8 +263,10 @@ built-in). Each source carries an install policy: `install-allowed` sources can
be installed from, while `discovery-only` sources are visible in `search`/`info`
but refuse installation. Manage the stack with `specify bundle catalog list|add|remove`.
Authors validate and package bundles locally — there is no first-class publish;
distribution is hosting the built artifact and adding a catalog entry:
Authors validate and package bundles locally. Distribution is hosting the built
artifact and adding a catalog source; community bundle submissions use the
[Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml)
issue template so required component catalogs and install evidence can be reviewed:
```bash
specify bundle validate --path ./my-bundle # structural + reference checks
@@ -403,7 +406,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --integration copilot --ignore-agent-tools

53
docs/community/bundles.md Normal file
View File

@@ -0,0 +1,53 @@
# Community Bundles
> [!NOTE]
> Community bundles are independently created and maintained by their respective authors. Maintainers only verify that submission metadata is complete and correctly formatted — they do **not review, audit, endorse, or support the bundle code or the components it installs**. Review bundle manifests, component catalogs, and source repositories before installation and use at your own discretion.
Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands.
Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
## What to Submit
A bundle submission should include:
- A public repository with a valid `bundle.yml` manifest.
- A versioned GitHub release with a bundle artifact created by `specify bundle build`.
- Documentation that explains the intended role, installed components, required catalogs, and expected workflow.
- A proposed catalog entry with bundle metadata and component counts.
- Test evidence from a clean Spec Kit project.
## Component Resolution
A bundle catalog entry describes where to download the bundle artifact, but the bundle's component references still need to resolve when a user installs it. References can resolve from bundled components, already installed components, or active extension, preset, workflow, and step catalogs.
If your bundle depends on components that are not available from the default Spec Kit catalogs, include the required catalog URLs in the submission and in your README. Test the full install path from a clean project with those catalogs added before submitting.
For example:
```bash
specify preset catalog add https://example.com/presets.json --name example-bundle --install-allowed
specify extension catalog add https://example.com/extensions.json --name example-bundle --install-allowed
curl -L -o example-bundle-1.0.0.zip https://example.com/example-bundle-1.0.0.zip
specify bundle install ./example-bundle-1.0.0.zip
# Or install by id from an install-allowed bundle catalog.
specify bundle catalog add https://example.com/bundles.json --id example-bundle-catalog --policy install-allowed
specify bundle install example-bundle
```
## Review Scope
Maintainers check that:
- The submission fields are complete and correctly formatted.
- The release artifact and documentation URLs are reachable.
- The repository contains a `bundle.yml` manifest.
- The submission clearly identifies any required component catalogs.
- The proposed catalog entry uses the expected bundle catalog entry shape.
Maintainers do not audit the behavior of installed extensions, presets, workflows, steps, or scripts. Users should review those components before installing a community bundle.
## Updating a Bundle
To update a submitted bundle, file another [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue with the new version, download URL, changed component list, and updated test evidence. Mention that the issue updates an existing bundle entry.

View File

@@ -1,6 +1,6 @@
# Community
The Spec Kit community builds extensions, presets, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors.
The Spec Kit community builds extensions, presets, bundles, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors.
## Extensions
@@ -14,6 +14,12 @@ Presets customize how Spec Kit behaves — overriding templates, commands, and t
[Browse community presets →](presets.md)
## Bundles
Bundles compose extensions, presets, workflows, and steps into role or team stacks that can be installed together.
[Browse community bundles →](bundles.md)
## Walkthroughs
Step-by-step guides that show Spec-Driven Development in action across different scenarios, languages, and frameworks.

View File

@@ -25,7 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| SicarioSpec Core | Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| 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) |
| 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) |

View File

@@ -26,6 +26,7 @@ through the standard flow:
2. Run `/speckit.plan` to define the implementation approach.
3. Run `/speckit.tasks` to derive the work breakdown.
4. Run `/speckit.implement` and review the resulting code and artifact diffs.
5. Run `/speckit.converge` to verify completeness and generate tasks for remaining gaps. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete.
The previous feature directory remains intact for audit, comparison, or
explaining how the project reached its current state. Use clear feature names or
@@ -50,6 +51,7 @@ spec:
5. Run `/speckit.analyze` before implementation resumes to catch gaps between
the spec, plan, and tasks.
6. Run `/speckit.implement`, then review the code and artifact diffs together.
7. Run `/speckit.converge` to assess completion and append any remaining work to `tasks.md`. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete.
Preserve important implementation rationale before replacing derived artifacts.
If a plan or task list contains decisions that still matter, carry them forward

View File

@@ -94,8 +94,15 @@ This helps verify you are running the official Spec Kit build from GitHub, not a
After initialization, you should see the following commands available in your coding agent:
- `/speckit.specify` - Create specifications
- `/speckit.plan` - Generate implementation plans
- `/speckit.plan` - Generate implementation plans
- `/speckit.tasks` - Break down into actionable tasks
- `/speckit.implement` - Execute implementation tasks
- `/speckit.analyze` - Validate cross-artifact consistency
- `/speckit.clarify` - Identify and resolve ambiguities
- `/speckit.checklist` - Generate quality checklists
- `/speckit.constitution` - Create or update project principles
- `/speckit.converge` - Assess codebase against artifacts and append remaining tasks
- `/speckit.taskstoissues` - Convert tasks to issues
Scripts are installed into a variant subdirectory matching the chosen script type:

View File

@@ -13,10 +13,10 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
```text
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement -> /speckit.converge
```
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. Finally, run `/speckit.converge` after implementation to verify all planned work is complete and generate tasks for any remaining gaps. If `/speckit.converge` appends new tasks, run `/speckit.implement` again (and converge again) until it reports that the feature has converged.
### Step 1: Install Specify
@@ -188,6 +188,14 @@ Finally, implement the solution:
/speckit.implement
```
### Step 8: Converge
Run the `/speckit.converge` command after implementation to assess the current codebase against the feature's artifacts and append any remaining unbuilt work as new tasks to `tasks.md`. If the command appends new tasks, run `/speckit.implement` again to complete them, and repeat the converge step until the feature is fully complete.
```bash
/speckit.converge
```
> [!TIP]
> **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage.

View File

@@ -69,6 +69,33 @@ Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
}
```
### GitHub Enterprise Server (GHES)
To use a private catalog or extension hosted on a GitHub Enterprise Server
instance, add a `github` entry listing your GHES host(s). The same entry
authenticates both catalog JSON fetches **and** private release-asset
downloads — Specify recognizes the listed hosts as GitHub Enterprise and
resolves release downloads through the GHES REST API (`/api/v3`).
```json
{
"providers": [
{
"hosts": ["ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_ENTERPRISE_TOKEN"
}
]
}
```
List the **bare** web host (e.g. `ghes.example.com`) — release-download URLs
live there. If your instance uses subdomain isolation, also list the `raw.`
and `codeload.` subdomains your catalog/extension URLs use. A
`*.ghes.example.com` wildcard matches subdomains but **not** the bare host,
so always include the bare host explicitly.
### Azure DevOps (`azure-devops`)
| Scheme | Header | Use for |

View File

@@ -119,6 +119,12 @@ specify bundle build
Produces a single versioned, distributable `.zip` artifact from a bundle directory. The artifact embeds the manifest and can be installed directly with `specify bundle install <artifact.zip>`.
## Publish a Bundle
Bundle authors validate and package bundles locally, then host the generated artifact and catalog metadata where users can access it. A bundle catalog entry points at the bundle artifact, but the components declared inside `bundle.yml` still resolve through bundled components, installed components, or active extension, preset, workflow, and step catalogs.
If your bundle references components from non-default catalogs, document those catalog URLs and test the install path from a clean project with those catalogs added. Community bundle submissions should include that dependency-resolution evidence in the [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
## Manage Catalog Sources
Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes).

View File

@@ -26,6 +26,7 @@ specify extension add <name>
| --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--force` | Overwrite if already installed |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.

View File

@@ -19,7 +19,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
@@ -54,6 +54,27 @@ Shows all available integrations, which one is currently installed, and whether
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
The list also shows whether each built-in integration is declared multi-install safe.
## Search Available Integrations
```bash
specify integration search [query]
```
| Option | Description |
| ---------- | ------------------ |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
Searches the active catalog stack for integrations matching the query. Without a query, lists all available integrations. Must be run inside a Spec Kit project.
## Integration Info
```bash
specify integration info <integration_id>
```
Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project.
## Install an Integration
```bash
@@ -100,6 +121,7 @@ specify integration switch <key>
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
@@ -151,6 +173,47 @@ is `null` when no installed integration set can be evaluated, such as when the
integration state is missing, unreadable, lacks a valid recorded integration
list, or records no installed integrations.
## Catalog Management
Integration catalogs control where the discovery commands (`search` and `info`) look for integrations. Catalogs are checked in priority order.
### List Catalogs
```bash
specify integration catalog list
```
Shows the active catalog sources. Project-level sources (when configured) are removable by index; otherwise the active sources are shown as non-removable.
### Add a Catalog
```bash
specify integration catalog add <url>
```
| Option | Description |
| --------------- | ----------------------------- |
| `--name <name>` | Optional name for the catalog |
Adds a custom catalog URL to the project's `.specify/integration-catalogs.yml`. The URL must use HTTPS (except `http://localhost`, `http://127.0.0.1`, or `http://[::1]` for local testing).
### Remove a Catalog
```bash
specify integration catalog remove <index>
```
Removes a project catalog source by its 0-based index in `catalog list`.
### Catalog Resolution Order
Catalogs are resolved in this order (first match wins):
1. **Environment variable**`SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs
2. **Project config**`.specify/integration-catalogs.yml`
3. **User config**`~/.specify/integration-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog
## Integration-Specific Options
Some integrations accept additional options via `--integration-options`:
@@ -166,6 +229,18 @@ Example:
specify integration install generic --integration-options="--commands-dir .myagent/cmds"
```
## Scaffold a New Integration
```bash
specify integration scaffold <key>
```
Creates a minimal built-in integration package and a matching test skeleton in the Spec Kit repository, then prints the next steps for wiring it up. Run this command from the Spec Kit repository root. The `<key>` must be lowercase kebab-case (for example, `my-agent`).
| Option | Description |
| -------- | ---------------------------------------------------------------- |
| `--type` | Scaffold template to use: `markdown` (default), `skills`, `toml`, or `yaml` |
## FAQ
### Can I install multiple integrations in the same project?
@@ -184,6 +259,7 @@ The currently declared multi-install safe integrations are:
| --- | --------- |
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
| `claude` | `.claude/skills`, `CLAUDE.md` |
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
@@ -199,6 +275,7 @@ The currently declared multi-install safe integrations are:
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
| `zcode` | `.zcode/skills`, `ZCODE.md` |
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.

View File

@@ -137,9 +137,11 @@ catalogs:
## File Resolution
Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers.
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release.
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.
The resolution stack, from highest to lowest precedence:
@@ -148,8 +150,6 @@ The resolution stack, from highest to lowest precedence:
3. **Installed extensions** — sorted by priority
4. **Spec Kit core**`.specify/templates/`
Commands are registered at install time (not resolved through the stack at runtime).
### Resolution Stack
```mermaid
@@ -215,7 +215,7 @@ Run `specify preset resolve <name>` to trace the resolution stack and see which
### What's the difference between disabling and removing a preset?
**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`.
**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`.
**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.

View File

@@ -262,6 +262,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) |
| `prompt` | Send an arbitrary prompt to the AI coding agent |
| `shell` | Execute a shell command and capture output |
| `init` | Bootstrap a project (like `specify init`) |
| `gate` | Pause for human approval before continuing |
| `if` | Conditional branching (then/else) |
| `switch` | Multi-branch dispatch on an expression |

View File

@@ -66,6 +66,8 @@
href: community/extensions.md
- name: Presets
href: community/presets.md
- name: Bundles
href: community/bundles.md
- name: Walkthroughs
href: community/walkthroughs.md
- name: Friends

View File

@@ -6,15 +6,17 @@ It owns the lifecycle of the managed section delimited by the configurable start
## Why an extension?
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
## Commands
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
| Command | Description |
|---------|-------------|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
@@ -40,7 +42,7 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
@@ -62,5 +64,4 @@ pip install pyyaml
specify extension disable agent-context
```
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.

View File

@@ -0,0 +1,42 @@
{
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
"agents": {
"agy": "AGENTS.md",
"amp": "AGENTS.md",
"auggie": ".augment/rules/specify-rules.md",
"bob": "AGENTS.md",
"claude": "CLAUDE.md",
"cline": ".clinerules/specify-rules.md",
"codebuddy": "CODEBUDDY.md",
"codex": "AGENTS.md",
"copilot": ".github/copilot-instructions.md",
"cursor-agent": ".cursor/rules/specify-rules.mdc",
"devin": "AGENTS.md",
"firebender": ".firebender/rules/specify-rules.mdc",
"forge": "AGENTS.md",
"gemini": "GEMINI.md",
"generic": "AGENTS.md",
"goose": "AGENTS.md",
"hermes": "AGENTS.md",
"iflow": "IFLOW.md",
"junie": ".junie/AGENTS.md",
"kilocode": ".kilocode/rules/specify-rules.md",
"kimi": "AGENTS.md",
"kiro-cli": "AGENTS.md",
"lingma": ".lingma/rules/specify-rules.md",
"omp": "AGENTS.md",
"opencode": "AGENTS.md",
"pi": "AGENTS.md",
"qodercli": "QODER.md",
"qwen": "QWEN.md",
"roo": ".roo/rules/specify-rules.md",
"rovodev": "AGENTS.md",
"shai": "SHAI.md",
"tabnine": "TABNINE.md",
"trae": ".trae/rules/project_rules.md",
"vibe": "AGENTS.md",
"windsurf": ".windsurf/rules/specify-rules.md",
"zcode": "ZCODE.md",
"zed": "AGENTS.md"
}
}

View File

@@ -10,9 +10,9 @@
#
# Usage: update-agent-context.sh [plan_path]
#
# When `plan_path` is omitted, the script picks the most recently modified
# `specs/*/plan.md` if any exist, otherwise emits the section without a
# concrete plan path.
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail
@@ -59,7 +59,14 @@ case "$(uname -s 2>/dev/null || true)" in
esac
# Parse extension config once; emit context files as JSON, followed by marker strings.
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
#
# NOTE (bash 3.2 / macOS portability): the embedded Python heredocs below run
# inside $(...) command substitution. bash 3.2 (the system /bin/bash on macOS)
# mis-parses a single-quote/apostrophe in a heredoc body nested in $(...),
# failing with "unexpected EOF while looking for matching `''". Keep these
# $(...)-nested heredoc bodies free of apostrophes (use double quotes in Python
# string literals and avoid contractions in comments).
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY'
import json
import sys
try:
@@ -95,24 +102,67 @@ def get_str(obj, *keys):
context_files = []
seen_context_files = set()
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
def add_context_file(value):
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
return
context_files.append(candidate)
seen_context_files.add(key)
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
if not isinstance(value, str):
continue
candidate = value.strip()
if not candidate:
continue
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
continue
context_files.append(candidate)
seen_context_files.add(key)
add_context_file(value)
if not context_files:
raw_file = get_str(data, "context_file")
candidate = raw_file.strip()
if candidate:
context_files.append(candidate)
add_context_file(get_str(data, "context_file"))
if not context_files:
# Self-seed: the agent-context extension manages its own lifecycle, so when
# its config declares no target, it derives one from the active integration
# recorded in init-options.json, mapped through the bundled
# agent-context-defaults.json file. This is independent of the Specify CLI
# by design; nothing here imports specify_cli.
project_root = sys.argv[3] if len(sys.argv) > 3 else "."
integration_key = ""
try:
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:
opts = json.load(fh)
if isinstance(opts, dict):
value = opts.get("integration") or opts.get("ai") or ""
integration_key = value if isinstance(value, str) else ""
except Exception:
integration_key = ""
if integration_key:
defaults_path = (
f"{project_root}/.specify/extensions/agent-context/"
"agent-context-defaults.json"
)
mapping = {}
try:
with open(defaults_path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
mapping = agents if isinstance(agents, dict) else {}
except Exception:
print(
"agent-context: unable to read %s; cannot self-seed the context "
"file. Set context_file in the extension config." % defaults_path,
file=sys.stderr,
)
mapping = {}
add_context_file(mapping.get(integration_key, "") or "")
if not context_files:
print(
"agent-context: no default context file is known for integration "
"%s. Set context_file in the extension config to choose one."
% integration_key,
file=sys.stderr,
)
print(json.dumps(context_files))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
@@ -202,23 +252,78 @@ unset _cf_parts _seg
PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Use find + sort by modification time to avoid ls/head fragility with
# spaces in paths or SIGPIPE from pipefail.
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys, os
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
_feature_json="$PROJECT_ROOT/.specify/feature.json"
if [[ -f "$_feature_json" ]]; then
_feature_dir="$("$_python" - "$_feature_json" <<'PY'
import sys, json
try:
with open(sys.argv[1], encoding="utf-8") as fh:
d = json.load(fh)
val = d.get("feature_directory", "")
print(val if isinstance(val, str) else "")
except Exception:
print("")
PY
)"
# Normalize backslashes (written by PS on Windows) to forward slashes before path ops.
_feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')"
_feature_dir="${_feature_dir%/}"
if [[ -n "$_feature_dir" ]]; then
# feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT
# are preserved as-is by _persist_feature_json in common.sh).
# Also match drive-qualified paths (C:/...) written by PowerShell on Windows.
if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then
_candidate="$_feature_dir/plan.md"
else
_candidate="$PROJECT_ROOT/$_feature_dir/plan.md"
fi
if [[ -f "$_candidate" ]]; then
# Resolve symlinks before comparing so paths like /var/… vs /private/var/…
# (macOS) are treated as equivalent. Mirrors the mtime-fallback approach.
PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY'
import sys
from pathlib import Path
specs = Path(sys.argv[1]) / "specs"
root = Path(sys.argv[1]).resolve()
cand = Path(sys.argv[2]).resolve()
try:
print(cand.relative_to(root).as_posix())
except ValueError:
# Outside project root: emit the resolved path in POSIX form.
# as_posix() converts backslashes correctly on native Windows Python.
print(cand.as_posix())
PY
)"
fi
fi
fi
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
# Python emits a project-relative POSIX path directly to avoid bash prefix-strip
# issues with backslash paths on Windows (Git bash / MSYS2).
if [[ -z "$PLAN_PATH" ]]; then
_plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
specs = root / "specs"
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
print(plans[0] if plans else "")
if plans:
try:
print(plans[0].relative_to(root).as_posix())
except ValueError:
print("")
else:
print("")
PY
)"
if [[ -n "$_plan_abs" ]]; then
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
if [[ -n "$_plan_rel" ]]; then
PLAN_PATH="$_plan_rel"
fi
fi
fi
@@ -240,11 +345,58 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
mkdir -p "$(dirname "$CTX_PATH")"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
import os
import re
import sys
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
section = fh.read().rstrip("\n") + "\n"
def ensure_mdc_frontmatter(content):
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
``alwaysApply: true``. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
"""
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
match = re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
return content
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
@@ -274,6 +426,8 @@ else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
if ctx_path.casefold().endswith(".mdc"):
new_content = ensure_mdc_frontmatter(new_content)
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY

View File

@@ -9,6 +9,10 @@
# .specify/extensions/agent-context/agent-context-config.yml
#
# Usage: update-agent-context.ps1 [plan_path]
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()]
param(
@@ -16,6 +20,56 @@ param(
[string]$PlanPath
)
function Add-MdcFrontmatter {
<#
Ensure .mdc content has YAML frontmatter with alwaysApply: true.
Cursor only auto-loads .mdc rule files that carry frontmatter with
alwaysApply: true. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
$leading = ''
$stripped = $Content
$m = [regex]::Match($Content, '^\s*')
if ($m.Success) {
$leading = $m.Value
$stripped = $Content.Substring($m.Length)
}
if (-not $stripped.StartsWith('---')) {
return "---`nalwaysApply: true`n---`n`n" + $Content
}
$fm = [regex]::Match($stripped, '^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $fm.Success) {
return "---`nalwaysApply: true`n---`n`n" + $Content
}
$opening = $fm.Groups[1].Value
$fmText = $fm.Groups[2].Value
$closing = $fm.Groups[3].Value
$sep = $fm.Groups[4].Value
$rest = $fm.Groups[5].Value
$newline = if ($opening.Contains("`r`n")) { "`r`n" } else { "`n" }
if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$')) {
return $Content
}
if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:')) {
$alwaysApplyRegex = [regex]'(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$'
$fmText = $alwaysApplyRegex.Replace($fmText, '${1}alwaysApply: true${2}', 1)
} elseif ($fmText.Trim()) {
$fmText = $fmText + $newline + 'alwaysApply: true'
} else {
$fmText = 'alwaysApply: true'
}
return "$leading$opening$fmText$closing$sep$rest"
}
function Get-ConfigValue {
param(
[AllowNull()][object]$Object,
@@ -126,14 +180,26 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) {
$Options = $null
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
try {
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
$Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop
} catch {
# fall through to Python fallback
# fall through to ConvertFrom-Json fallback
}
}
if ($null -eq $Options) {
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
# ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps,
# works when the config file is valid JSON, which is a subset of YAML).
try {
$raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8
$Options = $raw | ConvertFrom-Json -ErrorAction Stop
if (-not (Test-ConfigObject -Object $Options)) { $Options = $null }
} catch {
$Options = $null
}
}
if ($null -eq $Options) {
# ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML.
$pythonCmd = $null
$pythonCandidates = @()
if ($env:SPECKIT_PYTHON) {
@@ -234,6 +300,43 @@ foreach ($ContextFile in $ContextFiles) {
}
}
$ContextFiles = $dedupedContextFiles
if ($ContextFiles.Count -eq 0) {
# Self-seed: the agent-context extension owns its lifecycle, so when its
# own config declares no target it derives one from the active integration
# recorded in init-options.json, using the extension's OWN bundled mapping
# (agent-context-defaults.json). Independent of the Specify CLI by design.
$initOptionsPath = Join-Path $ProjectRoot '.specify/init-options.json'
if (Test-Path -LiteralPath $initOptionsPath) {
try {
$initOpts = Get-Content -LiteralPath $initOptionsPath -Raw | ConvertFrom-Json -ErrorAction Stop
$integrationKey = $null
if ($initOpts.PSObject.Properties['integration'] -and $initOpts.integration) {
$integrationKey = [string]$initOpts.integration
} elseif ($initOpts.PSObject.Properties['ai'] -and $initOpts.ai) {
$integrationKey = [string]$initOpts.ai
}
if ($integrationKey) {
$defaultsPath = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-defaults.json'
if (Test-Path -LiteralPath $defaultsPath) {
$defaults = Get-Content -LiteralPath $defaultsPath -Raw | ConvertFrom-Json -ErrorAction Stop
$derived = $null
if ($defaults.PSObject.Properties['agents'] -and $defaults.agents.PSObject.Properties[$integrationKey]) {
$derived = [string]$defaults.agents.PSObject.Properties[$integrationKey].Value
}
if ($derived -and -not [string]::IsNullOrWhiteSpace($derived)) {
$ContextFiles += $derived.Trim()
} else {
Write-Warning ("agent-context: no default context file is known for integration '{0}'; set 'context_file' in the extension config to choose one." -f $integrationKey)
}
} else {
Write-Warning ("agent-context: unable to read {0}; cannot self-seed the context file. Set 'context_file' in the extension config." -f $defaultsPath)
}
}
} catch {
# Non-fatal: fall through to the nothing-to-do guard below.
}
}
}
if ($ContextFiles.Count -eq 0) {
Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.'
exit 0
@@ -280,21 +383,69 @@ if ($cm) {
}
if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
# $ErrorActionPreference = 'Stop' don't abort the script.
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
$FeatureJson = Join-Path $ProjectRoot '.specify/feature.json'
if (Test-Path -LiteralPath $FeatureJson) {
try {
$fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json
$featureDir = $fj.feature_directory
if ($featureDir -isnot [string] -or -not $featureDir) {
$featureDir = $null
} else {
$featureDir = $featureDir.TrimEnd('\', '/')
}
if ($featureDir) {
# Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly.
if ([System.IO.Path]::IsPathRooted($featureDir)) {
$candidatePlan = Join-Path $featureDir 'plan.md'
} else {
$candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md'
}
if (Test-Path -LiteralPath $candidatePlan) {
# Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()).
# GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible).
$resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan)
$resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan)
$normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
if ($normDir.StartsWith($normRoot, $cmp)) {
$relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/')
$PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' }
} else {
$PlanPath = $resolvedPlan.Replace('\', '/')
}
}
}
} catch {
# Non-fatal: fall through to mtime heuristic.
}
}
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
# GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat.
# Use case-insensitive comparison on Windows only (matches common.ps1 pattern).
$fullPath = $candidate.FullName.Replace('\', '/')
$normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/'
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
if ($fullPath.StartsWith($normRoot, $cmp)) {
$PlanPath = $fullPath.Substring($normRoot.Length)
} else {
$PlanPath = $fullPath
}
}
} catch {
# Non-fatal: continue without a plan path.
}
} catch {
# Non-fatal: continue without a plan path.
}
}
@@ -347,6 +498,9 @@ foreach ($ContextFile in $ContextFiles) {
}
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
if ($ContextFile -match '\.mdc$') {
$newContent = Add-MdcFrontmatter -Content $newContent
}
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -772,40 +772,40 @@
"companion": {
"name": "SpecKit Companion",
"id": "companion",
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and a turbo pipeline profile.",
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.",
"author": "alfredoperez",
"version": "0.3.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.0.zip",
"version": "0.11.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip",
"repository": "https://github.com/alfredoperez/speckit-companion",
"homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension",
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/",
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md",
"changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md",
"license": "MIT",
"category": "visibility",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.5",
"speckit_version": ">=0.9.5",
"tools": [
{ "name": "python3", "required": false }
]
},
"provides": {
"commands": 10,
"commands": 13,
"hooks": 4
},
"tags": [
"tracking",
"companion",
"progress",
"vscode",
"lifecycle",
"resume"
"progress",
"status",
"resume",
"configurable",
"extensible"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-06-11T00:00:00Z"
"updated_at": "2026-06-24T00:00:00Z"
},
"conduct": {
"name": "Conduct Extension",
@@ -2501,8 +2501,8 @@
"id": "product",
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
"author": "d0whc3r",
"version": "0.8.3",
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip",
"version": "1.0.1",
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v1.0.1/product-1.0.1.zip",
"repository": "https://github.com/d0whc3r/spec-kit-product",
"homepage": "https://d0whc3r.github.io/spec-kit-product/",
"documentation": "https://github.com/d0whc3r/spec-kit-product/wiki",
@@ -2514,7 +2514,7 @@
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 4,
"commands": 3,
"hooks": 3
},
"tags": [
@@ -2538,7 +2538,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
"updated_at": "2026-06-29T00:00:00Z"
},
"product-forge": {
"name": "Product Forge",

View File

@@ -288,7 +288,7 @@ generate_branch_name() {
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -qw -- "${word^^}"; then
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
meaningful_words+=("$word")
fi
fi

View File

@@ -51,4 +51,4 @@ _git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo " Git repository initialized" >&2
echo "[OK] Git repository initialized" >&2

View File

@@ -252,7 +252,10 @@ function Get-BranchName {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`:
# keep a short word only when its UPPERCASE form appears in the original
# (an acronym). -match is case-insensitive and would keep every short word.
$meaningfulWords += $word
}
}
@@ -397,8 +400,10 @@ if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
# $hasGit is computed for branch-creation logic only; it is intentionally not
# emitted so this output contract matches the bash twin: BRANCH_NAME and
# FEATURE_NUM, plus DRY_RUN (added just below) on dry runs.
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
@@ -406,7 +411,6 @@ if ($Json) {
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-25T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -567,13 +567,13 @@
"sicario-core": {
"name": "SicarioSpec Core",
"id": "sicario-core",
"version": "0.4.0",
"description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.",
"version": "0.5.1",
"description": "Baseline secure-by-default Spec Kit governance profile.",
"author": "SicarioSpec Contributors",
"repository": "https://github.com/dfirs1car1o/sicario-spec",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip",
"homepage": "https://github.com/dfirs1car1o/sicario-spec",
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md",
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.9.0"
@@ -583,14 +583,13 @@
"commands": 0
},
"tags": [
"security",
"governance",
"security-ops",
"secure-by-default",
"evidence"
],
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
"updated_at": "2026-06-25T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.11.8.dev0"
version = "0.12.1"
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

@@ -78,8 +78,14 @@ done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
# Get feature paths.
# In --paths-only mode this is pure resolution, so pass --no-persist to opt out
# of the feature.json write side effect (issue #3025).
if $PATHS_ONLY; then
_paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
else
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
fi
eval "$_paths_output"
unset _paths_output

View File

@@ -152,6 +152,15 @@ _persist_feature_json() {
}
get_feature_paths() {
# Read-only callers (e.g. check-prerequisites.sh --paths-only) pass
# --no-persist so pure path resolution never writes .specify/feature.json,
# which would dirty the working tree or overwrite a pinned value (issue #3025).
local no_persist=false
if [[ "${1:-}" == "--no-persist" ]]; then
no_persist=true
shift
fi
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
# get_repo_root propagates as a hard error instead of being masked by `local`.
local repo_root
@@ -168,8 +177,11 @@ get_feature_paths() {
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
# Persist to feature.json so future sessions without the env var still work
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
# Persist to feature.json so future sessions without the env var still
# work — unless the caller opted out for read-only resolution (#3025).
if [[ "$no_persist" != true ]]; then
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
fi
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")

View File

@@ -152,7 +152,7 @@ generate_branch_name() {
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -q "\b${word^^}\b"; then
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words+=("$word")
fi

View File

@@ -56,8 +56,14 @@ EXAMPLES:
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
$paths = Get-FeaturePathsEnv
# Get feature paths.
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
# the feature.json write side effect (issue #3025).
if ($PathsOnly) {
$paths = Get-FeaturePathsEnv -NoPersist
} else {
$paths = Get-FeaturePathsEnv
}
# If paths-only mode, output paths and exit (no validation)
if ($PathsOnly) {

View File

@@ -143,6 +143,13 @@ function Save-FeatureJson {
}
function Get-FeaturePathsEnv {
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
[switch]$NoPersist
)
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
@@ -157,8 +164,11 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
# Persist to feature.json so future sessions without the env var still work
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
if (-not $NoPersist) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
@@ -209,7 +219,13 @@ function Test-FileExists {
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
# A directory counts as non-empty when Get-ChildItem returns any entry
# (files or subdirectories) -- matching the JSON contracts checks in
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Write-Output " [OK] $Description"
return $true
} else {

View File

@@ -142,8 +142,10 @@ if ($ShortName) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Warn if -Number and -Timestamp are both specified
if ($Timestamp -and $Number -ne 0) {
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
@@ -153,8 +155,10 @@ if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# Determine branch number from existing feature directories
if ($Number -eq 0) {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
@@ -207,6 +211,10 @@ if (-not $DryRun) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
# Match the bash twin (create-new-feature.sh): warn on stderr that no
# spec template was found before creating an empty spec file, so the
# missing-template signal is not silently swallowed on Windows.
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}

View File

@@ -40,8 +40,22 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
if ($Json) {
[Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)")
} else {
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
}
} else {
Write-Warning "Plan template not found"
# Match the bash twin's wording and stream routing (stderr in -Json so
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
# "Copied plan template" message above.
if ($Json) {
[Console]::Error.WriteLine("Warning: Plan template not found")
} else {
Write-Output "Warning: Plan template not found"
}
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}

View File

@@ -262,85 +262,9 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
console.print(f" - {f}")
# ---------------------------------------------------------------------------
# Agent-context extension config helpers
# Skills directory helpers
# ---------------------------------------------------------------------------
_AGENT_CTX_EXT_CONFIG = (
Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml"
)
def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
"""Load the agent-context extension config, returning defaults on failure."""
from .integrations.base import IntegrationBase
defaults: dict[str, Any] = {
"context_file": "",
"context_files": [],
"context_markers": {
"start": IntegrationBase.CONTEXT_MARKER_START,
"end": IntegrationBase.CONTEXT_MARKER_END,
},
}
path = project_root / _AGENT_CTX_EXT_CONFIG
if not path.exists():
return defaults
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, yaml.YAMLError):
return defaults
if not isinstance(raw, dict):
return defaults
return raw
def _save_agent_context_config(
project_root: Path, config: dict[str, Any]
) -> None:
"""Persist *config* to the agent-context extension config file."""
path = project_root / _AGENT_CTX_EXT_CONFIG
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
def _update_agent_context_config_file(
project_root: Path,
context_file: str | None,
*,
preserve_markers: bool = True,
preserve_context_files: bool = True,
) -> None:
"""Update the agent-context extension config with *context_file*.
When *preserve_markers* is True (default), any existing
``context_markers`` values are kept unchanged so user customisations
survive integration changes and reinit. When False, the default
markers are written unconditionally.
When *preserve_context_files* is True (default), an existing
``context_files`` list is kept unchanged, including an empty list. This
lets projects opt into updating multiple agent context files while still
preserving the legacy singular ``context_file`` value for compatibility.
"""
from .integrations.base import IntegrationBase
cfg = _load_agent_context_config(project_root)
cfg["context_file"] = context_file or ""
existing_context_files = cfg.get("context_files")
if preserve_context_files:
cfg["context_files"] = (
existing_context_files if isinstance(existing_context_files, list) else []
)
else:
cfg.pop("context_files", None)
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
cfg["context_markers"] = {
"start": IntegrationBase.CONTEXT_MARKER_START,
"end": IntegrationBase.CONTEXT_MARKER_END,
}
_save_agent_context_config(project_root, cfg)
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
"""Resolve the agent-specific skills directory.
@@ -1128,9 +1052,10 @@ def workflow_add(
raise typer.Exit(1)
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
from specify_cli.authentication.http import github_provider_hosts
_wf_url_extra_headers = None
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30)
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30, github_hosts=github_provider_hosts())
if _resolved_wf_url:
source = _resolved_wf_url
_wf_url_extra_headers = {"Accept": "application/octet-stream"}
@@ -1234,10 +1159,11 @@ def workflow_add(
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
_wf_cat_extra_headers = None
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30)
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30, github_hosts=github_provider_hosts())
if _resolved_workflow_url:
workflow_url = _resolved_workflow_url
_wf_cat_extra_headers = {"Accept": "application/octet-stream"}

View File

@@ -34,6 +34,10 @@ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
console = Console(highlight=False)
# Stderr-bound console for error/diagnostic output, so human-facing messages
# never contaminate stdout (which carries machine-readable ``--json`` payloads).
err_console = Console(stderr=True, highlight=False)
class StepTracker:
"""Track and render hierarchical steps without emojis, similar to Claude Code tree output.
Supports live auto-refresh via an attached refresh callback.

View File

@@ -10,6 +10,7 @@ through the config-driven helpers in :mod:`specify_cli.authentication.http`.
import os
import urllib.request
from fnmatch import fnmatch
from typing import Callable, Dict, Optional
from urllib.parse import quote, unquote, urlparse
@@ -56,55 +57,79 @@ def build_github_request(url: str) -> urllib.request.Request:
return urllib.request.Request(url, headers=headers)
def _host_matches(hostname: str, patterns: tuple[str, ...]) -> bool:
"""Return True when *hostname* matches a pattern (exact or ``*.suffix``)."""
hostname = hostname.lower()
return any(p == hostname or fnmatch(hostname, p) for p in patterns)
def resolve_github_release_asset_api_url(
download_url: str,
open_url_fn: Callable,
timeout: int = 60,
github_hosts: tuple[str, ...] = (),
) -> Optional[str]:
"""Resolve a GitHub browser release URL to its REST API asset URL.
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
For private or SSO-protected repositories, browser release download
URLs (``https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>``)
redirect to an HTML/SSO page instead of delivering the file. This
helper resolves such a URL to the matching GitHub REST API asset URL
(``https://api.github.com/repos/…/releases/assets/<id>``), which can
then be downloaded with ``Accept: application/octet-stream`` and an
auth token to retrieve the actual file payload.
Works for public ``github.com`` and for GitHub Enterprise Server (GHES)
hosts. A host is treated as GHES when it matches one of *github_hosts*
(exact hostname or ``*.suffix``) — supply the hosts the user has trusted
under a ``github`` provider in ``auth.json``. This allowlist is the
security gate: unlisted hosts never receive GHES API treatment, so a
malicious catalog cannot induce an API request to an arbitrary host.
If *download_url* is already a REST API asset URL, it is returned
as-is. Non-GitHub URLs and GitHub URLs that are not release-download
URLs return ``None``. If the API lookup fails (e.g. network error or
asset not found), ``None`` is returned so callers can fall back to the
original URL.
For a public URL the API base is ``https://api.github.com``; for a GHES
host it is ``{scheme}://{host[:port]}/api/v3``. Returns the API asset URL
(downloadable with ``Accept: application/octet-stream`` + a token), the
input unchanged if it is already an API asset URL, or ``None`` when the
URL is not a resolvable GitHub release download or the lookup fails.
Args:
download_url: The URL to resolve.
open_url_fn: A callable compatible with
``specify_cli.authentication.http.open_url`` used to make the
authenticated API request.
``specify_cli.authentication.http.open_url`` used for the
authenticated release-metadata lookup.
timeout: Per-request timeout in seconds.
Returns:
The resolved REST API asset URL, or ``None`` if resolution is not
applicable or fails.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
"""
import json
import urllib.error
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
# Already a REST API asset URL — use it directly
if (
parsed.hostname == "api.github.com"
and len(parts) >= 6
and parts[:1] == ["repos"]
and parts[3:5] == ["releases", "assets"]
):
is_ghes = (
bool(hostname)
and hostname not in GITHUB_HOSTS
and _host_matches(hostname, github_hosts)
)
def _is_asset_path(segments: list[str]) -> bool:
return (
len(segments) >= 6
and segments[:1] == ["repos"]
and segments[3:5] == ["releases", "assets"]
)
# Already a REST API asset URL — use it directly. Pure passthrough induces
# no new request: the caller fetches this same URL regardless, so it is
# gated on path shape alone rather than the GHES allowlist. The token stays
# independently gated by auth.json in the download helper, and only the
# resolving path below (which issues a tag-lookup request) needs the
# allowlist as its anti-SSRF gate.
if hostname == "api.github.com" and _is_asset_path(parts):
return download_url
if hostname and parts[:2] == ["api", "v3"] and _is_asset_path(parts[2:]):
return download_url
# Only handle github.com browser release download URLs
if parsed.hostname != "github.com":
# Determine the REST API base for browser release-download URLs.
if hostname == "github.com":
api_base = "https://api.github.com"
elif is_ghes:
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
api_base = f"{parsed.scheme}://{authority}/api/v3"
else:
return None
# Expecting /<owner>/<repo>/releases/download/<tag>/<asset>
@@ -114,7 +139,7 @@ def resolve_github_release_asset_api_url(
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
encoded_tag = quote(tag, safe="")
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
with open_url_fn(release_url, timeout=timeout) as response:

View File

@@ -433,37 +433,6 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
# When disabled, ignore stale context_files but keep the singular
# context_file value so generated commands still point at the agent
# context file managed before the extension was disabled.
from .integrations.base import IntegrationBase
# Local import: _load_agent_context_config lives in __init__.py which
# imports agents.py, so a top-level import would be circular.
from . import _load_agent_context_config
ac_cfg = _load_agent_context_config(project_root)
extension_enabled = IntegrationBase._agent_context_extension_enabled(
project_root
)
if extension_enabled:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
)
else:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
include_context_files=False,
validate=False,
)
context_file = IntegrationBase._format_context_file_values(context_files)
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(

View File

@@ -118,6 +118,20 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
return urllib.request.Request(url, headers=headers)
def github_provider_hosts() -> tuple[str, ...]:
"""Return host patterns from every ``github`` provider entry in ``auth.json``.
Used to classify which hosts are GitHub Enterprise Server instances when
resolving release-asset download URLs. Returns an empty tuple when no
``auth.json`` exists or it contains no ``github`` entries.
"""
hosts: list[str] = []
for entry in _load_config():
if entry.provider == "github":
hosts.extend(entry.hosts)
return tuple(hosts)
def open_url(
url: str,
timeout: int = 10,

View File

@@ -78,7 +78,10 @@ class CatalogStackBase:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@", so the host guarantee this error
# promises would not actually hold. hostname is None in those cases.
if not parsed.hostname:
raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import typer
from ..._console import console
from ..._console import console, err_console
from ...bundler import BundlerError
from ...bundler.lib.project import (
active_integration,
@@ -41,7 +41,9 @@ bundle_app.add_typer(bundle_catalog_app, name="catalog")
def _fail(message: str) -> None:
"""Print an actionable error to stderr and exit non-zero."""
console.print(f"[red]Error:[/red] {message}", style=None)
# Use the stderr console so the error never lands on stdout, which under
# ``--json`` carries the machine-readable payload and must stay parseable.
err_console.print(f"[red]Error:[/red] {message}", style=None)
raise typer.Exit(code=1)

View File

@@ -18,7 +18,6 @@ from .._agent_config import (
SCRIPT_TYPE_CHOICES,
)
from .._assets import (
_locate_bundled_extension,
_locate_bundled_preset,
_locate_bundled_workflow,
get_speckit_version,
@@ -171,7 +170,6 @@ def register(app: typer.Typer) -> None:
from .. import (
_install_shared_infra_or_exit,
_print_cli_warning,
_update_agent_context_config_file,
ensure_executable_scripts,
save_init_options,
)
@@ -376,7 +374,6 @@ def register(app: typer.Typer) -> None:
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("workflow", "Install bundled workflow"),
("agent-context", "Install agent-context extension"),
("final", "Finalize"),
]:
tracker.add(key, label)
@@ -507,47 +504,6 @@ def register(app: typer.Typer) -> None:
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
# --- agent-context extension (bundled, auto-installed) ---
# Installed after init-options.json is written so that skill
# registration can read ai_skills + integration key.
try:
from ..extensions import ExtensionManager as _ExtMgr
bundled_ac = _locate_bundled_extension("agent-context")
if bundled_ac:
ac_mgr = _ExtMgr(project_path)
if ac_mgr.registry.is_installed("agent-context"):
tracker.complete("agent-context", "already installed")
else:
ac_mgr.install_from_directory(
bundled_ac, get_speckit_version()
)
tracker.complete("agent-context", "extension installed")
else:
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
tracker.error(
"agent-context",
f"bundled extension not found — installation may be "
f"incomplete. Run: {_ac_reinstall}",
)
except Exception as ac_err:
sanitized_ac = str(ac_err).replace("\n", " ").strip()
tracker.error(
"agent-context",
f"extension install failed: {sanitized_ac[:120]}",
)
# Write context_file to the agent-context extension config
# AFTER the extension install (which copies the template config
# with an empty context_file).
if resolved_integration.context_file:
_update_agent_context_config_file(
project_path,
resolved_integration.context_file,
preserve_markers=True,
)
ensure_executable_scripts(project_path, tracker=tracker)
if preset:

View File

@@ -2057,12 +2057,18 @@ class ExtensionCatalog(CatalogStackBase):
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL.
Delegates to the shared helper in :mod:`specify_cli._github_http`.
Delegates to the shared helper in :mod:`specify_cli._github_http`,
passing the ``github`` provider hosts from ``auth.json`` so GitHub
Enterprise Server release assets resolve via ``/api/v3``.
"""
from specify_cli._github_http import resolve_github_release_asset_api_url
from specify_cli.authentication.http import github_provider_hosts
return resolve_github_release_asset_api_url(
download_url, self._open_url, timeout=timeout
download_url,
self._open_url,
timeout=timeout,
github_hosts=github_provider_hosts(),
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:

View File

@@ -482,6 +482,7 @@ def extension_add(
elif from_url:
# Install from URL (ZIP file)
import io
import urllib.error
console.print(f"Downloading from {safe_url}...")
@@ -498,10 +499,33 @@ def extension_add(
zip_path = Path(download_file.name)
try:
from specify_cli.authentication.http import open_url as _open_url
# Use the catalog's authenticated fetch so configured
# credentials (incl. GitHub Enterprise Server) are applied
# and GHES release-asset URLs resolve via /api/v3 — keeping
# --from consistent with catalog-based installs.
dl_catalog = ExtensionCatalog(project_root)
download_url = from_url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with _open_url(from_url, timeout=60) as response:
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = response.read()
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
f"(got {len(zip_data)} bytes). This usually means the request "
f"was not authenticated and a login/HTML page was returned. "
f"Verify the URL is correct and that credentials for its host "
f"are configured in ~/.specify/auth.json."
)
raise typer.Exit(1)
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP

View File

@@ -117,11 +117,6 @@ class {class_name}({template.base_class}):
"args": "{template.args}",
"extension": "{template.extension}",
}}
context_file = "AGENTS.md"
# Default to False so the generated boilerplate passes the registry
# contract out of the box: multi-install-safe integrations must each have a
# distinct context_file, and the placeholder above ("AGENTS.md") collides
# with the existing codex integration. Opt in once you pick a unique one.
multi_install_safe = False
'''
@@ -155,7 +150,6 @@ def test_metadata():
assert integration.registrar_config["format"] == "{template.registrar_format}"
assert integration.registrar_config["args"] == "{template.args}"
assert integration.registrar_config["extension"] == "{template.extension}"
assert integration.context_file == "AGENTS.md"
assert integration.multi_install_safe is False
'''
@@ -274,7 +268,7 @@ def scaffold_integration(
next_steps = (
f"Register {class_name} in src/specify_cli/integrations/__init__.py.",
"Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.",
"Review config metadata, install_url, requires_cli, and multi_install_safe.",
f"Run pytest tests/integrations/test_integration_{package_name}.py -v.",
)
return IntegrationScaffoldResult(

View File

@@ -103,38 +103,17 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None:
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
"""Clear active integration keys from init-options.json when they match.
Also clears ``context_file`` from the agent-context extension config so
no stale path is left behind when the integration is uninstalled.
"""
"""Clear active integration keys from init-options.json when they match."""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
opts = load_init_options(project_root)
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
# Remove legacy fields that older versions may have written.
opts.pop("context_file", None)
opts.pop("context_markers", None)
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
# Clear context_file in the extension config if it already exists.
# Avoid creating the config (and parent dirs) in projects where the
# agent-context extension was never installed.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True, preserve_context_files=False
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
def _remove_integration_json(project_root: Path) -> None:
@@ -274,21 +253,13 @@ def _update_init_options_for_integration(
integration: Any,
script_type: str | None = None,
) -> None:
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
"""Update init-options.json to reflect *integration* as the active one.
``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists. Existing ``context_files``
lists are also preserved so projects can keep multi-agent context anchors
during integration switches. Invalid marker values are
silently ignored at runtime by ``_resolve_context_markers()`` which falls
back to the class-level defaults.
Agent context/instruction files are owned entirely by the opt-in
agent-context extension, so this function never touches the extension
or its config.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
@@ -296,9 +267,6 @@ def _update_init_options_for_integration(
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
# Remove legacy fields if they were written by an older version.
opts.pop("context_file", None)
opts.pop("context_markers", None)
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
@@ -307,24 +275,6 @@ def _update_init_options_for_integration(
else:
opts.pop("ai_skills", None)
# Update the agent-context extension config BEFORE init-options.json
# so a failure here doesn't leave init-options partially updated.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=True,
)
elif integration.context_file:
# Extension config doesn't exist yet (extension not installed).
# Write defaults so scripts have something to read.
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=False,
)
save_init_options(project_root, opts)

View File

@@ -42,7 +42,6 @@ class AgyIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@staticmethod
def _inject_hook_command_note(content: str) -> str:

View File

@@ -18,4 +18,3 @@ class AmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -18,5 +18,4 @@ class AuggieIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".augment/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -13,14 +13,13 @@ Provides:
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
from pathlib import Path, PureWindowsPath
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
@@ -91,13 +90,9 @@ class IntegrationBase(ABC):
And may optionally set:
* ``context_file`` — path (relative to project root) of the agent
context/instructions file (e.g. ``"CLAUDE.md"``)
Projects may additionally opt into managing multiple context files by
setting ``context_files`` in the agent-context extension config. The
integration class still declares one default ``context_file`` for backwards
compatibility and command-template rendering.
* ``invoke_separator`` — slash-command separator (defaults to ``"."``)
* ``multi_install_safe`` — declare the integration safe to install
alongside others (defaults to ``False``)
"""
# -- Must be set by every subclass ------------------------------------
@@ -113,9 +108,6 @@ class IntegrationBase(ABC):
# -- Optional ---------------------------------------------------------
context_file: str | None = None
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
@@ -125,16 +117,11 @@ class IntegrationBase(ABC):
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.
Safe integrations must use a static, unique agent root, command directory,
and context file. Registry tests enforce those invariants for every
Safe integrations must use a static, unique agent root and command
directory. Registry tests enforce those invariants for every
integration that sets this flag.
"""
# -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
CONTEXT_MARKER_END = "<!-- SPECKIT END -->"
# -- Public API -------------------------------------------------------
@classmethod
@@ -533,498 +520,6 @@ class IntegrationBase(ABC):
return created
# -- Agent context file management ------------------------------------
@staticmethod
def _ensure_mdc_frontmatter(content: str) -> str:
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
If frontmatter is missing, prepend it. If frontmatter exists but
``alwaysApply`` is absent or not ``true``, inject/fix it.
Uses string/regex manipulation to preserve comments and formatting
in existing frontmatter.
"""
import re as _re
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
# Match frontmatter block: ---\n...\n---
match = _re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
_re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
# Already correct?
if _re.search(
r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text
):
return content
# alwaysApply exists but wrong value — fix in place while preserving
# indentation and any trailing inline comment.
if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = _re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
@staticmethod
def _build_context_section(plan_path: str = "") -> str:
"""Build the content for the managed section between markers.
*plan_path* is the project-relative path to the current plan
(e.g. ``"specs/<feature>/plan.md"``). When empty, the section
contains only the generic directive without a concrete path.
"""
lines = [
"For additional context about technologies to be used, project structure,",
"shell commands, and other important information, read the current plan",
]
if plan_path:
lines.append(f"at {plan_path}")
return "\n".join(lines)
@staticmethod
def _agent_context_extension_enabled(project_root: Path) -> bool:
"""Return whether the bundled ``agent-context`` extension is enabled.
The extension is the single source of truth for managing coding
agent context/instruction files (e.g. ``CLAUDE.md``,
``.github/copilot-instructions.md``).
Returns ``True`` (enabled) when:
- the extension registry does not exist (legacy project, backwards
compatibility), or
- the registry has no ``agent-context`` entry (older project layout
predating the extension), or
- the entry is present and not explicitly disabled.
Returns ``False`` only when an entry exists with ``enabled: false``.
"""
registry_path = (
project_root / ".specify" / "extensions" / ".registry"
)
if not registry_path.exists():
return True
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeError):
return True
if not isinstance(data, dict):
return True
extensions = data.get("extensions")
if not isinstance(extensions, dict):
return True
entry = extensions.get("agent-context")
if not isinstance(entry, dict):
return True
return entry.get("enabled", True) is not False
@staticmethod
def _context_file_dedupe_key(path: str) -> str:
"""Return the comparison key for context file de-duplication."""
return path.casefold() if os.name == "nt" else path
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
"""Return the (start, end) context markers to use for *project_root*.
Reads ``context_markers.start`` / ``context_markers.end`` from the
agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present. Falls back to the class-level constants
``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is
missing, the section is absent, or the values are not non-empty
strings.
"""
from .._console import console # local import to avoid cycles
start = self.CONTEXT_MARKER_START
end = self.CONTEXT_MARKER_END
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
return start, end
markers = cfg.get("context_markers") if isinstance(cfg, dict) else None
if isinstance(markers, dict):
cm_start = markers.get("start")
cm_end = markers.get("end")
s_valid = isinstance(cm_start, str) and cm_start
e_valid = isinstance(cm_end, str) and cm_end
if not s_valid and cm_start is not None:
console.print(
f"[yellow]agent-context: ignoring invalid context_markers.start "
f"({cm_start!r}), using default[/yellow]"
)
if not e_valid and cm_end is not None:
console.print(
f"[yellow]agent-context: ignoring invalid context_markers.end "
f"({cm_end!r}), using default[/yellow]"
)
if s_valid:
start = cm_start # type: ignore[assignment]
if e_valid:
end = cm_end # type: ignore[assignment]
return start, end
@staticmethod
def _validate_context_file_path(project_root: Path, context_file: str) -> str:
"""Return a safe project-relative context file path.
The agent-context scripts reject paths that can escape the project
root; the Python integration path must apply the same guard before
setup or teardown touches context files.
"""
candidate = context_file.strip()
if not candidate:
raise ValueError("agent-context: context file path must not be empty")
win_path = PureWindowsPath(candidate)
if Path(candidate).is_absolute() or win_path.drive or win_path.root:
raise ValueError(
"agent-context: context files must be project-relative paths; "
f"got {candidate!r}"
)
if "\\" in candidate:
raise ValueError(
"agent-context: context files must not contain backslash "
f"separators; got {candidate!r}"
)
parts = [part for part in re.split(r"[\\/]+", candidate) if part]
if ".." in parts:
raise ValueError(
"agent-context: context files must not contain '..' path "
f"segments; got {candidate!r}"
)
root = project_root.resolve()
target = (root / candidate).resolve(strict=False)
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(
"agent-context: context file path resolves outside the project "
f"root; got {candidate!r}"
) from exc
return candidate
@classmethod
def _resolve_context_file_values(
cls,
project_root: Path,
cfg: dict[str, Any] | None,
*,
fallback_context_file: Any = None,
legacy_context_file: Any = None,
include_context_files: bool = True,
validate: bool = True,
) -> list[str]:
"""Resolve context file config with shared precedence and de-duplication."""
files: list[str] = []
seen: set[str] = set()
def add_context_file(value: Any) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
if validate:
candidate = cls._validate_context_file_path(project_root, candidate)
key = cls._context_file_dedupe_key(candidate)
if key in seen:
return
files.append(candidate)
seen.add(key)
if isinstance(cfg, dict) and include_context_files:
configured = cfg.get("context_files")
if isinstance(configured, list):
for value in configured:
add_context_file(value)
if files:
return files
if isinstance(cfg, dict):
add_context_file(cfg.get("context_file"))
if files:
return files
add_context_file(fallback_context_file)
if files:
return files
add_context_file(legacy_context_file)
return files
@staticmethod
def _format_context_file_values(context_files: list[str]) -> str:
"""Return context file targets as the template display string."""
return ", ".join(context_files)
def _resolve_context_files(self, project_root: Path) -> list[str]:
"""Return project-relative context files managed for *project_root*.
``context_files`` in the agent-context extension config, when present
and non-empty, takes precedence over the config's singular
``context_file``. The integration class default is used only when the
extension config has no context file target.
Raises ``ValueError`` when a configured path can escape the project
root.
"""
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
cfg = None
return self._resolve_context_file_values(
project_root,
cfg,
fallback_context_file=self.context_file,
)
def _context_file_display(self, project_root: Path) -> str:
"""Return human-readable context file target(s) for templates."""
if not self._agent_context_extension_enabled(project_root):
from .. import _load_agent_context_config
context_files = self._resolve_context_file_values(
project_root,
_load_agent_context_config(project_root),
fallback_context_file=self.context_file,
include_context_files=False,
validate=False,
)
return context_files[0] if context_files else ""
return self._format_context_file_values(
self._resolve_context_files(project_root)
)
@staticmethod
def _upsert_context_file(
ctx_path: Path,
section: str,
marker_start: str,
marker_end: str,
) -> None:
"""Create or update one managed context section."""
if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Replace existing section (include the end marker + newline)
end_of_marker = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:start_idx] + section + content[end_of_marker:]
elif start_idx != -1:
# Corrupted: start marker without end — replace from start through EOF
new_content = content[:start_idx] + section
elif end_idx != -1:
# Corrupted: end marker without start — replace BOF through end marker
end_of_marker = end_idx + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
# No markers found — append
if content:
if not content.endswith("\n"):
content += "\n"
new_content = content + "\n" + section
else:
new_content = section
# Ensure .mdc files have required YAML frontmatter
if ctx_path.suffix == ".mdc":
new_content = IntegrationBase._ensure_mdc_frontmatter(new_content)
else:
ctx_path.parent.mkdir(parents=True, exist_ok=True)
# Cursor .mdc files require YAML frontmatter to be loaded
if ctx_path.suffix == ".mdc":
new_content = IntegrationBase._ensure_mdc_frontmatter(section)
else:
new_content = section
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
ctx_path.write_bytes(normalized.encode("utf-8"))
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the first context file, or ``None`` when no context
files are configured or the ``agent-context`` extension is
disabled.
"""
if not self._agent_context_extension_enabled(project_root):
return None
context_files = self._resolve_context_files(project_root)
if not context_files:
return None
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
)
first_path: Path | None = None
for context_file in context_files:
ctx_path = project_root / context_file
self._upsert_context_file(ctx_path, section, marker_start, marker_end)
if first_path is None:
first_path = ctx_path
return first_path
def remove_context_section(self, project_root: Path) -> bool:
"""Remove the managed section from the agent context file.
Returns ``True`` if the section was found and removed. If the
file becomes empty (or whitespace-only) after removal it is deleted.
Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
"""
if not self._agent_context_extension_enabled(project_root):
return False
context_files = self._resolve_context_files(project_root)
if not context_files:
return False
marker_start, marker_end = self._resolve_context_markers(project_root)
removed_any = False
for context_file in context_files:
ctx_path = project_root / context_file
if not ctx_path.exists():
continue
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
continue
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
)
if not normalized.strip() or frontmatter_only:
ctx_path.unlink()
removed_any = True
continue
if not normalized.strip():
ctx_path.unlink()
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
removed_any = True
return removed_any
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
@@ -1049,7 +544,6 @@ class IntegrationBase(ABC):
agent_name: str,
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
context_file: str = "",
invoke_separator: str = ".",
) -> str:
"""Process a raw command template into agent-ready content.
@@ -1060,9 +554,8 @@ class IntegrationBase(ABC):
3. Strip ``scripts:`` section from frontmatter
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
5. Replace ``__AGENT__`` with *agent_name*
6. Replace ``__CONTEXT_FILE__`` with *context_file*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
@@ -1122,10 +615,7 @@ class IntegrationBase(ABC):
# 5. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
# 6. Replace __CONTEXT_FILE__
content = content.replace("__CONTEXT_FILE__", context_file)
# 7. Rewrite paths — delegate to the shared implementation in
# 6. Rewrite paths — delegate to the shared implementation in
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
@@ -1180,8 +670,6 @@ class IntegrationBase(ABC):
self.record_file_in_manifest(dst_file, project_root, manifest)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1196,11 +684,9 @@ class IntegrationBase(ABC):
Delegates to ``manifest.uninstall()`` which only removes files
whose hash still matches the recorded value (unless *force*).
Also removes the managed context section from the agent file.
Returns ``(removed, skipped)`` file lists.
"""
self.remove_context_section(project_root)
return manifest.uninstall(project_root, force=force)
# -- Convenience helpers for subclasses -------------------------------
@@ -1234,12 +720,11 @@ class IntegrationBase(ABC):
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
Subclasses only need to set ``key``, ``config``, ``registrar_config``
(and optionally ``context_file``). Everything else is inherited.
Subclasses only need to set ``key``, ``config``, ``registrar_config``.
Everything else is inherited.
``setup()`` processes command templates (replacing ``{SCRIPT}``,
``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the
managed context section into the agent context file.
``{ARGS}``, ``__AGENT__``, rewriting paths).
"""
def build_exec_args(
@@ -1294,13 +779,11 @@ class MarkdownIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -1308,8 +791,6 @@ class MarkdownIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1323,8 +804,7 @@ class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``key``, ``config``, ``registrar_config``. Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
@@ -1500,14 +980,12 @@ class TomlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
@@ -1517,8 +995,6 @@ class TomlIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1532,8 +1008,7 @@ class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
Mirrors ``TomlIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``key``, ``config``, ``registrar_config``. Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
@@ -1696,7 +1171,6 @@ class YamlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1712,7 +1186,6 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
@@ -1724,8 +1197,6 @@ class YamlIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1741,8 +1212,8 @@ class SkillsIntegration(IntegrationBase):
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
the `agentskills.io <https://agentskills.io/specification>`_ spec.
Subclasses set ``key``, ``config``, ``registrar_config`` (and
optionally ``context_file``) like any integration. They may also
Subclasses set ``key``, ``config``, ``registrar_config`` like any
integration. They may also
override ``options()`` to declare additional CLI flags (e.g.
``--skills``, ``--migrate-legacy``).
@@ -1887,7 +1358,6 @@ class SkillsIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1911,7 +1381,6 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
@@ -1958,7 +1427,5 @@ class SkillsIntegration(IntegrationBase):
)
created.append(dst)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -18,4 +18,3 @@ class BobIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -22,13 +22,17 @@ ARGUMENT_HINTS: dict[str, str] = {
}
# Per-command frontmatter overrides for skills that should run in a forked
# subagent context. Read-only analysis commands are good candidates: the
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
# so isolating them keeps the main conversation context clean.
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
"analyze": {"context": "fork", "agent": "general-purpose"},
}
# subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
#
# This is intentionally empty. ``analyze`` was previously forked (added in
# #2511) on the assumption that its heavy reads collapse to a short summary,
# but in practice ``/speckit-analyze`` returns a 300-500 line report that is
# injected back into the main conversation. In long sessions each subsequent
# fork inherits that growing context, compounding overhead until the chat
# freezes (#3185). Until a command genuinely returns a compact result, no
# command opts into ``context: fork``. The injection mechanism below stays in
# place so a future command can be added here when that holds true.
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {}
class ClaudeIntegration(SkillsIntegration):
@@ -48,7 +52,6 @@ class ClaudeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
multi_install_safe = True
@staticmethod

View File

@@ -70,7 +70,6 @@ class ClineIntegration(MarkdownIntegration):
"format_name": format_cline_command_name,
"invoke_separator": "-",
}
context_file = ".clinerules/specify-rules.md"
invoke_separator = "-"
multi_install_safe = True

View File

@@ -9,7 +9,7 @@ class CodebuddyIntegration(MarkdownIntegration):
"name": "CodeBuddy",
"folder": ".codebuddy/",
"commands_subdir": "commands",
"install_url": "https://www.codebuddy.ai/cli",
"install_url": "https://www.codebuddy.cn/docs/cli/installation",
"requires_cli": True,
}
registrar_config = {
@@ -18,5 +18,4 @@ class CodebuddyIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "CODEBUDDY.md"
multi_install_safe = True

View File

@@ -26,7 +26,6 @@ class CodexIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True

View File

@@ -4,7 +4,6 @@ Copilot has several unique behaviors compared to standard markdown agents:
- Commands use ``.agent.md`` extension (not ``.md``)
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
- Installs ``.vscode/settings.json`` with prompt file recommendations
- Context file lives at ``.github/copilot-instructions.md``
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
@@ -79,7 +78,6 @@ class _CopilotSkillsHelper(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".github/copilot-instructions.md"
class CopilotIntegration(IntegrationBase):
@@ -108,7 +106,6 @@ class CopilotIntegration(IntegrationBase):
"args": "$ARGUMENTS",
"extension": ".agent.md",
}
context_file = ".github/copilot-instructions.md"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
@@ -354,14 +351,12 @@ class CopilotIntegration(IntegrationBase):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
context_file_display = self._context_file_display(project_root)
# 1. Process and write command files as .agent.md
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -396,8 +391,6 @@ class CopilotIntegration(IntegrationBase):
self.record_file_in_manifest(dst_settings, project_root, manifest)
created.append(dst_settings)
# 4. Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -36,7 +36,6 @@ class CursorAgentIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = ".cursor/rules/specify-rules.mdc"
multi_install_safe = True
def build_exec_args(

View File

@@ -30,7 +30,6 @@ class DevinIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

View File

@@ -3,8 +3,8 @@
Firebender (https://firebender.com/) is an AI coding agent for Android Studio
and IntelliJ. It reads project-local custom slash commands from
``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``,
so Spec Kit installs its command templates as ``.mdc`` command files and writes
the managed context section into a ``.firebender/rules/`` rule file.
so Spec Kit installs its command templates as ``.mdc`` command files. The managed
context section (when used) is owned by the ``agent-context`` extension.
"""
from ..base import MarkdownIntegration
@@ -25,7 +25,6 @@ class FirebenderIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".mdc",
}
context_file = ".firebender/rules/specify-rules.mdc"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:

View File

@@ -89,7 +89,6 @@ class ForgeIntegration(MarkdownIntegration):
"format_name": format_forge_command_name, # Custom name formatter
"invoke_separator": "-",
}
context_file = "AGENTS.md"
invoke_separator = "-"
def setup(
@@ -128,14 +127,12 @@ class ForgeIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Process template with standard MarkdownIntegration logic
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
@@ -152,8 +149,6 @@ class ForgeIntegration(MarkdownIntegration):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -18,5 +18,4 @@ class GeminiIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
multi_install_safe = True

View File

@@ -31,7 +31,6 @@ class GenericIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -119,13 +118,11 @@ class GenericIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -133,7 +130,5 @@ class GenericIntegration(MarkdownIntegration):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -1,4 +1,4 @@
"""Goose integration — Block's open source AI agent."""
"""Goose integration — open source AI agent (Agentic AI Foundation)."""
from ..base import YamlIntegration
@@ -9,7 +9,7 @@ class GooseIntegration(YamlIntegration):
"name": "Goose",
"folder": ".goose/",
"commands_subdir": "recipes",
"install_url": "https://block.github.io/goose/docs/getting-started/installation",
"install_url": "https://goose-docs.ai/docs/getting-started/installation",
"requires_cli": True,
}
registrar_config = {
@@ -18,4 +18,3 @@ class GooseIntegration(YamlIntegration):
"args": "{{args}}",
"extension": ".yaml",
}
context_file = "AGENTS.md"

View File

@@ -50,7 +50,6 @@ class HermesIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
# -- Helpers -----------------------------------------------------------
@@ -114,7 +113,6 @@ class HermesIntegration(SkillsIntegration):
global_skills_dir.mkdir(parents=True, exist_ok=True)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -141,7 +139,6 @@ class HermesIntegration(SkillsIntegration):
self.key,
script_type,
arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
@@ -183,8 +180,6 @@ class HermesIntegration(SkillsIntegration):
skill_file.write_bytes(normalized.encode("utf-8"))
created.append(skill_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
# Create project-local marker directory so extension commands
# (e.g. git) can detect Hermes as an active integration.
@@ -204,8 +199,7 @@ class HermesIntegration(SkillsIntegration):
) -> tuple[list[Path], list[Path]]:
"""Uninstall integration files including global Hermes skills.
Removes the managed context section from AGENTS.md, removes the
project-local marker directory (if empty), delegates to
Removes the project-local marker directory (if empty), delegates to
``manifest.uninstall()`` for project-local tracked files, and
removes all ``speckit-*`` skills under ``~/.hermes/skills/``.
@@ -213,8 +207,6 @@ class HermesIntegration(SkillsIntegration):
standard integration behaviour where all files created by the
integration are removed on ``specify integration uninstall``.
"""
# Remove managed context section from AGENTS.md
self.remove_context_section(project_root)
# Delegate to manifest for project-local tracked files (scripts,
# templates, context entries tracked in the manifest).

View File

@@ -18,5 +18,4 @@ class IflowIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "IFLOW.md"
multi_install_safe = True

View File

@@ -18,5 +18,4 @@ class JunieIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".junie/AGENTS.md"
multi_install_safe = True

View File

@@ -18,5 +18,4 @@ class KilocodeIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".kilocode/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -5,8 +5,7 @@ Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
Legacy migration covers projects created before Kimi Code CLI moved to
this layout and handles two distinct changes: the directory move from
``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md``
context file), and the dotted-to-hyphenated skill naming
``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming
(``speckit.xxx`` → ``speckit-xxx``).
"""
@@ -16,7 +15,7 @@ import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
@@ -37,7 +36,6 @@ class KimiIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
multi_install_safe = False
def build_command_invocation(self, command_name: str, args: str = "") -> str:
@@ -79,9 +77,7 @@ class KimiIntegration(SkillsIntegration):
default=False,
help=(
"Migrate legacy Kimi installations: "
".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, "
"and (when the agent-context extension is enabled) "
"KIMI.md user content → AGENTS.md"
".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx"
),
),
]
@@ -128,14 +124,6 @@ class KimiIntegration(SkillsIntegration):
_is_safe_legacy_dir(new_skills_dir, project_root)
):
_migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir)
# Mirror upsert/remove_context_section: a disabled agent-context
# extension is a full opt-out, so skip the KIMI.md → AGENTS.md
# migration entirely and leave both files untouched.
if self._agent_context_extension_enabled(project_root):
marker_start, marker_end = self._resolve_context_markers(project_root)
_migrate_legacy_kimi_context_file(
project_root, marker_start=marker_start, marker_end=marker_end
)
return created
@@ -363,112 +351,6 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
)
def _migrate_legacy_kimi_context_file(
project_root: Path,
*,
marker_start: str = IntegrationBase.CONTEXT_MARKER_START,
marker_end: str = IntegrationBase.CONTEXT_MARKER_END,
) -> bool:
"""Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``.
The Speckit managed section is stripped from ``KIMI.md`` before the
remaining content is appended to ``AGENTS.md``. The legacy file is
deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was
migrated, ``False`` when the migration is skipped.
The migration is skipped (leaving ``KIMI.md`` untouched) in any of these
cases, so a best-effort legacy cleanup never aborts ``setup()`` or
corrupts ``AGENTS.md``:
- ``KIMI.md`` is a symlink, missing, or unreadable (its target could be
read from outside the project, or it may not be valid UTF-8).
- ``AGENTS.md`` is a symlink (it could redirect the write to a file
outside the project root), exists as a non-file (e.g. a directory),
or is unreadable/unwritable.
- ``KIMI.md`` has a corrupted managed section — only one marker is
present, or the end marker precedes the start. Stripping is only done
when both markers are present and well-ordered, so a partial managed
block is never copied into ``AGENTS.md``; the user repairs it manually.
"""
legacy_path = project_root / "KIMI.md"
if legacy_path.is_symlink() or not legacy_path.is_file():
return False
target_path = project_root / "AGENTS.md"
# Never follow a symlinked target, and never treat an existing non-file
# (e.g. a directory) as a writable context file.
if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()):
return False
try:
content = legacy_path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError):
return False
marker_pairs = [(marker_start, marker_end)]
default_pair = (
IntegrationBase.CONTEXT_MARKER_START,
IntegrationBase.CONTEXT_MARKER_END,
)
if default_pair not in marker_pairs:
marker_pairs.append(default_pair)
start_idx = -1
end_idx = -1
has_start = False
has_end = False
for s, e in marker_pairs:
s_idx = content.find(s)
e_idx = content.find(e, s_idx if s_idx != -1 else 0)
has_s = s_idx != -1
has_e = e_idx != -1
if not has_s and not has_e:
continue
# Refuse to migrate a corrupted managed section: exactly one marker, or
# an end marker that does not follow the start.
if has_s != has_e or e_idx <= s_idx:
return False
marker_start, marker_end = s, e
start_idx, end_idx = s_idx, e_idx
has_start = True
has_end = True
break
if has_start and has_end:
removal_start = start_idx
removal_end = end_idx + len(marker_end)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
content = content[:removal_start] + content[removal_end:]
user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not user_content:
legacy_path.unlink()
return True
try:
if target_path.is_file():
existing = target_path.read_text(encoding="utf-8-sig")
existing = existing.replace("\r\n", "\n").replace("\r", "\n")
if not existing.endswith("\n"):
existing += "\n"
new_content = existing + "\n" + user_content + "\n"
else:
new_content = user_content + "\n"
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(new_content.encode("utf-8"))
except (OSError, UnicodeDecodeError):
return False
legacy_path.unlink()
return True
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Compatibility shim — migrate legacy dotted skill dirs in place.

View File

@@ -26,4 +26,3 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".lingma/rules/specify-rules.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -20,7 +20,6 @@ class OmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

View File

@@ -19,7 +19,6 @@ class OpencodeIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

View File

@@ -9,7 +9,7 @@ class PiIntegration(MarkdownIntegration):
"name": "Pi Coding Agent",
"folder": ".pi/",
"commands_subdir": "prompts",
"install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
"install_url": "https://www.npmjs.com/package/@earendil-works/pi-coding-agent",
"requires_cli": True,
}
registrar_config = {
@@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -18,5 +18,4 @@ class QodercliIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "QODER.md"
multi_install_safe = True

View File

@@ -18,5 +18,4 @@ class QwenIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "QWEN.md"
multi_install_safe = True

View File

@@ -18,5 +18,4 @@ class RooIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".roo/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -39,7 +39,6 @@ class RovodevIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
# -- CLI dispatch ------------------------------------------------------
@@ -228,8 +227,7 @@ class RovodevIntegration(SkillsIntegration):
) -> list[Path]:
"""Install RovoDev skills, then generate prompt wrappers and manifest.
1. ``SkillsIntegration.setup()`` generates skill files and
upserts the context section.
1. ``SkillsIntegration.setup()`` generates the skill files.
2. Generates prompt wrappers and ``prompts.yml`` for each skill
created in step 1.
"""

View File

@@ -18,5 +18,4 @@ class ShaiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "SHAI.md"
multi_install_safe = True

View File

@@ -18,5 +18,4 @@ class TabnineIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "TABNINE.md"
multi_install_safe = True

View File

@@ -26,7 +26,6 @@ class TraeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".trae/rules/project_rules.md"
multi_install_safe = True
@classmethod

View File

@@ -28,7 +28,6 @@ class VibeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -18,5 +18,4 @@ class WindsurfIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -28,7 +28,6 @@ class ZcodeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "ZCODE.md"
multi_install_safe = True
@classmethod

View File

@@ -27,7 +27,6 @@ class ZedIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -1861,7 +1861,10 @@ class PresetCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@", so the host guarantee this error
# promises would not actually hold. hostname is None in those cases.
if not parsed.hostname:
raise PresetValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -1892,10 +1895,19 @@ class PresetCatalog:
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its REST API asset URL."""
"""Resolve a GitHub release asset URL to its REST API asset URL.
Passes the ``github`` provider hosts from ``auth.json`` so GitHub
Enterprise Server release assets resolve via ``/api/v3``.
"""
from specify_cli._github_http import resolve_github_release_asset_api_url
from specify_cli.authentication.http import github_provider_hosts
return resolve_github_release_asset_api_url(
download_url, self._open_url, timeout=timeout
download_url,
self._open_url,
timeout=timeout,
github_hosts=github_provider_hosts(),
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:

View File

@@ -144,10 +144,13 @@ def preset_add(
zip_path = Path(tmpdir) / "preset.zip"
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts
from specify_cli._github_http import resolve_github_release_asset_api_url
_preset_extra_headers = None
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
_resolved_from_url = resolve_github_release_asset_api_url(
from_url, _open_url, github_hosts=github_provider_hosts()
)
if _resolved_from_url:
from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"}

View File

@@ -296,6 +296,40 @@ def _validate_steps(
f"boolean, got {type(coe).__name__}."
)
# Fan-in: every wait_for id must reference a step declared at or before
# this point. An id not yet seen is either a typo (unknown step) or a
# forward reference (the target runs after this fan-in, so its results
# cannot exist yet) — both are wiring errors that previously surfaced as
# a silent empty result + COMPLETED. A step that is declared but only
# conditionally executed (e.g. inside an if/switch branch) is still
# "seen" here, so a legitimately-empty result at runtime stays valid.
if step_type == "fan-in":
wait_for = step_config.get("wait_for")
if isinstance(wait_for, list):
for wid in wait_for:
if not isinstance(wid, str):
# A non-string entry (e.g. YAML `wait_for: [123]`) can
# never match a real step id, so the join is silently
# empty at runtime — surface it as a wiring error.
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' entries must "
f"be step-id strings, got {type(wid).__name__} "
f"({wid!r})."
)
elif wid == step_id:
# The fan-in's own id is already in seen_ids by now, so
# a self-reference would pass the membership check below
# while still producing an empty join at runtime.
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' references "
f"itself; a fan-in cannot wait for its own results."
)
elif wid not in seen_ids:
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' references "
f"unknown or not-yet-declared step id {wid!r}."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
@@ -1010,7 +1044,12 @@ class WorkflowEngine:
value = float(value)
if value == int(value):
value = int(value)
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
# OverflowError: `int(value)` raises it for an infinite float
# (e.g. a `default: .inf` authoring mistake), which would
# otherwise escape validate_workflow's `except ValueError` and
# break its "return errors, never raise" contract. Surface it as
# the same clean "expected a number" error as NaN does.
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg) from None
elif input_type == "boolean":

View File

@@ -180,6 +180,35 @@ def _split_top_level_commas(text: str) -> list[str]:
return parts
def _find_top_level(text: str, token: str) -> int:
"""Return the index of the first occurrence of *token* in *text* that lies
outside any quoted string or nested bracket, or ``-1`` if there is none.
Used so operator/keyword splitting (``and``/``or``/``in``/comparisons) does
not match a separator that appears *inside* a quoted operand -- e.g. the
``and`` in ``mode == 'read and write'`` or the ``or`` in ``'approve or reject'``.
"""
quote: str | None = None
depth = 0
i = 0
n = len(text)
while i < n:
ch = text[i]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth = max(0, depth - 1)
elif depth == 0 and text.startswith(token, i):
return i
i += 1
return -1
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
@@ -193,18 +222,21 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""
expr = expr.strip()
# String literal — check before pipes and operators so quoted strings
# containing | or operator keywords are not mis-parsed.
if (expr.startswith("'") and expr.endswith("'")) or (
expr.startswith('"') and expr.endswith('"')
):
# String literal — only when the WHOLE expression is one quoted string,
# i.e. the opening quote's matching close is the final character. Checking
# startswith/endswith alone would also grab `'a' == 'b'` and strip it to the
# garbage `a' == 'b`; a genuine single literal short-circuits here so quoted
# strings containing `|` or operator keywords are not mis-parsed downstream.
if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1:
return expr[1:-1]
# Handle pipe filters
if "|" in expr:
parts = expr.split("|", 1)
value = _evaluate_simple_expression(parts[0].strip(), namespace)
filter_expr = parts[1].strip()
# Handle pipe filters. Detect the pipe at the top level only, so a literal
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
# not mistaken for a filter separator — mirroring the operator parsing below.
pipe_idx = _find_top_level(expr, "|")
if pipe_idx != -1:
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
filter_expr = expr[pipe_idx + 1:].strip()
# `from_json` is strict: it takes no arguments and tolerates no
# trailing tokens. Match on the leading filter name and require the
@@ -262,29 +294,33 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
)
# Boolean operators — parse 'or' first (lower precedence) so that
# 'a or b and c' is evaluated as 'a or (b and c)'.
if " or " in expr:
parts = expr.split(" or ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket
# aware so a keyword inside a quoted operand (e.g. the 'and' in
# 'read and write') is not mistaken for an operator.
or_idx = _find_top_level(expr, " or ")
if or_idx != -1:
left = _evaluate_simple_expression(expr[:or_idx].strip(), namespace)
right = _evaluate_simple_expression(expr[or_idx + 4:].strip(), namespace)
return bool(left) or bool(right)
if " and " in expr:
parts = expr.split(" and ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
and_idx = _find_top_level(expr, " and ")
if and_idx != -1:
left = _evaluate_simple_expression(expr[:and_idx].strip(), namespace)
right = _evaluate_simple_expression(expr[and_idx + 5:].strip(), namespace)
return bool(left) and bool(right)
if expr.startswith("not "):
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
return not bool(inner)
# Comparison operators (order matters — check multi-char ops first)
# Comparison operators (order matters — check multi-char ops first). Split at
# the first top-level occurrence so an operator inside a quoted operand is
# ignored.
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
if op in expr:
parts = expr.split(op, 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
op_idx = _find_top_level(expr, op)
if op_idx != -1:
left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace)
right = _evaluate_simple_expression(expr[op_idx + len(op):].strip(), namespace)
if op == "==":
return left == right
if op == "!=":

View File

@@ -194,7 +194,14 @@ class GateStep(StepBase):
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if on_reject in ("abort", "retry") and isinstance(options, list):
# Only inspect option text when every option is a string; otherwise the
# `o.lower()` below would raise AttributeError on a non-string option
# (already reported above) and break validate_workflow's never-raise contract.
if (
on_reject in ("abort", "retry")
and isinstance(options, list)
and all(isinstance(o, str) for o in options)
):
reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):
errors.append(

View File

@@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
@@ -228,6 +229,7 @@ After reporting, check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles

View File

@@ -66,6 +66,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Execution Steps.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
@@ -363,4 +364,5 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -251,6 +252,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -147,4 +148,5 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
@@ -266,5 +267,6 @@ After producing the result, check if `.specify/extensions.yml` exists in the pro
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -192,6 +193,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -53,6 +53,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -91,6 +92,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
@@ -154,14 +156,11 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
4. **Agent context update**:
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
**Output**: data-model.md, /contracts/*, quickstart.md
## Key rules
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
- ERROR on gate failures or unresolved clarifications
## Done When

View File

@@ -50,6 +50,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -253,6 +254,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

Some files were not shown because too many files have changed in this diff Show More