Compare commits

...
Author SHA1 Message Date
github-actions[bot] 4871b485f9 chore: bump version to 0.16.2 2026-08-10 19:45:44 +00:00
6aa9431b24 Add Command Code integration to spec-kit (#4019)
* Add Command Code integration to spec-kit

Adds `command-code` as a built-in skills-based integration so Spec Kit can
be installed into Command Code. Command Code loads agent skills from
`.commandcode/skills/speckit-<name>/SKILL.md` and invokes them in chat as
`$speckit-<command>`.

- New `CommandCodeIntegration` (SkillsIntegration) writing to
  `.commandcode/skills/`; declared multi-install safe (static, isolated
  agent root).
- Register in `_register_builtins()` and the integration catalog.
- Add `command-code` to `DOLLAR_SKILLS_AGENTS` so next-steps guidance
  renders `$speckit-*` invocations.
- Tests: reuse `SkillsIntegrationTests` mixin plus a dollar-invocation
  next-steps test; registry completeness updated.
- Docs: README and docs/reference/integrations.md (supported agents +
  multi-install-safe table).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Assisted-by: Command Code (autonomous)

* Fix issue template agent lists to include command-code

The runtime AGENT_CONFIG now includes command-code, but the GitHub issue
templates and the consistency test's expected key list were not updated,
failing test_issue_template_agent_lists_match_runtime_integrations.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Assisted-by: Command Code (autonomous)

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-10 14:44:20 -05:00
1b3695bfb3 fix(workflows): strip a resolved condition before the true/false check (#3883)
evaluate_condition() special-cases the strings "false"/"true" so that
`condition: "false"` behaves as a boolean, but it matches with
`result.lower()` and never strips.

The most common way a *string* reaches a condition is captured command
output, and the shell step stores stdout verbatim
(steps/shell/__init__.py:67 `"stdout": proc.stdout`). So `run: echo false`
resolves to "false\n", which matches neither branch and falls through to
`bool("false\n")` -> True:

  'false'     -> False
  'false\n'   -> True    <-- bug
  'false\r\n' -> True    <-- bug
  ' false'    -> True    <-- bug

An `if` step therefore takes its `then` branch on a step that printed
"false", and `while`/`do-while` keep dispatching their body.

A workflow author cannot work around it: the registered filters are
default/join/map/contains/from_json — there is no `trim`.

`InitStep._resolve_bool` and both catalog readers already strip before
matching boolean text. `bool(result)` still sees the raw string, so no
non-boolean text changes truthiness.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:20:47 -05:00
3451a21277 fix(workflows): guard a non-string overlay edit 'operation' (#3881)
`_parse_edit` reads `operation` straight from hand-edited YAML and then
does `if operation not in VALID_OPERATIONS`. `VALID_OPERATIONS` is a
frozenset, so that membership test hashes the value — and an unhashable
one raises:

  operation={'insert_after': 'a'} -> TypeError: unhashable type: 'dict'
  operation=['insert_after']      -> TypeError: unhashable type: 'list'

`validate_overlay_yaml`'s docstring promises "validation never raises",
and nothing upstream catches TypeError (layer_sources wraps only
YAMLError/OSError/UnicodeDecodeError; _commands catches only ValueError),
so the CLI dies with a raw traceback instead of reporting the error.

The trigger is an ordinary authoring mistake: nesting the recommended
shorthand form under the explicit key.

Every other field in the same function is isinstance-guarded first
(`anchor`, `step`, `step["id"]`); `operation` was the outlier. Check the
type first and return the message the function already uses for
`operation: None` / `operation: 7`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:19:50 -05:00
Quratulain-bilalandGitHub fd8cc6da22 fix: bound response read in integration catalog fetch (#3818) 2026-08-10 13:18:36 -05:00
2df78f33fb Fix bug-test Python dependency provisioning (#4030)
* fix: provision Python test deps for bug-test workflow

* test: anchor bug-test workflow domain assertions

Address CodeQL py/incomplete-url-substring-sanitization alerts (14-17)
by anchoring the PyPI domain assertions to their structural context:
the `network.allowed` YAML list items in the source and the quoted JSON
entries in the compiled lock. This defeats the incomplete-URL-substring
pattern and strengthens the test to confirm the domains are real
allowlist entries rather than incidental substrings.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5

* fix: provision test deps without creating a project lock

Replace `uv sync --extra test` with `uv pip install --system -e ".[test]"`
in the bug-test provisioning step.

`uv sync` writes a root `uv.lock` (and `.venv`) into the working tree.
This repository intentionally has no `uv.lock`/`[tool.uv]` (uv.lock is
gitignored), so the sync produced an untracked lockfile before the agent
checks out the fix ref in Step 2. `uv pip install` installs the test
extra into the runner's Python without generating a project lock, keeping
the working tree clean before the fix checkout. The editable install
means the agent's `python3 -m pytest` runs against the checked-out fix
code. Recompiled the lock and updated the assertions accordingly.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5

* chore(workflows): sync gh-aw action-pin metadata to latest across all workflows

Dependabot bumps the third-party action `uses:` pins (and header comments)
directly, but does not update gh-aw's own metadata: the per-file
`gh-aw-manifest` JSON blob and the shared `.github/aw/actions-lock.json`
pin cache. As a result the executing pins were already uniform and current
(checkout v7.0.1, setup-node v7.0.0) while the manifest/cache metadata still
recorded checkout v6.0.3 / setup-node v6.4.0.

This is a latent downgrade hazard: a plain `gh aw compile` reads the stale
cache and can silently revert the `uses:` lines back to the older pins,
undoing Dependabot's bumps and breaking lockstep.

Sync all four pin surfaces (uses / header comment / manifest / cache) to the
current pins so every workflow agrees and a future recompile is a no-op:
- actions-lock.json: checkout v6.0.3 -> v7.0.1, setup-node v6.4.0 -> v7.0.0,
  and add the setup-python v7.0.0 + setup-uv v9.0.0 entries now used by
  bug-test.
- gh-aw-manifest blobs in the 5 non-bug-test lock files: checkout + setup-node
  bumped to match their own uses lines (bug-test was already current).

No workflow body changes; only pin metadata. `uses:` pins are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)

---------

Co-authored-by: root <kinsonnee@gmail.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5
2026-08-10 13:07:50 -05:00
36da77f864 fix(bundle): escape Rich markup in bundle CLI error and status output (#4023)
* fix(bundle): escape Rich markup in bundle CLI error and status output

`specify bundle`'s `_fail` helper interpolated its message straight into
`err_console.print`, which has Rich markup enabled. Every caller passes
`str(exc)` from a `BundlerError`, and those messages embed untrusted data
-- including the command's own argument -- so a `[...]` in it was parsed
as a style tag.

Balanced tags were silently swallowed; an unbalanced closer raised
`MarkupError`, which replaced the error message with a traceback and left
the output completely empty. Three commands crashed on user input alone,
with no project state required:

    specify bundle catalog add 'ssh://ex[/red]ample.com/c.json'
    specify bundle catalog remove 'no[/red]such'
    specify bundle update 'no[/red]such'

`bundle validate` had the same failure on both branches: its errors echo
`requires.speckit_version`, and its warnings echo component ids, which are
not charset-validated -- so a structurally *valid* manifest crashed on the
success path too.

Fixed centrally in `_fail`, plus the remaining raw interpolations: the
`validate` warning/error/success lines, the install overlap and plan
warnings, the install/update/remove/catalog-add confirmations, the
`catalog list` id/url, and the `bundle init` project path.

Regression tests cover the four crashing error paths (parametrized) and
both `validate` branches; all six fail without this change.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bundle): escape markup in `bundle list` records and `bundle build` output path

Review follow-up: two raw interpolations the first sweep missed, both on
success paths rather than error paths.

`bundle_list` rendered `record.bundle_id`, `record.version` and
`record.installed_at` unescaped. `InstalledBundleRecord.from_dict` only
requires non-empty strings for the first two and applies no charset check to
any of them, so a records file that *loads cleanly* still crashed the command
that displays it — confirmed as
`MarkupError: closing tag '[/red]' at position 12 doesn't match any open tag`.

`bundle_build` echoed `result.artifact_path` twice in its success line.
Brackets are legal in a directory name, so a bracketed `--output` built the
artifact and then misreported it: the work is already on disk when the
markup is consumed, so the line names a path that does not exist.

Re-scanned every `{...}` interpolation in the module to confirm nothing else
remains: the rest are either `BundlerError` messages that funnel through the
already-escaped `_fail`, `_format_component` output escaped at its call site
(:293), ints, or hardcoded enum `.value`s.

Two regression tests. The list case uses the unbalanced-closer form that
raises outright. The build case deliberately uses `[bold]` instead: `/` is a
path separator on Windows, so `dist[/red]out` becomes the directory
`dist[\red]out` and the fixture stops testing what it claims — the
silent-swallow form keeps it portable while still asserting the reported path
matches what was written. Verified both fail against 1d2184d.

tests/contract/test_bundle_cli.py -> 42 passed. tests/contract
tests/integration tests/unit -> 364 passed, 6 skipped, 5 failed; the 5 are
the pre-existing `*_refuses_symlinked_*` tests needing symlink privileges on
Windows, unchanged from main. ruff check passes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:34:02 -05:00
1a44a6aa08 fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove`

`_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core
command template or an extension source. Both of those reads were bare
`read_text(encoding="utf-8")` calls, so a project-owned override in
`.specify/templates/commands/` that exists but cannot be read or decoded
raised a raw `UnicodeDecodeError`/`OSError` straight out of
`PresetManager.remove()`, which has no handler for it — `specify preset
remove` dies with a traceback.

Every other failure in this loop degrades with `continue`: an unsafe
registry name, a missing skill subdirectory, a foreign owner. Sibling reads
of the very same directory are already guarded — `_infer_legacy_skill_
provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read
in `except (OSError, UnicodeDecodeError): continue`, and the read inside
`_substitute_core_template` was just given the same boundary in #3961. The
two restore reads were the remaining gap.

`continue` is the right recovery here rather than falling through: the
`else` branch below removes the skill outright, so treating an unreadable
source as "no source" would delete a user's skill at exactly the moment its
replacement cannot be generated. Skipping leaves the skill in place and
keeps it out of the returned `mutated_names`, so callers don't record a
restore that never happened.

Two regression tests, one per exception arm: a non-UTF-8 core template, and
a mocked `PermissionError` so the `OSError` half is also covered under
privileged CI where permission bits aren't enforced. Both assert the skill
survives untouched and is not reported as mutated.

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

* fix(presets): warn when a skill keeps preset content after a failed restore

Review follow-up on two points.

Surface the skipped restore. Skipping is still the correct recovery — the
alternative branch deletes the skill — but it was silent, and it is a
partial removal: `remove()` goes on to delete the preset directory and the
registry entry, while this `SKILL.md` keeps the removed preset's content,
and leaving the name out of `mutated_names` also keeps it out of
reconciliation, so nothing retries it. Both arms now emit a warning naming
the skill, the unreadable source, and the exception, and pointing at the
re-run that refreshes it once the file is fixed. `warnings.warn` matches
how the surrounding code reports non-fatal degradation (the reconciliation
failures in `remove()`/`install_from_directory`, the unreadable core
template in `_substitute_core_template` from #3961).

Cover the extension arm. A skill backed by an installed extension never
reaches the core-template read, so the two branches can regress
independently and both prior tests exercised only the core one.
`test_unregister_skills_in_dir_unreadable_extension_source_skips` installs
an extension whose command file is non-UTF-8 and asserts the skill survives
byte-for-byte and is absent from `mutated_names`. Verified it raises the
raw `UnicodeDecodeError` against unpatched source. The two existing tests
now assert the warning via `pytest.warns` so dropping it fails the suite.

pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are
the pre-existing Windows symlink tests that need elevation, unchanged from
main. ruff check passes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:26:07 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
f0ee1fc419 Add Keel Discovery extension to community catalog (#4035)
Add keel extension submitted by @athulrajeev to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #4026

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-08-10 12:23:10 -05:00
Quratulain-bilalandGitHub 44c3dc7d3e fix: show error details in preset catalog config read failure (#3840)
Capture and display the exception message when reading preset-catalogs.yml
fails, instead of swallowing the error details. Matches the pattern used
in preset_catalog_add 54 lines earlier.
2026-08-10 12:19:36 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
2100f59f7e Update Reconcile Extension to v1.1.0 (#4034)
Update reconcile extension submitted by @stn1slv:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table

Closes #4024

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-08-10 12:13:19 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
a2c7ff29db Add Model Routing Governance preset to community catalog (#4033)
Add model-routing-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #4021

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-08-10 12:11:20 -05:00
Quratulain-bilalandGitHub ea1f5b8a2d fix: use missing_ok=True in integration JSON removal (#3846)
Replace check-then-act pattern (exists()+unlink()) with unlink(missing_ok=True)
to eliminate TOCTOU race condition.
2026-08-10 11:08:29 -05:00
Quratulain-bilalandGitHub 29efcbe2c4 fix: use missing_ok=True in extension cache clear (#3845)
Replace check-then-act pattern (exists()+unlink()) with unlink(missing_ok=True)
to eliminate TOCTOU race condition. Matches the pattern already used for
per-URL cache files in the same method.
2026-08-10 11:07:11 -05:00
chelsealongandGitHub 11e3176fd1 fix(extensions): reject duplicate provides.templates/scripts names (#4016)
The resolver returns the first entry matching a declared name, so a
later duplicate within provides.templates or provides.scripts was
silently unreachable while still counted by ExtensionManifest
properties. Reject duplicates at manifest-validation time instead.

Also clarify EXTENSION-DEVELOPMENT-GUIDE.md's provides section: hooks
and events are top-level manifest fields, not provides sub-fields, so
the "at least one of ..." wording doesn't imply they can be nested
under provides.
2026-08-10 10:23:23 -05:00
16cfab7724 feat(presets): resolve constitution templates at command time (#3984)
* feat(presets): resolve constitutions at command time

Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): emit composed template content

Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): unify runtime template composition

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): secure runtime template resolution

Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* fix(presets): align runtime priority semantics

Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* fix(presets): stop at effective template base

Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* fix(presets): align extension template resolution

Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* fix(presets): resolve dotted command identifiers

Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* Avoid orphan feature directories on template errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

* Align malformed preset manifest handling

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Complete runtime resolver parity

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on resolver input errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Force UTF-8 and full manifest validation

Force UTF-8 decoding for registry and manifest reads in the Bash and
PowerShell embedded-Python parsers so resolution no longer depends on the
process locale, and validate every manifest template entry's required
fields, type, and strategy consistent with the canonical PresetManifest.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on empty manifests and corrupt registries

Reject manifests missing the provides/templates sections or declaring an
empty template list in all three runtime resolvers, matching the canonical
PresetManifest which treats those as invalid instead of silently degrading a
composing layer to a convention `replace` lookup.

Make a corrupt or unreadable extension registry fail closed in Bash,
PowerShell, and Python instead of swallowing the error and treating every
on-disk extension directory as unregistered-and-enabled, which could activate
a disabled extension.

Read the preset and extension registries as explicit UTF-8 in the PowerShell
resolver so priority/enabled-state decoding no longer depends on the process
code page under Windows PowerShell 5.1.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed when extension registry is not a regular file

The Bash and Python resolvers used is_file()/`-f` to gate reading the
extension `.registry`, which returns false for a directory or a broken
symlink at that path. In those cases the resolvers treated the registry
as absent and scanned every on-disk extension directory as unregistered
and enabled — a fail-open path. Detect any filesystem entry at the
registry path (including broken symlinks) and reject unless it is a
readable regular file. PowerShell now rejects a non-leaf entry explicitly
for parity. Adds directory- and broken-symlink parity regressions.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell

Two remaining fail-open paths for an invalid extension registry:

- The canonical PresetResolver enumerated extensions through
  ExtensionRegistry, whose _load() normalizes a corrupt or unreadable
  registry to an empty mapping. The directory scan then admitted every
  on-disk extension directory as unregistered-and-enabled, so a corrupt
  registry could still supply constitution content at init and through
  constitution-sync materialization. Add a non-invasive is_corrupt()
  probe (recovery behavior for install/enable/disable is unchanged) and
  raise from _get_all_extensions_by_priority() when the registry exists
  but is invalid. _load() now also recovers from OSError/UnicodeDecodeError
  so a directory or unreadable registry no longer crashes construction.

- The PowerShell resolver gated the registry read with Test-Path, which
  returns false for a dangling symlink on Windows, letting a broken
  .registry symlink bypass the guard and enable every on-disk extension.
  Detect the entry via directory enumeration (which observes a broken
  symlink) and reject it unless it is a readable regular file.

Adds canonical corrupt/directory-registry regressions and extends the
broken-symlink parity test to PowerShell.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt

is_corrupt() gated on Path.exists(), which follows symlinks and returns
False for a dangling .registry symlink — so the canonical PresetResolver
treated it as an absent registry and fell back to scanning every on-disk
extension directory as unregistered-and-enabled, reopening the fail-open
path this guard closes. Detect lexical existence with os.path.lexists and
require a regular file before parsing, so a broken symlink (or directory)
is reported corrupt and resolution fails closed. Adds a canonical
broken-symlink regression alongside the directory case.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
2026-08-10 10:22:31 -05:00
github-actions[bot]GitHubCopilot Appgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Manfred Riem
1a60d1b6b8 [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
* Fix preset-wrap-drops-argument-hint: inherit argument-hint from core

Apply the remediation from the bug assessment on issue #3991.
Extend the inheritance allowlist in _register_skills and _compose_layers
to include 'argument-hint', so wrap-strategy presets that omit this key
will inherit it from the core template rather than silently dropping it
and risking its value being leaked into description.

Refs #3991

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

* test(presets): guard wrap argument-hint inheritance for unmapped command

The existing regression test for #3991 wraps `speckit.specify`, whose stem
is in Claude's ARGUMENT_HINTS map. The string-injection fallback in
post_process_skill_content re-adds argument-hint even when wrap composition
drops it, so that test passes with or without the inheritance fix and does
not actually guard the regression.

Add a parallel test that wraps an extension-like command
(`speckit.myfeature`) absent from ARGUMENT_HINTS, so the wrap-composition
inheritance is the only path that can carry argument-hint into the SKILL.md.
This test fails without the fix and passes with it.

Refs #3991

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce
2026-08-10 10:19:09 -05:00
d751231468 docs: document installing specify-cli from a custom package index (#4032)
* docs: document installing specify-cli from a custom package index

Add a generic section to the PyPI install guide covering how to point
uv, pipx, and pip at a non-default package index (env var and flags),
with a placeholder URL, plus notes on pins/upgrades and authentication.
Link to it from the main installation guide.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: d6ef3f75-54e9-4789-902b-4f0adeaadfad
2026-08-10 08:15:14 -05:00
chelsealongandGitHub 684b3d8e05 feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
* feat(extensions): accept provides.templates and provides.scripts in manifest

Extensions could only formally declare commands under `provides`
(plus config/hooks/events); templates and scripts shipped by an
extension were picked up purely by filename convention, with no id,
description, or metadata. Add optional `provides.templates` and
`provides.scripts` sections to the extension manifest schema, mirroring
the preset template shape minus an authorable `strategy` (extension
artifacts always resolve as replace, so a present `strategy` key is
now a validation error rather than a silently accepted no-op).

ExtensionManifest gains `templates`/`scripts` properties so tooling can
enumerate an extension's declared artifacts directly from the
manifest. An extension may now satisfy the "must provide something"
rule with only a template or script, not just a command/hook/event.

Addresses the manifest-schema portion of #4010; resolver
authoritative-vs-convention precedence for these new sections is left
for a follow-up.

* fix(presets): wire extension-declared templates/scripts into resolver

collect_all_layers only consulted ExtensionManifest for command
resolution, leaving provides.templates/.scripts purely decorative --
a declared entry whose file didn't sit at the conventional path was
validated but never resolved. Extend the existing manifest-fallback
branch to cover template_type "template" and "script" the same way
it already does "command": convention lookup first, manifest lookup
as fallback so undeclared on-disk files keep resolving unchanged.

* fix(presets): make extension manifest lookup authoritative over convention

Copilot review on #4012 found the manifest-declared template/script lookup
was gated on convention lookup missing first, so a stale conventional file
could shadow a declared entry at a non-conventional path, and resolve()
never consulted the manifest at all (only collect_all_layers() did). Add a
shared _extension_manifest_declared_template() helper and check it before
convention-based lookup in both resolve() and collect_all_layers(), mirroring
the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md,
which still claimed provides only supports commands and required a command
or hook.

* fix(presets): stop resolving symlinks in extension manifest candidate path

_extension_manifest_declared_template() resolved ext_dir/rel_path before
returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's
symlinked tmp dir) and diverges from the unresolved paths convention-based
lookup returns for the same directory. Resolve only for the traversal
containment check; return the unresolved candidate.

Fixes the 4 CI test failures across all OS/Python matrix jobs on #4012.
2026-08-07 12:55:55 -05:00
247abbf5e4 fix(presets): treat an unreadable core template as missing (#3961)
* fix(presets): treat an unreadable core template as missing

_substitute_core_template() read the resolved core template with a bare
read_text(), so one corrupted project-owned override in
.specify/templates/commands/ crashed the whole wrap-strategy command
registration with a raw UnicodeDecodeError. Both callers
(CommandRegistrar.register_pack and _register_commands) are unguarded
here, even though register_pack already skips an unreadable preset
source with a warning a few lines above the call.

Treat an unreadable core template like a missing one — warn and return
the body unchanged with empty frontmatter — matching the function's
documented no-core contract.

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

* test: assert the unreadable-core warning instead of suppressing it

Review follow-up: use pytest.warns so removing or changing the promised
warning fails the test.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 12:49:58 -05:00
Manfred RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
6da6956c3e chore: release 0.16.1, begin 0.16.2.dev0 development (#4014)
* chore: bump version to 0.16.1

* chore: begin 0.16.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 12:31:44 -05:00
2a28f62e25 fix(integrations): wrap a non-UTF-8 catalog response (#4011)
* fix(integrations): wrap a non-UTF-8 catalog response

`_fetch_single_catalog` decodes the response body with `.decode("utf-8")`
before handing it to `json.loads`. A non-UTF-8 body therefore raises
`UnicodeDecodeError`, which is a sibling of `json.JSONDecodeError` under
`ValueError` rather than a subclass of it, so neither the `URLError` nor the
`JSONDecodeError` handler catches it.

The raw exception escapes `_get_merged_integrations`, whose
`except IntegrationCatalogError` is specifically designed to warn and skip a
bad catalog and carry on with the remaining ones. One catalog served over a
misconfigured proxy or truncated mid-multibyte-sequence thus takes down
`specify integration search` entirely instead of degrading to a warning.

Wrap it in `IntegrationCatalogError`, matching the convention already used
for the same decode in `authentication/azure_devops.py`, which lists
`UnicodeDecodeError` alongside `JSONDecodeError`.

Note that the cache-read path in this same method already tolerates this via
its `UnicodeError` clause; only the network path was unguarded.

Two regression tests: one pins the wrapped-error contract on the fetch, and
one covers the behaviour that actually motivates it — a broken catalog is
skipped with a warning while a healthy sibling catalog still resolves.

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

* test(integrations): use the shared urlopen routing fixture

The raw-bytes helper patched `open_url` wholesale, which skipped the real
URL validation and redirect handling inside it. This module already imports
`route_opener_open_through_urlopen`, the repo's shared fixture that routes
`build_opener().open()` back through `urlopen` for exactly this reason, so
patching `urlopen` instead keeps the stub effective while still exercising
`open_url` itself.

Renamed to `_patch_urlopen_bytes` to sit alongside the existing
`_patch_urlopen`, whose signature it now mirrors.

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

* fix(integrations): restore the non-UTF-8 handler

The previous commit reverted the source change by accident while reworking
the tests, leaving the regression tests passing against an unfixed module.
Restores the `except UnicodeDecodeError` clause.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:17:46 -05:00
5d9ac6a3f5 fix(events): skip an unreadable command template (#3956)
* fix(events): skip an unreadable command template

_render_command_template() read the resolved template with a bare
read_text(), so a template file that exists but cannot be read or
decoded (permission error, non-UTF-8 bytes) crashed event dispatch with
a raw OSError/UnicodeDecodeError. Every sibling failure in this path
(missing template, unresolvable command) already returns None so the
dispatcher falls back cleanly.

Wrap the read and return None on OSError/UnicodeDecodeError, matching
the sibling contract.

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

* test: cover the OSError half of the unreadable-template boundary

Review follow-up: add a mocked PermissionError case so both promised
exception paths are protected under privileged CI.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 12:16:09 -05:00
920ed7546d fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
`_local_manifest_source` handles three local bundle sources. The directory
and `bundle.yml` branches both go through `BundleManifest.from_file` ->
`load_yaml`, which converts a parse failure into a `BundlerError`. The `.zip`
branch instead parses inline with a bare `_yaml.safe_load`.

`yaml.YAMLError` derives directly from `Exception` -- it is neither a
`ValueError` nor an `OSError` -- so it escapes `bundle_install`'s
`except BundlerError` and reaches the user as a raw
`yaml.parser.ParserError` traceback.

The remote counterpart of this same call, `_download_manifest`, already
guards it and even names `_yaml.YAMLError` explicitly. Only the local zip
path was missed, so the same corrupt manifest is reported cleanly when
fetched from a catalog but crashes when installed from disk.

Before, for the identical malformed bundle.yml:

    specify bundle install ./bundle-dir   ->  Error: Invalid YAML in ... (exit 1)
    specify bundle install ./bundle.yml   ->  Error: Invalid YAML in ... (exit 1)
    specify bundle install ./bundle.zip   ->  ParserError traceback

Two regression tests: one pins the `BundlerError` contract on the zip
branch, and one drives all three local sources through the CLI to assert
they now fail alike.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:04:39 -05:00
dependabot[bot]GitHubCopilot Appdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Manfred Riem
8865e57344 chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
* chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5

Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.3 to 4.37.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...d1ba80a13dd99fba24a470575428917156a28b43)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump github/codeql-action/init to match analyze 4.37.5

Dependabot only bumped the analyze step; keep init on the same
4.37.5 SHA so both CodeQL steps use the same release.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 126ceec5-7d64-444c-8cd4-d60b225d46f5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 126ceec5-7d64-444c-8cd4-d60b225d46f5
2026-08-07 11:44:47 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d2befe4021 chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 24.1.0 to 24.2.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/6bf21b07787794f89a243495939cd651942aeabe...21c1be1b93ad9ed58fa840aacc3f279cde2a72ff)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: 24.2.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-08-07 10:16:39 -05:00
Quratulain-bilalandGitHub 81d5cdbbf2 fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
* fix(agent-context): recurse for nested plans in Python mtime fallback

The Python port's mtime fallback discovered plans with a one-level
specs/*/plan.md glob, so a scoped layout created via
SPECIFY_FEATURE_DIRECTORY (specs/<scope>/<feature>/plan.md) was missed when
feature.json is absent — the fallback returned no plan and the managed
context section omitted the 'at <plan>' line. The bash and PowerShell twins
were already fixed to recurse (#3024); the Python twin was left behind.

Switch to specs.rglob('plan.md') with the same symlink-safe containment check
the bash twin uses (resolve each candidate and confirm it stays within the
project root before ranking by mtime), so a plan reached through a specs/
symlink pointing outside the project is not selected.

Adds parity regression tests (vs bash and vs PowerShell) covering a nested
specs/<scope>/<feature>/plan.md; both fail on the pre-fix one-level glob.

Fixes #3733

* test(agent-context): cover symlink containment in the mtime fallback

The recursive fallback resolves each candidate before the relative_to()
containment check, but nothing exercised that path. Add a parity test for a
plan reachable only through a specs/ symlink pointing outside the project:
relative_to() is lexical and would accept it, emitting an in-project-looking
path for an out-of-project file. Both the bash twin and the Python port skip
it, so the "at <plan>" line is omitted.

Also correct the module docstring, which still described the fallback as
scanning specs/*/plan.md one level deep.
2026-08-06 14:32:35 -05:00
Quratulain-bilalandGitHub adb2413ab6 fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
Both extension and preset registry read/write calls used platform-default
encoding, which on Windows (cp1252/UTF-16) would corrupt UTF-8 JSON data
or raise UnicodeDecodeError. Explicitly specify encoding='utf-8' to match
the JSON contract.

Assisted-by: opencode (autonomous)
2026-08-06 10:45:29 -05:00
36a33555bc fix(init): escape user-supplied values in specify init output (#3787)
* fix(init): escape user-supplied values in `specify init` output

commands/init.py interpolated the project name, --integration/--script values
and paths straight into Rich markup f-strings. It was the only CLI command
module without escaping -- extensions, presets, workflows and integrations all
wrap user-controlled display values already.

Two consequences, both reproduced end-to-end through the real CLI:

1. SILENT WRONG OUTPUT. `specify init "proj [v2]"` exits 0 and creates the
   directory, but the Next Steps panel prints

       1. Go to the project folder: cd proj

   Rich ate `[v2]` as a style tag, so the command the user copy-pastes fails.

2. CRASH AFTER SUCCESS. `specify init "app[/red]x"` creates the project and
   then dies with MarkupError("closing tag '[/red]' ... doesn't match any open
   tag") -> exit 1 with a traceback for work that actually completed.

Wrap the user-controlled display values in rich.markup.escape: project name
(error/warning/conflict/next-steps), project and working paths, the echoed
--integration and --script values, and the agent folder in the gitignore hint.
Display only -- no control flow, exit codes or messages change, and escape is a
no-op for any value without a tag-shaped bracket run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(init): shell-quote the project name in the Next Steps cd line

Rich-escaping stopped the brackets being swallowed, but the printed
command was still unusable for any name containing whitespace: `cd proj
v2` is two arguments in every shell.

  $ cd proj v2      -> /bin/bash: line 1: cd: too many arguments (rc=1)
  $ cd "proj v2"    -> rc=0, lands in "proj v2"

Quote it for the host the same way _version._render_argv renders its
copy-pasteable installer command: subprocess.list2cmdline on Windows,
shlex.quote elsewhere. Windows must use double quotes -- cd 'my project'
is a path-not-found in cmd.exe, while cd "my project" is accepted by
cmd.exe, PowerShell and Git Bash alike. Names needing no quoting are
returned unchanged, so the common case is byte-identical.

Shell-quote inner, Rich-escape outer.

Tests execute the printed command through a real shell rather than only
inspecting the string, and pin that an ordinary name stays unquoted.

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

* fix(init): drop the now-redundant local escape imports that shadowed the module one

Rebasing onto main brought in three new extension-install helpers, and two
of them carry a function-local

    from rich.markup import escape as _escape_markup

inside `register > init`. This PR adds the same import at module level, so
the locals made `_escape_markup` a local variable for the whole `init`
function — every use *before* those import lines then raised

    UnboundLocalError: cannot access local variable '_escape_markup'
    where it is not associated with a value

which broke `specify init` outright (7 of 8 tests in this file failed after
the rebase, all with exit_code 1).

The locals are redundant now that the module-level import exists, so remove
them. Verified with an AST scope walk that the only remaining
`_escape_markup` imports are the module-level one and the one inside
`_confirm_extension_url_trust`, which has no module-level use to shadow.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:21:32 -05:00
Quratulain-bilalandGitHub f71cfafa71 fix: bound response read in integration catalog fetch (#3812)
* fix: bound response read in integration catalog fetch

* fix: address review - update FakeResponse for bounded reads and add regression test

- Update FakeResponse.read() to accept size parameter for bounded reads
- Add test_fetch_rejects_oversized_catalog_response regression test
- Verifies _fetch_single_catalog uses MAX_JSON_METADATA_BYTES

Fixes #3812

* fix: resolve lint errors and update FakeResponse to support bounded reads

- Remove duplicate imports of MAX_JSON_METADATA_BYTES and read_response_limited
- Update FakeResponse.read() to accept size argument for read_response_limited
- Add offset tracking for proper bounded read behavior

Refs: #3812
2026-08-06 10:21:02 -05:00
Quratulain-bilalandGitHub 4a465431b8 fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
* fix(agent-context): recurse for nested plans in Python mtime fallback

The Python port's mtime fallback discovered plans with a one-level
specs/*/plan.md glob, so a scoped layout created via
SPECIFY_FEATURE_DIRECTORY (specs/<scope>/<feature>/plan.md) was missed when
feature.json is absent — the fallback returned no plan and the managed
context section omitted the 'at <plan>' line. The bash and PowerShell twins
were already fixed to recurse (#3024); the Python twin was left behind.

Switch to specs.rglob('plan.md') with the same symlink-safe containment check
the bash twin uses (resolve each candidate and confirm it stays within the
project root before ranking by mtime), so a plan reached through a specs/
symlink pointing outside the project is not selected.

Adds parity regression tests (vs bash and vs PowerShell) covering a nested
specs/<scope>/<feature>/plan.md; both fail on the pre-fix one-level glob.

Fixes #3733

* test(agent-context): cover symlink containment in the mtime fallback

The recursive fallback resolves each candidate before the relative_to()
containment check, but nothing exercised that path. Add a parity test for a
plan reachable only through a specs/ symlink pointing outside the project:
relative_to() is lexical and would accept it, emitting an in-project-looking
path for an out-of-project file. Both the bash twin and the Python port skip
it, so the "at <plan>" line is omitted.

Also correct the module docstring, which still described the fallback as
scanning specs/*/plan.md one level deep.

* fix: use missing_ok for temp file cleanup to avoid masking errors
2026-08-06 09:58:19 -05:00
204d94fdb1 fix(workflows): handle an unreadable run state in workflow status (#3999)
`workflow status <run_id>` and `workflow resume <run_id>` both call
`RunState.load()`, and a prior fix aligned them on the FileNotFoundError
and ValueError boundaries. `resume` also handles OSError; `status` never
gained that handler.

So an unreadable `state.json` -- wrong permissions, an I/O error, or a
directory sitting where the file belongs -- escapes as a raw traceback
with no output at all, while `resume` on the same run prints a clean
`Error:` line and exits 1. `state_path.exists()` is True for a directory,
so the existing guard passes and `open()` raises.

Add the missing `except OSError` next to its siblings, using the same
`_escape_markup` + `typer.Exit(1)` shape, and routing through `err` so
the message lands on stderr under `--json` and the stdout JSON stream
stays parseable.

Two regression tests: the end-to-end CLI path (a directory in place of
state.json) and the `--json` stderr-routing path. Both fail without the
source change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 09:12:45 -05:00
40037b1aca feat(init): scaffold managed .specify/.gitignore (#4000)
* feat(init): scaffold managed .specify/.gitignore

Write a manifest-tracked `.specify/.gitignore` during shared-infra install
so machine-local Spec Kit state stays out of version control while everything
else under `.specify/` remains shareable:

- `feature.json` — the current-feature pointer, rewritten on every feature
  switch (per-checkout state, not something to share).
- `extensions/*/local-config.yml` — per-machine extension config overrides.

The file is routed through the same overwrite/skip/preserve policy as shared
templates: `--force` refreshes it, user edits are preserved on re-init, and
uninstall removes it via the manifest. Addresses github/spec-kit#2304.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* docs: correct .specify/.gitignore uninstall claim

The file is tracked in the shared-infra manifest (speckit.manifest.json),
not the per-integration manifest that `specify integration uninstall` loads.
Shared infrastructure is deliberately preserved on uninstall
(see test_uninstall_preserves_shared_infra), so `.specify/.gitignore` is
left in place rather than removed. Reword the code comment and core.md note
to state the actual behavior; keep the true benefits (force-refresh and
preserve-on-edit).

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* revert: drop manual CHANGELOG.md edit

CHANGELOG.md is auto-generated; do not hand-edit it.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9

* test: add .specify/.gitignore to integration file inventories

The complete-file-inventory tests assert an exact match of every file
produced by `specify init`. Now that shared infra scaffolds a managed
`.specify/.gitignore`, add it to the expected inventories so the exact-match
assertions pass on both sh and ps script types.

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 98faefd1-9fc8-48fc-bd25-d4f3ccbb2ab9
2026-08-06 09:08:09 -05:00
3dff6f1d50 fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
* fix(scripts): stop check-prerequisites text mode crashing on a legacy code page

_check_file/_check_dir hard-code U+2713/U+2717 and print() them to
sys.stdout. On Windows sys.stdout falls back to the ANSI code page whenever
stdout is not a console — which is every time an agent or a workflow step
captures the output — and U+2713 is unencodable in cp1252:

  stdout encoding: cp1252
  UnicodeEncodeError: 'charmap' codec can't encode character '✓'

So text mode aborted right after printing "AVAILABLE_DOCS:", losing every
per-document line.

Fall back to ASCII when stdout cannot encode the glyph. "[OK]"/"[FAIL]" is
the rendering these markers already have in-tree: Test-FileExists in
scripts/powershell/common.ps1 emits exactly those, and
normalize_status_text in tests/parity_helpers.py maps the glyphs onto them,
so the twins already treat the two forms as equivalent.

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

* test(scripts): cover both status markers in the cp1252 regression

Review catch: the fixture left every reported document absent (the empty
contracts/ also reports missing), so the test only ever called
_status_marker(False). The assertion was `"[OK]" in out or "[FAIL]" in out`,
which "[FAIL]" alone satisfied.

Proved the hole by mutation: replacing the fallback body with a bare
`return "[FAIL]"` — deleting the success branch outright — left the test
GREEN.

Add research.md so one document is present, and assert both markers
explicitly. The strengthened test now kills all three mutations:

  fallback always "[FAIL]"  -> FAILS (was passing)
  fallback always "[OK]"    -> FAILS
  no fallback at all        -> FAILS (the original bug)
  unmutated                 -> 12 passed, 8 skipped

Missing documents are still present in the fixture, so the failure path
stays covered too.

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

* fix(scripts): restore the _status_marker ASCII fallback

The previous commit on this branch unintentionally reverted the source fix
while adding the strengthened test, so the branch carried the test without
the implementation it tests.

Cause: my local verification script reverted the file for its red run with
`git checkout upstream/main -- <file>`, which writes the INDEX as well as
the working tree. Restoring the working-tree copy afterwards left main's
version staged, and the next commit captured it.

Restores the fix from 275663b. Verified: 12 passed / 8 skipped, and the red
run (source reverted) produces 1 new-vs-baseline failure.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 08:17:36 -05:00
fe3732e268 fix(presets): return None for an unreadable layer in resolve_content (#3959)
* fix(presets): return None for an unreadable layer in resolve_content

PresetResolver.resolve_content() reads the winning layer (and each
composition layer) with a bare read_text(), so a layer file that cannot
be read or decoded crashed command registration with a raw
OSError/UnicodeDecodeError. The docstring already promises 'Composed
content string, or None if not found', and since #3896
collect_all_layers() deliberately tolerates a non-UTF-8 legacy layer —
moving the crash here, where both callers (_register_commands and
_reconcile_composed_commands) are unguarded.

Return None when the winning or base layer cannot be read, treating an
unreadable layer like a missing one per the documented contract.

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

* test: cover the base guard and composing-layer read

Review follow-up: add an unreadable replace base beneath a valid
composing layer, and a mocked-PermissionError composing layer over a
valid base, so every new boundary and both exception types are covered.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 08:16:08 -05:00
3d4f71c90e fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
ExtensionRegistry._load() catches json.JSONDecodeError and
FileNotFoundError to start fresh on a corrupted or missing registry, but
a .registry file with invalid UTF-8 bytes raised UnicodeDecodeError from
the text-mode read before JSON parsing began. Because the registry is
loaded in __init__, that bare traceback broke every extension command --
`specify extension list` on such a project exits with a raw
UnicodeDecodeError instead of the module's clean path.

Catch UnicodeDecodeError in the same clause: undecodable bytes are the
same corruption class as unparseable JSON, only the exception type
differs. OSError stays uncaught on purpose -- the data may be intact on
disk, and starting fresh would let a later _save() wipe it.

This is the exact twin of the PresetRegistry._load() fix in #3955; the
two registries are parallel implementations and only the preset side was
corrected. _get_installed_sibling_ids() already worked around this gap
locally by catching UnicodeError at its own call site; its comment is
updated to reflect that _load() now handles the case itself, with the
local catch kept as belt-and-braces against regression.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 08:15:40 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
f31b2b45eb Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
Apply the remediation from the bug assessment on issue #3990.

After integration setup() and manifest.save(), when --force is used
(re-initializing an existing project), call _register_presets_for_agent
and _register_extensions_for_agent so that previously-installed presets
and extensions are recomposed on top of the freshly-regenerated core
files. Without this, preset-composed files reverted to pure core while
the preset registry continued to report them as installed.

This mirrors the same pattern already present in integration_upgrade()
(added in PR #3853 / issue #3849 for the upgrade path).

Refs #3990

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 11:23:18 -05:00
Quratulain-bilalandGitHub f8a448f0a9 fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
Hermes overrides SkillsIntegration.setup() with its own copy of the
frontmatter parse and body strip, and Kimi's _is_speckit_generated_skill()
parses frontmatter independently, so all three carried the same
split("---", 2) bug the base class just fixed. A description such as
"Separate sections with --- markers" truncates the parsed frontmatter at the
embedded marker, dropping later keys and spilling the remainder into the
body; for Kimi that means a Speckit-generated skill is no longer recognized
on teardown and gets left behind.

Scan for a closing "---" on its own line instead. The body slice keeps
whatever trails the marker so output stays byte-for-byte identical for
well-formed templates.
2026-08-05 11:07:10 -05:00
e9710ae45e fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
* fix(archives): wrap the bare EOFError a truncated tar.gz raises

`tarfile` wraps most decompression failures in `TarError`, but a gzip
stream that ends before its end-of-stream marker escapes as a bare
`EOFError` from the gzip layer. `EOFError` derives from neither
`TarError` nor `OSError`, so it bypassed all three of the tar handlers
added with tar archive support (#3874):

- the format probe in `detect_archive_format`, which caught only
  `tarfile.TarError`;
- `tarfile.open` in `safe_extract_tar`;
- member iteration in `safe_extract_tar`.

A truncated `.tar.gz` — an interrupted download, a partially written
file — therefore raised a raw `EOFError` straight through the caller's
`error_type`, so callers catching `ValueError`/`ExtensionError`/
`PresetError` never saw it. In `specify workflow add` the effect is worse
than a traceback: Typer treats a bare `EOFError` as a Ctrl-D abort, so
the command printed only "Aborted." with no diagnostic at all. The ZIP
twin reports "Invalid workflow archive: Invalid ZIP archive: <path>".

Route all three sites through a shared `_TAR_DECOMPRESSION_ERRORS`
tuple so they stay in sync. `zlib.error` is included alongside
`EOFError`: it is likewise neither a `TarError` nor an `OSError` and can
surface from a corrupt deflate block. `OSError` is kept only on the two
`safe_extract_tar` sites, which report genuine I/O failures; adding it
to the probe would silently swallow them instead.

Truncated tar.gz now reports the same clean, domain-typed error as the
ZIP path. Tests cover both the short prefix that fails in
`tarfile.open` and the longer ones that fail during member iteration —
`tarfile` decompresses lazily, so the leak surfaced at different sites
depending on how much of the stream survived.

Assisted-by: Claude Opus 5 (1M context)

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

* test(archives): cover the bare zlib.error a corrupt deflate block raises

Review feedback: the `zlib.error` arm of `_TAR_DECOMPRESSION_ERRORS` was
not exercised. Every regression added with the fix truncates a valid
deflate stream, which raises `EOFError`, so `zlib.error` could regress
independently of the EOF handling.

It is genuinely reachable, but only under a narrower condition than the
truncation cases. `tarfile` converts `zlib.error` to `ReadError` while
reading a member *header*, but the forward seek it performs to skip
member *data* (`tarfile.next`) sits outside that conversion, so a corrupt
region past the first header escapes raw. Reaching that seek needs
members larger than the gzip read buffer: with small members the whole
stream is decompressed during the first header read and the error is
wrapped. The new fixture therefore uses two 256 KiB members at
`compresslevel=1` — a ~7 KiB archive — corrupted past the midpoint so
the first header still reads clean.

Adds four tests: the two `safe_extract_tar` sites (plain and with a
caller-supplied `error_type`), the `safe_extract_archive` entry point
with a caller-supplied `error_type`, and a guard asserting the fixture
still reaches the module as a bare `zlib.error` — so if a future Python
wraps it, that fails loudly instead of the coverage silently decaying
into a duplicate of the `EOFError` cases.

Verified test-the-test: the three wrapping tests fail against the
unmodified `_download_security.py` with a raw
`zlib.error: Error -3 while decompressing data: invalid distance code`,
and pass with the fix.

Also corrects the scope claimed for the probe site. Fuzzing 2800 corrupt
archives never produced a bare `zlib.error` from `tarfile.open` alone,
because the only read it performs is the header read that `tarfile`
already converts. The probe's `zlib.error` arm is defensive, not
load-bearing; the tuple comment and a detection test now say so rather
than implying coverage that cannot exist.

Assisted-by: Claude Opus 5 (1M context)

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

* test(archives): make the corrupt-deflate fixture zlib-version independent

CI failure on macos-latest/3.13: `test_corrupt_deflate_fixture_raises_bare_zlib_error`
failed with `gzip.BadGzipFile: CRC check failed`. The other five pytest jobs
were fail-fast cancellations, not real failures, and ruff was already green.

The fixture built its corruption by XOR-ing 64 arbitrary bytes mid-stream.
Whether that produces a *structural* deflate error is zlib-version dependent:
on the macOS runner the mangled bytes still decoded, so the stream instead
failed the trailing gzip CRC check and raised `BadGzipFile` -- an `OSError`,
which the pre-fix `(TarError, OSError)` handler already caught. The guard test
exists precisely to catch that degradation, and it did its job.

Replaces the XOR with a deflate block header whose `BTYPE` is the reserved
value `0b11`. Every zlib rejects that identically as "invalid block type", and
it fails during decompression rather than at the CRC check, so no version can
turn it into a `TarError` or `OSError`. The stream is assembled by hand
(`compressobj(-15)` + explicit gzip header/trailer) so the invalid block lands
a controlled 256 KiB into the first member's data -- past the gzip read buffer,
so the first header still reads clean and the failure surfaces from the forward
seek in `tarfile.next`, which is the site the raw `zlib.error` escapes from.

A sweep over clean-prefix sizes confirms a wide margin: with 512 KiB members
every prefix from 160 KiB up yields a bare `zlib.error`, versus the transition
below ~131 KiB where `tarfile` still wraps it as `ReadError`. The hand-built
gzip header also zeroes the mtime field, so the fixture is now byte-identical
across builds instead of embedding a timestamp.

Strengthens the guard to assert what the fix actually depends on -- that the
exception is neither a `TarError` nor an `OSError` -- so the fixture cannot
silently decay into an already-caught type again.

Production code is unchanged from ef49acc; this is test-only.

Verified test-the-test by dropping the `zlib.error` arm from
`_TAR_DECOMPRESSION_ERRORS`: the three wrapping tests fail with the raw
`zlib.error: Error -3 while decompressing data: invalid block type`, and pass
with it restored. `tests/test_download_security.py`: 193 passed.
`ruff check src tests` (the exact CI command): all checks passed.

Assisted-by: Claude Opus 4.8 (1M context)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:57:51 -05:00
Quratulain-bilalandGitHub 71125fc346 test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
Add regression tests for SkillsIntegration mixin that verify:
- Multiline (block-scalar) description round-trips byte-for-byte
- C0/DEL control characters in description survive YAML escaping

Tests properly isolate Path.home() for Hermes to prevent overwriting
a developer's real global skill directory.

Refs: #3392
2026-08-05 09:32:05 -05:00
f01cac6300 fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
_check_file/_check_dir hard-code U+2713/U+2717 and print() them to
sys.stdout. On Windows sys.stdout falls back to the ANSI code page whenever
stdout is not a console — which is every time an agent or a workflow step
captures the output — and U+2713 is unencodable in cp1252, so the document
listing aborted mid-report with UnicodeEncodeError.

This is the byte-identical twin of the block in
scripts/python/check_prerequisites.py, which I flagged in the PR for that
file rather than widening its scope.

Fall back to ASCII when stdout cannot encode the glyph. "[OK]"/"[FAIL]" is
the rendering these markers already have in-tree: Test-FileExists in
scripts/powershell/common.ps1 emits exactly those, and
normalize_status_text in tests/parity_helpers.py maps the glyphs onto them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:44:43 -05:00
Manfred RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
6fa8c9aaef chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
* chore: bump version to 0.16.0

* chore: begin 0.16.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-05 08:40:04 -05:00
deborreandGitHub 4d5458c883 fix: keep long frontmatter values on a single line (#3989)
`CommandRegistrar.render_frontmatter` calls `yaml.dump()` without `width=`,
so PyYAML applies its default ~80-column wrap and folds any long scalar onto
a continuation line.

A `description` longer than roughly 80 characters is therefore rendered as:

    ---
    name: speckit-implement
    description: Execute the implementation plan by processing and executing all tasks
      defined in tasks.md
    ---

The YAML remains valid and round-trips faithfully through `yaml.safe_load`,
so this is not data loss. It is a shape inconsistency with real consequences:

- Hand-written core command templates always keep `description` on one line,
  so preset- and extension-rendered commands do not match the files they sit
  beside in the same directory.
- Consumers that read frontmatter line-wise rather than with a YAML parser
  see the description truncated at the fold, followed by a stray line. Spec
  Kit itself hand-builds SKILL.md frontmatter in the skills path (see #3391),
  so this is not a hypothetical class of consumer.
- `speckit.implement`'s own description is 89 characters, so a preset that
  overrides it hits this immediately.

`width=float("inf")` disables the line-wrapping only; escaping, quoting and
the handling of genuinely multi-line values are unchanged, since PyYAML
selects the scalar style before applying width.

Adds a regression test that fails without the change.

Verified against the repo's own suite: 6354 passed. Four failures in
tests/integrations/test_integration_subcommand.py are present on a clean
checkout too (ANSI escapes in captured output) and are unrelated.
2026-08-05 08:38:26 -05:00
Quratulain-bilalandGitHub 0ecb277f0e fix: skip corrupted run state files in list_runs (#3817)
* fix: skip corrupted run state files in list_runs

* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests

- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files

Fixes #3817
2026-08-05 08:37:12 -05:00
Quratulain-bilalandGitHub 1e85d4ff53 fix: skip corrupted run state files in list_runs (#3814)
* fix: skip corrupted run state files in list_runs

* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests

- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files

Fixes #3814
2026-08-05 08:11:38 -05:00
Manfred RiemandGitHub 03d71b3363 Add July 2026 newsletter (#3987)
* Add July 2026 newsletter

* docs(newsletters): remove internal press-index figures from earlier editions

Replace article counts, volume superlatives, and discovery-methodology
references (derived from an internal press index) with qualitative phrasing
in the April, May, and June editions, keeping only publicly verifiable data.
2026-08-04 12:12:43 -05:00
6e7818f837 fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
PresetRegistry._load() catches json.JSONDecodeError and
FileNotFoundError to start fresh on a corrupted or missing registry, but
a registry file with invalid UTF-8 bytes raised UnicodeDecodeError
before JSON parsing began, crashing every preset command.

Catch UnicodeDecodeError in the same clause: undecodable bytes are the
same corruption class as unparseable JSON. OSError stays uncaught on
purpose — the data may be intact on disk, and starting fresh would let a
later _save() wipe it (same fail-closed reasoning as the workflow
catalog cache loader).

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 09:07:13 -05:00
0824a09d0f docs: clarify agent PR review prioritization (#3985)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 31dc6b66-8484-46b5-a282-360029e14ff2
2026-08-04 08:57:13 -05:00
a9bde5c204 fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
_merge_toml_fragment() and _remove_toml_entries() read the user's
config.toml with bare read_text() calls, so a non-UTF-8 (or otherwise
unreadable) file crashed install_integration_events() and
remove_integration_events() with a raw UnicodeDecodeError — and the
merge path regenerates the file from what it read, so it would have
discarded the user's bytes had it not crashed first. Every JSON
merge/remove path already goes through _load_user_json(), which skips
on an unreadable file to preserve user content (#22).

Abort the merge (returning False so the caller skips tracking, S5) and
skip the teardown cleanup with a warning, leaving the user's bytes
untouched in both directions.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:35:59 -05:00
f245c6cd1a fix(extensions): treat an unreadable staged backup as a conflict (#3962)
The rescue-retry loop in install_from_directory() reads each staged
backup with bare stat()/read_bytes() calls, so a staged config that
cannot be read crashed the reinstall with a raw OSError. Every sibling
read in this path — the live twin four lines below, the packaged
baseline check, the mode sidecar — already catches OSError.

Treat an unreadable staged file like an uncomparable live config:
add it to the conflict set so both copies are preserved and the retry
aborts with the existing resolution guidance while dest_dir is still
untouched.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:18:33 -05:00
cd996f74eb fix(manifests): reject non-string requires.speckit_version (#3980)
`requires.speckit_version` was presence-checked but never type-checked in
both the extension and preset manifest validators, so an unquoted YAML
`speckit_version: 1.0` (a float) passed validation and reached
`SpecifierSet(required)` in `check_compatibility()`.

That call is guarded by `except InvalidSpecifier` alone, which a
non-string escapes two different ways:

- a float/int/bool/None raises `TypeError: 'float' object is not
  iterable` from the `SpecifierSet` constructor;
- a list or dict is an *iterable*, so `SpecifierSet` accepts it and the
  failure surfaces much later as `AttributeError: 'str' object has no
  attribute 'filter'` from inside `.contains()`.

Neither is a `CompatibilityError`/`PresetCompatibilityError`, so both
bypass the CLI's "Compatibility Error" handler in `_commands.py` and exit
1 with a raw traceback that names no field, leaving the author with no
hint which manifest key is wrong.

Type-check the field in both validators, requiring a non-empty string,
and additionally guard `check_compatibility()` in both managers since
each is public and reachable with a hand-built or mutated manifest. This
mirrors the sibling `IntegrationDescriptor`, which already requires a
non-empty string for the same key, and completes the type-checking pass
started in #3943 for the neighbouring `extension`/`preset` fields.

Adds 33 regression tests across both modules covering every escape path;
26 of them fail without this change.

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

Assisted-by: Claude Code (model: Claude Opus 5, supervised)
2026-08-04 08:03:00 -05:00
0fa86e8e9c fix(extensions): reject reinstall when a kept config cannot be read (#3960)
The keep-config rescue branch of install_from_directory() reads each
preserved config with bare read_bytes()/stat() calls, so a kept config
that cannot be read (permission or I/O error) crashed the reinstall
with a raw OSError. The sibling symlink guard four lines above already
rejects with a ValidationError and resolution guidance for the same
reason: bytes that cannot be safely rescued must not reach the rmtree
below.

Wrap the read and raise ValidationError with guidance, while dest_dir
is still untouched so the preserved bytes are never lost.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:01:16 -05:00
github-actions[bot]GitHubCopilotmnriemgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e9f653318e [extension] Update Charter extension to v0.5.1 (#3983)
* Update Charter extension to v0.5.1

Update charter extension submitted by @Huljo:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #3944

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

* Limit catalog diff to Charter fields and top-level timestamp

Assisted-by: GitHub Copilot (model: unknown, autonomous)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-08-04 07:45:36 -05:00
316cd1235a fix(events): return None for an unparseable script command (#3957)
_script_command() split the configured command with a bare
shlex.split(), so a command string with unbalanced quotes crashed event
dispatch with a raw ValueError. The dispatcher-template twin a few
lines up already wraps the same call in try/except ValueError and
returns None so dispatch falls back cleanly.

Wrap the split the same way and return None, restoring parity between
the two paths.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 07:34:02 -05:00
kanfilandGitHub 99970560db feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
* feat(events): context injection for opencode and JSON-envelope agent hooks

Adds first-class context injection to agent runtime events:

1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes.

2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument:
   - gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise)
   - copilot: top-level additionalContext on session_start
   - cursor: top-level additional_context on session_start; suppress elsewhere
   - claude, codex: plain stdout passthrough (already injected)

3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly.

Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass.

* fix(events): address code review on #3934

- Qwen/Gemini/Tabnine/Devin: include native hookEventName inside
  hookSpecificOutput envelope (required by Qwen's hooks spec). Thread
  the native event name from the integration's CANONICAL_TO_NATIVE
  through _dispatcher_command as a 6th dispatcher argument, through
  the dispatcher template's main()/_run_inline()/_emit(), and through
  resolve_and_run_event_command()/_emit_event_stdout().
- Copilot: map user_prompt_submit to additionalContext (previously
  unmapped, breaking per-prompt context injection despite Copilot CLI
  supporting it via userPromptSubmitted).
- OpenCode: guard experimental.chat.system.transform so canonical
  session_start handlers only run when input.sessionID is present —
  OpenCode fires this hook for non-session operations (e.g. agent
  generation) with no sessionID.

Assisted-by: opencode (model: glm-5.2, supervised)

* fix(events): address second Copilot review round on #3934

- Positional arg alignment: always emit default timeout (60s) as the
  4th dispatcher argument even when timeout_seconds is omitted, so the
  envelope (5th) and native_event (6th) land in the correct argv slots.
  Previously, omitting timeout_seconds caused the envelope to be parsed
  as an invalid timeout, silently falling back to plain stdout.
- OpenCode session_start caching: cache handler output per sessionID in
  the generated TS plugin so non-idempotent handlers (setup, telemetry,
  file-mutating scripts) run once per session instead of on every LLM
  request. Cache is evicted on session.deleted.
- Updated PR description to reflect Copilot user_prompt_submit now maps
  to additionalContext (was documented as plain/unprocessed).

Assisted-by: opencode (model: glm-5.2, supervised)
2026-08-04 07:32:30 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
e57a86cc9c Add TDD Extension to community catalog (#3982)
Add tdd extension submitted by @d0whc3r to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3978

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-08-04 07:25:15 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
4962ffe926 Update Archive Extension to v1.1.0 (#3981)
Update archive extension submitted by @stn1slv:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table

Closes #3977

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-08-04 07:25:01 -05:00
0fbd99d594 feat(copilot): default integration to skills (#3976)
* feat(copilot): default integration to skills

Make Copilot skills the default while retaining the commands layout behind --integration-options="--commands". Preserve historical project layouts and validate conflicting mode flags before switch teardown.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

* fix(copilot): preserve layout state during migration

Keep target integration options isolated from fallback state, prefer the Copilot manifest when resolving layouts, and update dispatch coverage for the skills-first default.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

---------

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5
2026-08-04 06:15:40 -05:00
ab468c4db7 fix(events): ignore non-UTF-8 event overrides (#3897)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 16:31:22 -05:00
Quratulain-bilalandGitHub f8b3d604e1 fix: cap stdin read at 1 MiB to prevent DoS (#3857)
* fix: cap stdin read at 1 MiB to prevent DoS

Unbounded sys.stdin.read() allowed a malicious caller to exhaust memory
by sending a multi-gigabyte payload. Cap at 1 MiB and raise typer.Exit
if truncated.

* fix: improve stdin payload limit error handling in event.py

- Rename _MAX_PAYLOAD to MAX_STDIN_BYTES (clearer constant naming)
- Improve error message to suggest truncation or smaller payload
- Better code formatting for readability

Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
2026-08-03 14:32:10 -05:00
cda7a921a4 fix(workflows): reject mismatched run state IDs (#3899)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 14:30:55 -05:00
Manfred RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
58f5d6e258 chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
* chore: bump version to 0.15.2

* chore: begin 0.15.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-03 14:08:18 -05:00
chelsealongandGitHub f4e3110560 fix(presets): restore core skills instead of deleting them on preset remove (#3929)
* fix(presets): restore core skills instead of deleting them on preset remove

Skill restoration looked for core command templates under
.specify/templates/commands, a directory specify init never populates
in real projects. Since that lookup always missed, presets overriding
a core command (e.g. speckit.plan) had their skill deleted outright on
removal instead of restored — the actual core templates live in the
bundled core_pack (wheel install) or the repo-root templates/ tree.

Restoration now falls back to that bundled location, gated behind a
restore_from_bundled_core flag so the existing "retire a stale skill
superseded by a command-mode winner" path keeps deleting rather than
resurrecting a duplicate skill.

Fixes #3928

* fix(tests): use explicit utf-8 encoding reading restored skill content

read_text() defaults to the platform locale encoding, which is cp1252
("charmap") on Windows. The bundled specify.md core template contains a
UTF-8 multi-byte emoji whose bytes aren't valid cp1252, so the Windows CI
job failed decoding the restored SKILL.md with UnicodeDecodeError.

* fix(presets): keep extension restore priority over bundled-core fallback

The bundled-core fallback added for #3928 ran before the
extension_restore_index lookup, so a skill an installed extension owns
could be silently replaced by lower-priority bundled core content on
preset removal instead of preserving the extension's winning layer.
2026-08-03 13:54:12 -05:00
2d8904a21e fix(manifests): reject non-string metadata instead of crashing on it (#3943)
* fix(manifests): reject non-string metadata instead of crashing on it

`ExtensionManifest` and `PresetManifest` checked only key PRESENCE for
`id`/`name`/`version`/`description`, then fed the values straight to
`re.match()` and `packaging.Version()`. Both raise a bare `TypeError` on a
non-string, which is neither `ValidationError` nor `PresetValidationError`,
so it escaped every caller that already handles a malformed manifest.

YAML makes this an easy authoring slip rather than a contrived one: an
unquoted `version: 1.0` parses as a float and `id: 2` as an int.

The user-visible symptom is the one the in-tree comment above the section
guards was written to prevent (#3898 for presets, and its extension twin):
`list_installed()` degrades a bad manifest to "⚠️ Corrupted extension" but
catches only the domain error, so a single bad manifest made
`specify extension list` / `specify preset list` exit 1 with a raw
traceback and *no output at all* — hiding every healthy extension/preset
too, not just the broken one.

Also unguarded on the same path:
- extension `provides.commands[].name` → `TypeError` from the command-name
  pattern match. The sibling `file` field was already safe, since
  `relative_extension_path_violation()` rejects a non-string.
- preset `provides.templates[].name`/`.file` → `TypeError` from `re.match`
  and `os.path.normpath` respectively.

The third manifest twin, `IntegrationDescriptor`, is already hardened: it
type-checks the same four fields and catches `TypeError` alongside
`InvalidVersion`. This brings the other two in line with it.

Tests: 68 added across both suites, covering each field against float, int,
None, list, dict, and bool, plus an end-to-end guard per manifest type
asserting a healthy entry still lists while the bad one degrades. All 68
fail with the source change reverted.

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-03 13:53:08 -05:00
Quratulain-bilalandGitHub 7ddb8194d9 fix: narrow bare except Exception in invoke separator resolution (#3856)
* fix: narrow exception in invoke separator resolution and add regression test

Narrow 'except Exception' to 'except (ImportError, ValueError, KeyError)'
in register_commands() invoke separator resolution. Add regression test
that verifies TypeError propagates instead of being silently swallowed.

* fix: remove duplicate pass statement in agents.py

Remove redundant second pass statement in the except block
for invoke separator resolution. The narrowed exception handler
now has a single clean pass statement.

Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
2026-08-03 13:49:37 -05:00
84a2114338 fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
The step documents the default twice:

  class docstring: "Because workflows run unattended, the step defaults to
                    ``--ignore-agent-tools``"
  field docs:      "Skip checks for the coding agent CLI (defaults to ``true``)"

It implements that with `config.get("ignore_agent_tools", True)`, which
applies the default only when the key is ABSENT. A bare
`ignore_agent_tools:` in YAML parses to None, and `_resolve_bool(None)`
returns False:

  key ABSENT                -> True   flag emitted: YES
  bare ignore_agent_tools:  -> False  flag emitted: NO   <-- bug
  explicit true             -> True   flag emitted: YES
  explicit false            -> False  flag emitted: NO

So the flag is dropped, `specify init` re-runs the agent-CLI presence
check, and an unattended run fails with "Agent Detection Error" for any
integration whose CLI is not installed on the runner.

Normalize an explicit null to the default, mirroring the while/do-while
`max_iterations` handling.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:14:31 -05:00
b69147c841 fix(kimi): preserve non-UTF-8 user skills (#3895)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 13:11:19 -05:00
14fab0a1af fix(presets): tolerate non-UTF-8 legacy commands (#3896)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 12:33:39 -05:00
e9ffc9d8e7 feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
* feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT

Resolve the non-interactive/init default integration from the
SPECKIT_INTEGRATION_DEFAULT environment variable, fitting the existing
SPECKIT_INTEGRATION_* namespace. Falls back to the hardcoded "copilot"
default when unset, and warns to stderr (rather than silently falling
back) when the value is not a registered integration key.

Wires the resolver into specify init (interactive prompt default and
non-interactive fallback), the init workflow step, and the bundle init
default. Adds unit and CLI tests and documents the variable.

Closes #3939

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

* test: cover env-var default wiring for picker, workflow step, and bundle

Address PR review: add regression tests so each SPECKIT_INTEGRATION_DEFAULT
wiring site cannot silently revert to the hardcoded constant.

- init.py: interactive picker receives the resolved key as default_key.
- workflow init step: no step/workflow default + env var drives output
  integration and argv.
- bundle _resolve_init_integration: env-var default applies when unspecified,
  while explicit override and manifest-declared integration still win.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
2026-08-03 12:20:49 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
4751777a38 Add adrkit extension to community catalog (#3947)
Add adrkit extension submitted by @mbeacom to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3942


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-08-03 11:56:04 -05:00
15cb7d9a66 feat(extensions): scaffold config templates on extension add/enable (#2000)
* feat(extensions): scaffold config templates on extension add/enable

Deploy an extension's provides.config templates into .specify/ when the
extension is added or enabled. Existing files are never overwritten, so
user customizations are preserved.

Addresses the review on #2000:
- ExtensionManifest.config returns [] unless provides.config is a list of
  dicts, so a malformed manifest cannot crash callers.
- scaffold_config returns a consistent (deployed, skipped_existing, failed)
  tuple on every path, including a missing manifest.
- Template paths must resolve inside the extension dir and targets inside
  .specify/; symlinks and non-regular files are rejected.
- Callers distinguish "already exists (preserved)" from "not scaffolded",
  and extension_enable no longer crashes on a corrupt manifest.
- Tests cover traversal, absolute paths, symlinks, directory templates,
  malformed provides.config, and the missing-manifest tuple shape.

Ported onto the extensions package introduced by #3014: the manager and
manifest changes land in extensions/__init__.py and the CLI wiring in
extensions/_commands.py.

* fix(extensions): deploy config where it is read, and contain the write

Addresses @Copilot's review.

Config now lands in .specify/extensions/<id>/ rather than the .specify/
root. ConfigManager._get_project_config() reads
.specify/extensions/<id>/<id>-config.yml, and the bundled scripts and
READMEs use the same path, so a scaffolded git-config.yml was being
written somewhere the git extension never looks.

Containment is checked component by component before .specify is used as
the root. Resolving it first and trusting the result let a symlinked
component point outside the project, after which every target satisfied
relative_to and copy2 wrote externally. This matches the project
safe-write path in shared_infra.

mkdir moved inside the OSError handler. A nested target like
foo/config.yml raised out of scaffolding when its parent could not be
created, and on extension add that happened after the extension was
already installed.

The 'Configuration may be required' warning is now conditional. It ran
unconditionally after the scaffolding block, so it contradicted the
success output directly above it and fired for extensions with no
provides.config at all.

Tests cover the corrected location, a symlinked config root, and an
uncreatable nested target.

* fix(extensions): only scaffold config targets that removal preserves

remove(keep_config=True) rmtree's every subdirectory and keeps only top-level
-config.yml / -config.local.yml files; the backup path globs the same
top-level pattern. Scaffolding a nested or differently-named target therefore
handed the user a file that 'extension add --force' silently deleted and
replaced with the template default, losing customization.

Constrain scaffold targets to that convention rather than widening four
removal paths.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:24:21 -05:00
137 changed files with 9269 additions and 1028 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name
+1
View File
@@ -70,6 +70,7 @@ body:
- Cline
- CodeBuddy
- Codex CLI
- Command Code
- Cursor
- Devin for Terminal
- Factory Droid
@@ -64,6 +64,7 @@ body:
- Cline
- CodeBuddy
- Codex CLI
- Command Code
- Cursor
- Devin for Terminal
- Factory Droid
+16 -6
View File
@@ -1,9 +1,9 @@
{
"entries": {
"actions/checkout@v6.0.3": {
"actions/checkout@v7.0.1": {
"repo": "actions/checkout",
"version": "v6.0.3",
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
"version": "v7.0.1",
"sha": "3d3c42e5aac5ba805825da76410c181273ba90b1"
},
"actions/download-artifact@v8.0.1": {
"repo": "actions/download-artifact",
@@ -15,10 +15,20 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"actions/setup-node@v6.4.0": {
"actions/setup-node@v7.0.0": {
"repo": "actions/setup-node",
"version": "v6.4.0",
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
"version": "v7.0.0",
"sha": "820762786026740c76f36085b0efc47a31fe5020"
},
"actions/setup-python@v7.0.0": {
"repo": "actions/setup-python",
"version": "v7.0.0",
"sha": "5fda3b95a4ea91299a34e894583c3862153e4b97"
},
"astral-sh/setup-uv@v9.0.0": {
"repo": "astral-sh/setup-uv",
"version": "v9.0.0",
"sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9"
},
"actions/upload-artifact@v7.0.1": {
"repo": "actions/upload-artifact",
+1 -1
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c64e3dc29aca89e48108bb6d4eb877f6264b4cec9cd56dcd36827893802d2a64","body_hash":"cade22e5083254b735200f4ff7d686104e4ccab848ff7355141b9689354834db","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
+1 -1
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"687ea37b376b3b918331c13fce6cdbf5b9898bab8e514ca57b662b92b6d3cd2c","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
+1 -1
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
+1 -1
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"00c226f69fb7ec2b63755304328cee6ecddbcedbe4a9840310e5f430bd3949f0","body_hash":"44428ecd81ba0e5ed7bb16436052e6cc3479fe4ad02414812e574d17830a464e","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
+1 -1
View File
@@ -1,5 +1,5 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aafdb01f262d603577971994522575829802b93d9042d62446313955485df558","body_hash":"4596de2b7de95c7c73c05caedc5c1e97724b39d09d21e9b0dbfc8b570312798a","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
# ___ _ _
+18 -7
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -60,6 +60,22 @@ permissions:
checkout:
fetch-depth: 0
network:
allowed:
- defaults
- pypi.org
- files.pythonhosted.org
steps:
- name: Setup uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Install Python test dependencies
run: uv pip install --system -e ".[test]"
safe-outputs:
noop:
report-as-issue: false
+2 -2
View File
@@ -22,11 +22,11 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
with:
globs: |
'**/*.md'
+21 -24
View File
@@ -260,13 +260,13 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--commands` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `speckit-<name>/SKILL.md` (default) or `.agent.md` + `.prompt.md` + `.vscode/settings.json` (`--commands`) |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
@@ -415,36 +415,28 @@ Some agents require custom processing beyond the standard template transformatio
### Copilot Integration
GitHub Copilot has unique requirements:
GitHub Copilot uses skills by default, scaffolded as
`speckit-<name>/SKILL.md` under `.github/skills/`.
- Commands use `.agent.md` extension (not `.md`)
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
via `--integration-options="--commands"`. When enabled:
- Commands use `.agent.md` extension under `.github/agents/`
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs `.vscode/settings.json` with prompt file recommendations
- Context file lives at `.github/copilot-instructions.md`
- `.vscode/settings.json` is merged with prompt file recommendations
- `build_command_invocation()` returns bare args for `--agent` dispatch
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
In the default skills mode, no companion prompts or VS Code settings merge are
created, and `build_command_invocation()` returns `/speckit-<stem>`.
The two modes are mutually exclusive — a project uses one or the other:
```bash
# Default mode: .agent.md agents + .prompt.md companions + settings merge
# Default skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot --integration-options="--skills"
# Commands mode: .agent.md agents + .prompt.md companions + settings merge
specify init my-project --integration copilot --integration-options="--commands"
```
### Forge Integration
@@ -509,6 +501,11 @@ When an issue exists, include its number immediately after the prefix — this i
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
### Opening pull requests
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
### Commits
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
+101
View File
@@ -2,6 +2,107 @@
<!-- insert new changelog below this comment -->
## [0.16.2] - 2026-08-10
### Changed
- Add Command Code integration to spec-kit (#4019)
- fix(workflows): strip a resolved condition before the true/false check (#3883)
- fix(workflows): guard a non-string overlay edit 'operation' (#3881)
- fix: bound response read in integration catalog fetch (#3818)
- Fix bug-test Python dependency provisioning (#4030)
- fix(bundle): escape Rich markup in bundle CLI error and status output (#4023)
- fix(presets): skip an unreadable restore source in `preset remove` (#4020)
- Add Keel Discovery extension to community catalog (#4035)
- fix: show error details in preset catalog config read failure (#3840)
- Update Reconcile Extension to v1.1.0 (#4034)
- Add Model Routing Governance preset to community catalog (#4033)
- fix: use missing_ok=True in integration JSON removal (#3846)
- fix: use missing_ok=True in extension cache clear (#3845)
- fix(extensions): reject duplicate provides.templates/scripts names (#4016)
- feat(presets): resolve constitution templates at command time (#3984)
- [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
- docs: document installing specify-cli from a custom package index (#4032)
- feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
- fix(presets): treat an unreadable core template as missing (#3961)
- chore: release 0.16.1, begin 0.16.2.dev0 development (#4014)
## [0.16.1] - 2026-08-07
### Changed
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
- fix(events): skip an unreadable command template (#3956)
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
- fix(init): escape user-supplied values in `specify init` output (#3787)
- fix: bound response read in integration catalog fetch (#3812)
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
- feat(init): scaffold managed .specify/.gitignore (#4000)
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
## [0.16.0] - 2026-08-05
### Changed
- fix: keep long frontmatter values on a single line (#3989)
- fix: skip corrupted run state files in list_runs (#3817)
- fix: skip corrupted run state files in list_runs (#3814)
- Add July 2026 newsletter (#3987)
- fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
- docs: clarify agent PR review prioritization (#3985)
- fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
- fix(extensions): treat an unreadable staged backup as a conflict (#3962)
- fix(manifests): reject non-string requires.speckit_version (#3980)
- fix(extensions): reject reinstall when a kept config cannot be read (#3960)
- [extension] Update Charter extension to v0.5.1 (#3983)
- fix(events): return None for an unparseable script command (#3957)
- feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
- Add TDD Extension to community catalog (#3982)
- Update Archive Extension to v1.1.0 (#3981)
- feat(copilot): default integration to skills (#3976)
- fix(events): ignore non-UTF-8 event overrides (#3897)
- fix: cap stdin read at 1 MiB to prevent DoS (#3857)
- fix(workflows): reject mismatched run state IDs (#3899)
- chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
## [0.15.2] - 2026-08-03
### Changed
- fix(presets): restore core skills instead of deleting them on preset remove (#3929)
- fix(manifests): reject non-string metadata instead of crashing on it (#3943)
- fix: narrow bare except Exception in invoke separator resolution (#3856)
- fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
- fix(kimi): preserve non-UTF-8 user skills (#3895)
- fix(presets): tolerate non-UTF-8 legacy commands (#3896)
- feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
- Add adrkit extension to community catalog (#3947)
- feat(extensions): scaffold config templates on extension add/enable (#2000)
- fix(events): skip non-UTF-8 extension manifests (#3900)
- fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
- fix(presets): validate required manifest mappings (#3898)
- fix: eliminate TOCTOU race in zip packaging (#3855)
- fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
- fix(workflows): refetch non-UTF-8 catalog caches (#3901)
- fix(bundler): wrap local catalog decode failures (#3902)
- Add `--extension` flag to `specify init` for opting into extensions at init time (#3914)
- fix: bound response reads in extension catalog and download (#3775)
- fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
- chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
## [0.15.1] - 2026-07-31
### Changed
+2
View File
@@ -55,6 +55,8 @@ Here are a few things you can do that will increase the likelihood of your pull
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request.
### Branch naming
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
+1 -1
View File
@@ -90,7 +90,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI and Command Code in skills mode use `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
+3
View File
@@ -25,6 +25,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) |
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
@@ -71,6 +72,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Keel Discovery | A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
@@ -152,6 +154,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
+1
View File
@@ -25,6 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Model Routing Governance | Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git. | 4 templates, 2 commands, 2 scripts | — | [spec-kit-preset-model-routing-governance](https://github.com/hindermath/spec-kit-preset-model-routing-governance) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| 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) |
+21
View File
@@ -35,6 +35,27 @@ pipx install specify-cli==0.12.11
pip install specify-cli==0.12.11
```
## Install from a custom or private package index
Some environments (corporate networks, mirrors, proxies, or artifact feeds) require installing `specify-cli` from a package index other than the default public PyPI. Each Python tool exposes a way to point at a different index — configure it before running the install commands above. Substitute your own index URL for the placeholder shown here.
```bash
# uv — via environment variable (applies to the whole command)
UV_DEFAULT_INDEX=https://your-index.example.com/pypi/simple/ uv tool install specify-cli
# uv — via flag
uv tool install --default-index https://your-index.example.com/pypi/simple/ specify-cli
# pipx — pass a pip argument through
pipx install specify-cli --index-url https://your-index.example.com/pypi/simple/
# pip
pip install specify-cli --index-url https://your-index.example.com/pypi/simple/
```
> [!NOTE]
> The same index configuration applies to pinned installs, upgrades (`--force`/`--upgrade`), and one-time usage — set the environment variable or flag on those commands too. If your index requires authentication, follow your tool's documentation and prefer credential environment variables, keyring, or netrc; do not embed secrets in command-line URLs because they can leak through shell history, process listings, or logs. Avoid committing secrets. For fully offline installs, see the [air-gapped installation guide](air-gapped.md).
## Verify
```bash
+1 -1
View File
@@ -50,7 +50,7 @@ pipx install specify-cli
pip install specify-cli
```
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade and how to [install from a custom or private package index](install/pypi.md#install-from-a-custom-or-private-package-index).
### One-time Usage
+4 -1
View File
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
### Examples
@@ -50,12 +50,15 @@ specify init my-project --integration copilot --preset compliance
| Variable | Description |
| ----------------- | ------------------------------------------------------------------------ |
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
## Check Installed Tools
+5 -2
View File
@@ -14,13 +14,14 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
| [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | |
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
@@ -234,7 +235,8 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
Example:
@@ -278,6 +280,7 @@ The currently declared multi-install safe integrations are:
| `cline` | `.clinerules/workflows` |
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `command-code` | `.commandcode/skills` |
| `cursor-agent` | `.cursor/skills` |
| `droid` | `.factory/skills` |
| `firebender` | `.firebender/commands` |
+39 -1
View File
@@ -40,12 +40,25 @@ requires:
required: boolean # Optional, default: false
provides:
commands: # Required, at least one command
commands: # At least one of commands/templates/scripts/hooks/events required
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
file: string # Required, relative path to command file
description: string # Required
aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands
templates: # Optional, array of declared templates. Always resolve
# as "replace" -- 'strategy' is not an authorable field here.
- name: string # Required, pattern: ^[a-z0-9-]+$
file: string # Required, relative path to template file
description: string # Optional
scripts: # Optional, array of declared scripts. Always resolve
# as "replace" -- 'strategy' is not an authorable field here.
- name: string # Required, pattern: ^[a-z0-9-]+$
file: string # Required, relative path to script file
description: string # Optional
runtimes: [string] # Optional, subset of: bash, powershell, python
config: # Optional, array of config files
- name: string # Config file name
template: string # Template file path
@@ -111,6 +124,29 @@ defaults: # Optional, default configuration values
- **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync`
- **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues`
#### `provides.templates[].name` / `provides.scripts[].name`
- **Type**: string
- **Pattern**: `^[a-z0-9-]+$`
- **Description**: Unlike commands, templates and scripts are not invoked by
name, so they use the same plain slug pattern as `extension.id` rather than
the namespaced command pattern.
- **Examples**: `myext-template`, `myext-collect`
#### `provides.templates[].strategy` / `provides.scripts[].strategy`
- Not an authorable field. Extension-contributed templates and scripts are
always resolved as `replace`; a manifest that includes a `strategy` key on
one of these entries is rejected with a `ValidationError`. Composable
strategies (`wrap`/`prepend`/`append`) are preset-only.
#### `provides.scripts[].runtimes`
- **Type**: array of strings
- **Values**: `bash`, `powershell`, `python`
- **Description**: Declares which runtimes the script supports. Purely
informational metadata — it is not used to select or invoke the script.
#### `hooks`
- **Type**: object
@@ -143,6 +179,8 @@ manifest.version # str: Version
manifest.description # str: Description
manifest.requires_speckit_version # str: Required spec-kit version
manifest.commands # List[Dict]: Command definitions
manifest.templates # List[Dict]: Declared template definitions
manifest.scripts # List[Dict]: Declared script definitions
manifest.hooks # Dict: Hook definitions
```
+24 -2
View File
@@ -177,9 +177,16 @@ Compatibility requirements.
What the extension provides.
**Optional sub-fields**:
**Optional sub-fields:**
- `commands`: Array of command objects (at least one command or hook is required)
- `commands`: Array of command objects
- `templates`: Array of template objects
- `scripts`: Array of script objects
`hooks` and `events` are separate top-level manifest fields (siblings of
`provides`, not nested under it — see [`hooks`](#hooks) below). At least one
of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
`events` is required.
**Command object**:
@@ -188,6 +195,21 @@ What the extension provides.
- `description`: Command description (optional)
- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)
**Template object**:
- `name`: Template name (lowercase, alphanumeric, hyphens — e.g. `myext-template`)
- `file`: Path to template file (relative to extension root)
- `description`: Template description (optional)
**Script object**:
- `name`: Script name (lowercase, alphanumeric, hyphens — e.g. `myext-collect`)
- `file`: Path to script file (relative to extension root)
- `description`: Script description (optional)
- `runtimes`: Runtimes the script supports (optional, array; subset of `bash`, `powershell`, `python` — informational only, not used to select or invoke the script)
Extension-provided templates and scripts always resolve as `replace`; a manifest that includes a `strategy` key on one of these entries is rejected with a `ValidationError`. Composable strategies (`wrap`/`prepend`/`append`) are preset-only.
### Optional Fields
#### `hooks`
@@ -11,8 +11,8 @@ Usage: update_agent_context.py [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 ``plan.md`` anywhere under ``specs/`` (including nested scoped
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
recently modified ``plan.md`` found anywhere under ``specs/`` — scoped layouts
nest it as ``specs/<scope>/<feature>/plan.md`` — only when feature.json is
absent or its plan does not exist yet.
"""
@@ -173,16 +173,31 @@ def _resolve_plan_path(project_root: str) -> str:
if not plan_path:
root = Path(project_root).resolve()
plans = sorted(
(root / "specs").rglob("plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
specs = root / "specs"
def _resolved_rel(p: Path) -> Path | None:
# Resolve symlinks before checking containment: relative_to() is
# lexical and would otherwise accept a plan reached through a specs/
# symlink that points outside the project, emitting an
# in-project-looking path for an out-of-project file (or picking it
# as "most recent").
try:
plan_path = plans[0].relative_to(root).as_posix()
except ValueError:
plan_path = ""
return p.resolve().relative_to(root)
except (OSError, ValueError):
return None
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
# specs/<scope>/<feature>/plan.md, are still discovered when
# feature.json is absent (#3024). Mirrors the bash and PowerShell twins.
candidates = []
for p in specs.rglob("plan.md"):
rel = _resolved_rel(p)
if rel is not None:
candidates.append((p, rel))
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
if candidates:
plan_path = candidates[0][1].as_posix()
return plan_path
+122 -11
View File
@@ -1,8 +1,43 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-08-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"adrkit": {
"name": "adrkit — decision memory for spec-driven development",
"id": "adrkit",
"description": "Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact.",
"author": "Mark Beacom (@mbeacom)",
"version": "0.1.2",
"download_url": "https://github.com/mbeacom/adrkit/releases/download/spec-kit-v0.1.2/adrkit.zip",
"repository": "https://github.com/mbeacom/adrkit",
"homepage": "https://adrkit.dev",
"documentation": "https://github.com/mbeacom/adrkit/blob/main/packages/adapters/spec-kit/README.md",
"changelog": "https://github.com/mbeacom/adrkit/blob/main/CHANGELOG.md",
"license": "Apache-2.0",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.13.0,<0.16.0",
"tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"adr",
"governance",
"decision-records",
"architecture",
"compliance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-03T00:00:00Z",
"updated_at": "2026-08-03T00:00:00Z"
},
"aide": {
"name": "AI-Driven Engineering (AIDE)",
"id": "aide",
@@ -327,8 +362,8 @@
"id": "archive",
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
"author": "Stanislav Deviatov",
"version": "1.0.0",
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-archive",
"homepage": "https://github.com/stn1slv/spec-kit-archive",
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
@@ -353,7 +388,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
"updated_at": "2026-08-04T00:00:00Z"
},
"azure-devops": {
"name": "Azure DevOps Integration",
@@ -776,8 +811,8 @@
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"version": "0.5.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
@@ -786,7 +821,8 @@
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
"speckit_version": ">=0.11.9",
"tools": [{ "name": "git", "required": false }]
},
"provides": {
"commands": 5,
@@ -803,7 +839,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-08-04T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
@@ -2071,6 +2107,40 @@
"created_at": "2026-06-08T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
},
"keel": {
"name": "Keel Discovery",
"id": "keel",
"description": "A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards.",
"author": "Keel Discovery",
"version": "0.1.1",
"download_url": "https://github.com/keeldiscovery/spec-kit-keel/archive/refs/tags/v0.1.1.zip",
"repository": "https://github.com/keeldiscovery/spec-kit-keel",
"homepage": "https://keeldiscovery.com",
"documentation": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/README.md",
"changelog": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/CHANGELOG.md",
"license": "Apache-2.0",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.15.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"discovery",
"evidence",
"customer-research",
"validation",
"traceability"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-10T00:00:00Z",
"updated_at": "2026-08-10T00:00:00Z"
},
"learn": {
"name": "Learning Extension",
"id": "learn",
@@ -3370,8 +3440,8 @@
"id": "reconcile",
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
"author": "Stanislav Deviatov",
"version": "1.0.0",
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
@@ -3396,7 +3466,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
"updated_at": "2026-08-10T00:00:00Z"
},
"red-team": {
"name": "Red Team",
@@ -4453,6 +4523,47 @@
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"tdd": {
"name": "TDD Extension",
"id": "tdd",
"description": "Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength.",
"author": "d0whc3r",
"version": "1.1.2",
"download_url": "https://github.com/d0whc3r/spec-kit-tdd/releases/download/v1.1.2/tdd-1.1.2.zip",
"repository": "https://github.com/d0whc3r/spec-kit-tdd",
"homepage": "https://d0whc3r.github.io/spec-kit-tdd/",
"documentation": "https://github.com/d0whc3r/spec-kit-tdd/wiki",
"changelog": "https://github.com/d0whc3r/spec-kit-tdd/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 4,
"hooks": 3
},
"tags": [
"acceptance-tests",
"mutation-testing",
"property-based-testing",
"quality",
"red-green-refactor",
"spec-kit",
"spec-kit-extension",
"tdd",
"test-driven-development",
"test-first",
"testing",
"unit-tests"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z"
},
"team-assign": {
"name": "Team Assign",
"id": "team-assign",
+9
View File
@@ -84,6 +84,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"command-code": {
"id": "command-code",
"name": "Command Code",
"version": "1.0.0",
"description": "Command Code CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"devin": {
"id": "devin",
"name": "Devin for Terminal",
+2 -2
View File
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in April 2026. Seventeen releases shipped
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. External coverage continued across developer blogs and industry press. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
***
@@ -94,7 +94,7 @@ On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub S
### Developer Articles and Blog Posts
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
April produced a steady stream of external articles.
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
+152
View File
@@ -0,0 +1,152 @@
# Spec Kit - July 2026 Newsletter
This edition covers Spec Kit activity in July 2026 — a month of hardening and expanding the envelope. Twenty-eight releases shipped (v0.12.3 through v0.15.1), crossing three minor bumps and delivering three headline capabilities: the **`assess` "Idea Assessment Pipeline" extension**, which pushes spec-driven development *upstream* of the spec to answer "should we even build this?"; the new **`py` (Python) script type** and the broad shell→Python port that underpins it; and a **first-class agent-native runtime events layer** for integrations. Beneath the features, the month's dominant engineering theme was a sustained **security-hardening wave** — bounded HTTP reads, strict redirect validation, TOCTOU-race elimination, and defensive validation across the workflow engine. Externally, coverage broadened structurally: mainstream tech press (heise online) covered the v0.13 `assess` release in two languages, and a **companion-tooling ecosystem** bloomed around the project — spec↔code drift detectors, model-sizing advisors, and testing-gap tools all built *on top of* Spec Kit. A summary is in the table below, followed by details.
| **Spec Kit Core (Jul 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Twenty-eight releases shipped (v0.12.3–v0.15.1), crossing v0.13, v0.14, and v0.15. Headline features: the `assess` **Idea Assessment Pipeline** extension (capture→evidence→refine→design→go/clarify/kill), the new **`py` script type** plus a shell→Python port of the core scripts, git extension, and agent-context updater, and an **agent-native runtime events layer** for integrations. Three agents joined (Grok Build, Factory Droid CLI, Alquimia AI), the label-driven **bug-fix/bug-test** automation completed the triage pipeline, and a heavy **security-hardening** wave landed. The repo grew from ~117,400 to **124,655 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 125 to **144 entries**; presets reached **29**, community workflows **2**, bundles **1**. **heise online** covered the v0.13 `assess` release in English and German. Coverage shifted toward comparisons, companion tooling, and "who verifies the spec?" critiques. **~258 contributors** now listed. | A **companion-tooling ecosystem** emerged — artgraph (deterministic spec↔code drift), SpecJudge (model right-sizing), GAUNTLEX (security-testing gap), and custom skills like `speckit-next` and `prefill`. Comparisons increasingly pit Spec Kit against Kiro; balanced reviews keep flagging documentation proliferation and cognitive load, precisely the gaps the `assess` upstream step and the drift/companion ecosystem are built to close. |
***
> **Hardening the Foundation, Expanding the Envelope.** If June was defined by external validation, July was defined by internal consolidation and reach. No single release carried the weight of `converge` or `bundle`, but the month moved the project in two directions at once. It reached *upstream* — the new `assess` pipeline lets a team evaluate an idea (capture evidence, refine, design, then go/clarify/kill) *before* a spec exists, extending SDD past the spec into the decision to build. And it reached *down to the metal* — a new `py` script type and a systematic port of the core scripts, git extension, and agent-context updater from shell to Python, alongside a security-hardening wave that bounded every HTTP read, validated every redirect hop, eliminated file-race conditions, and taught the workflow engine to fail loudly instead of crashing on malformed input. Meanwhile the ecosystem answered the project's most-cited critique — "who verifies the spec, and who reads all this documentation?" — not with complaints but with *code*: a wave of companion tools built directly on Spec Kit artifacts. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
## Spec Kit Project Updates
### Releases Overview
**v0.12.3–v0.12.18** (July 1–17) was the month's longest patch run and carried two features amid heavy hardening. The **`py` script type** landed (#3285), adding Python interpreter resolution alongside the existing `sh`/`ps` options, and the **label-driven bug-fix (#3258) and bug-test (#3239) agentic workflows** completed the `bug-assess → bug-test → bug-fix` triage pipeline. The systematic shell→Python port began here: the **`update-agent-context` script** (#3387), the **git extension scripts** (#3400), and a **`check-prerequisites` proof-of-concept** (#3302) were all ported. **PyPI was documented as a first-class second install route** (#3516). New agents arrived — **Grok Build** (#3535) as a skills-based integration — while **Roo Code was retired** as a shut-down product (#3212). The rest was a broad defensive-validation sweep across the workflow engine (case-insensitive gate reject, quote-aware interpolation, host-less catalog-URL rejection, and dozens of "fail loudly on malformed input" guards). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.13.0–v0.13.4** (July 17–22) delivered the month's headline feature: the **`assess` Idea Assessment Pipeline extension** (#3568), a pre-spec evaluation flow. The release also completed the Python port of the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386) — and added **Azure DevOps `az`-CLI token acquisition** hardening (#3527), **community bundle submission automation** (#3553), and the standalone **`WorkflowResolver`** refactor (#3557). **Factory Droid CLI** joined as an integration (#3587), **Bob was updated to a skills-based layout for Bob 2.0** (#3415), the **`pipeline` workflow** was added to the community catalog (#3338), and the **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.14.0–v0.14.4** (July 23–29) crossed a minor with a **security-hardening focus**: **bounded HTTP reads and strict redirect enforcement** (#3140, #3671), **secured extension/preset archive downloads** (#3141), and the **removal of the `shell` parameter from `run_command`** (#3716). The **git extension gained configurable Conventional Commit support** (#3413), the wheel now **bundles `scripts/python`** so `--script py` works from a clean install (#3665), and **Alquimia AI** joined as the month's third new agent (#2734). Documentation added a **Simplified Chinese README translation** (#3740), and the constitution stopped **propagating guidance into templates** (#3790). A long run of bundler, preset, and integration validation fixes rounded out the cluster. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.15.0–v0.15.1** (July 30–31) closed the month with a **first-class agent-native runtime events layer for integrations** (#3704) — the release's headline — plus a continued security pass: **TOCTOU-race elimination in file-unlink calls** (#3811, #3815, #3819), **UTF-8 encoding on registry file opens** (#3810, #3816), and **hardening of the extension URL-download cache against symlink/junction races** (#3869). Workflows gained the ability to **bind a gate verdict to a workflow input via `verdict_input`** (#3725), an opt-in **`constitution-sync` preset** shipped (#3873), the **`yolo` workflow** was added to the community catalog (#3864), and installs gained **tar-archive support** (#3874). [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Idea Assessment Pipeline: `assess`
July's headline feature was the **`assess` extension** (#3568), an *idea-assessment pipeline* that ships as an opt-in extension and extends spec-driven development one step further upstream than it has ever reached. Where the core lifecycle begins at `/speckit.specify` — assuming the decision to build has already been made — `assess` addresses the question that comes *before* the spec: **should this idea be built at all, and is it understood well enough to specify?**
The pipeline runs a staged flow — **capture → evidence → refine → design → decision** — that takes a raw idea, gathers supporting evidence, refines it into something concrete, sketches a design, and terminates in an explicit **go / clarify / kill** verdict. A `go` feeds a well-formed problem into the existing `/speckit.specify` step; a `clarify` routes back for more information; a `kill` stops work before a line of spec is written. Its input is just an idea — pasted text, a URL, a ticket, or a codebase pointer — so the pipeline works **equally well on an empty, freshly-initialized project or on an existing codebase** (#3732); a team can evaluate a green idea before any scaffolding exists, or assess a change against a repo that already has one.
The feature drew the month's most prominent mainstream-press coverage: **heise online** ran *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German, framing `assess` as the notable addition of the 0.13 line alongside the Azure DevOps CLI support and the bundler/preset validation fixes. Coming from a major European technology outlet rather than a developer blog, it was a signal that Spec Kit's release cadence is now tracked as mainstream tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
### The Python Migration: the `py` Script Type
Spec Kit's second July theme was quieter but structurally important: the project began migrating its shell scripts to **Python**. The new **`py` script type** (#3285) joins `sh` (bash) and `ps` (PowerShell) as a third option at `specify init`, backed by Python-interpreter resolution that skips broken stubs (including the Windows Store `python3` alias, #3385). The `py` type is the project's answer to the perennial bash/PowerShell parity tax — every script fix previously had to be written twice and kept in sync, a recurring source of the Windows-parity bugs that filled prior months' changelogs.
Behind the new type, a systematic port landed piece by piece across the month: the **`update-agent-context`** updater (#3387), the **git extension scripts** (#3400), a **`check-prerequisites`** proof-of-concept (#3302), and finally the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386). The wheel was updated to bundle `scripts/python` so `--script py` works from a clean PyPI install (#3665), and the installation docs and init option table were updated to document the new type and the sh/ps migration plan (#3284, #3640). The end state is a single, cross-platform script implementation that removes an entire class of parity bugs. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent-Native Runtime Events
The v0.15.0 headline was a **first-class agent-native runtime events layer for integrations** (#3704). It bridges Spec Kit to the host agent's own lifecycle via a set of canonical, snake_case event names — `session_start`, `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `stop`, and `session_end`. A lightweight, zero-dependency **Event Dispatcher** (`.specify/events.py`) is scaffolded during `specify init`, and per-integration **Event Adapters** translate each canonical event into the agent's *native* hook configuration — `.github/hooks/speckit.json` (bash/PowerShell variants) for **Copilot CLI**, `.claude/settings.json` for Claude Code, `.cursor/hooks.json` for Cursor, `.codex/config.toml` for Codex, a TypeScript plugin for opencode, and native settings merges for Gemini, Qwen, Devin, and Tabnine — so extension authors declare `events:` in `extension.yml` once and never learn agent-specific names. Resolution is a four-tier stack (CLI `--events false` → user `.specify/integration-events.yml` override → extension-declared events → built-in defaults), and multiple extensions declaring the same event all run. The change accompanied a broader integration-refinement run: agents that use an always-slash invocation (Droid, Forge, Cline) now render hyphenated `/speckit-<name>` commands correctly (#3688, #3642, #3622), native skill-invocation prefixes are preserved (#3663), and several agents (kiro-cli, Lingma, Pi, omp) were declared multi-install-safe. The through-line is that integrations are increasingly *native* to each agent rather than a lowest-common-denominator overlay. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Security-Hardening Wave
The dominant engineering theme across all twenty-eight releases was **security and robustness**. The work fell into three bands. **Bounded I/O:** every catalog, download, and bundle HTTP response is now read under a byte cap with strict redirect validation on every hop (#3140, #3671, #3763, #3141), closing a class of unbounded-read / DoS exposure. **Race elimination:** TOCTOU races in file-unlink and state-file handling were removed (#3811, #3815, #3819), the extension URL-download cache was hardened against symlink and junction races (#3869), and registry file opens were pinned to UTF-8 (#3810, #3816). **Injection and input hardening:** the `shell` parameter was removed from `run_command` (#3716), user-supplied catalog metadata is escaped in every discovery/list/`init` output path (#3772, #3773, #3774, #3806, #3826, #3863), and catalog URLs are re-validated *after* redirects to preserve HTTPS/host guarantees (#3523, #3524).
Running alongside this was a systematic **"fail loudly, don't crash"** campaign across the workflow engine and catalog loaders: dozens of PRs replaced raw `ValueError`/`OverflowError`/crash paths with clean validation errors on malformed input — non-string commands, prompts, integrations, and models; non-list branches and `wait_for` entries; `priority: .inf` and boolean priorities; non-mapping manifest blocks; and superscript-digit gate prompts. The entire month's hardening arrived as prevention rather than response. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Workflow Engine & Bundles Mature
Beyond hardening, the **workflow engine** kept gaining capability. Steps can now read their **workflow source directory** (#3469), the shell and prompt steps got **configurable, validated timeouts** (#3404, #3847, #3768), a **gate verdict can bind to a workflow input** via `verdict_input` (#3725), and the **`WorkflowResolver`** was extracted as a standalone component (#3557). Two community workflows reached the catalog — the guided **`pipeline`** (#3338, which chains into the core `/speckit.converge`) and **`yolo`** (#3864) — bringing the standalone-workflow count to two.
The **bundle subsystem** introduced in June matured through a long tail of correctness work — reproducible builds via canonical POSIX arcnames (#3658), literal UTF-8 manifest dumps (#3660), strict rejection of malformed `requires`/`provides`/`integration`/`catalogs` blocks, and a clean `BundlerError` on malformed download URLs (#3586). **Community bundle submission automation** landed (#3553) and the **SicarioSpec Security & Governance Bundle** became a cataloged community bundle (#3636), making bundles a live community-submittable artifact type in practice. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent Integrations
The agent portfolio grew net **+3 to 37 integrations**. Three joined — **Grok Build** (#3535), **Factory Droid CLI** (#3587, closing the 300+-day #822), and **Alquimia AI** (#2734) — while **Roo Code** was retired as a shut-down product (#3212). **Bob** was migrated to a skills-based layout for Bob 2.0 (#3415), **Kilocode** now installs commands under `.kilo/commands` (#3672), and a broad correctness pass fixed hyphenated-command rendering and dispatch for the always-slash agents (Droid, Forge, Cline) and preserved native skill-invocation prefixes (#3663). The pattern continues from June — pruning dead products while making the surviving integrations more native to each agent. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension & Preset Ecosystem
The community extension catalog grew from 125 to **144 entries** during July — nineteen net additions. Community presets grew from 23 to **29**, community workflows reached **2**, and the first community **bundle** (SicarioSpec) was cataloged.
Notable new extensions by category:
- **Verification, drift & evidence**: Test Coverage Drift Control, PatchWarden Evidence Pack, Quality Gates (Enforcement Layer), Verify Review Ship, Intent Reconciliation
- **Requirements & intake**: EARS Requirements Syntax, the `assess` Idea Assessment Pipeline, Spec-Kit BDD, Charter
- **External trackers & round-trip**: Linear Weave, Multi-Repo Branch Sync, ContextForge MCP
- **Design & docs**: Spec Kit Figma, Figma Starter, Blueprint Index — Living Architecture Map, LLM Wiki, Dotdog
- **Knowledge & orchestration**: OKF Knowledge Bundle Generator, Orchestration Task Context Management, Spec Kit Memory
The catalog also showed strong maintenance activity: **DocGuard — CDD Enforcement** advanced through several releases (to v0.33.0), **Verify Review Ship** and **Quality Gates (Enforcement Layer)** iterated rapidly, and **Architecture Guard**, **Golden Demo**, **Coding Standards Drift Control**, **Ripple**, and the **Ralph Loop** all shipped updates. The preset side was the month's busiest: a large **governance-preset** family expanded and iterated — the **Autonomous Run Governance** and **Parallel Autonomous Run Governance** presets, a full **Intake** governance suite (Authoring, Review, Sequencing), **Test-First Governance**, and coordinated version bumps across the A11Y, Agent-Parity, Cross-Platform, iSAQB-Architecture, Architecture, and Security governance presets. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Documentation & Docs Site
July's documentation work paired the new features with a landing-page refresh. The **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648), the **`py` (Python) script type** was documented in the installation guide and init option table (#3284, #3625, #3640), and the **`__SPECKIT_COMMAND`** token for portable cross-command references was documented (#3503). The landing page was reframed to weave the **harness/SDLC framing** and modernize the install and positioning story (#3565, #3567), ecosystem stats were refreshed (#3561), and **`extensions.yml` hook configuration** was documented (#3563). Upgrade guidance clarified that project-file upgrades flow through `integration upgrade` / `extension update` (#3326) and that Claude Code files live in `.claude/skills` (#3708). [\[github.com\]](https://github.com/github/spec-kit/releases)
## Community & Content
### Press and Industry Coverage
July's coverage shifted from "what is SDD" explainers toward tool comparisons, companion tooling, and pointed "who verifies the spec?" critiques. No first-party Microsoft or GitHub post appeared in July; the nearest remained June's Microsoft Developer Blog piece.
**heise online** (Wolf Hosbach, July 21) was the month's most prominent mainstream-press coverage, publishing *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German — news coverage of the `assess` Idea Assessment Pipeline, the Azure DevOps CLI support, and the 0.13 validation fixes. Mainstream European tech press now tracks Spec Kit's minor releases as tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
**Towards AI** (Rost Glukhov, July 12) compared **GitHub Spec Kit vs Kiro vs Claude Code** on SDD workflow rather than model capability, part of a July-long current of "which SDD tool?" comparisons that increasingly pit Spec Kit specifically against Kiro. [\[pub.towardsai.net\]](https://pub.towardsai.net/github-spec-kit-vs-kiro-vs-claude-code-sdd-workflows-a9e7fab3e545)
**ranjankumar.in** (Ranjan Kumar, July 13) argued that four SDD frameworks — BMAD, Spec Kit, Kiro, and Superpowers — converge on the same structural "invariants," engaging Spec Kit's actual internals (`workflows.md`, run-state `state.json`) rather than treating it as a black box. [\[ranjankumar.in\]](https://ranjankumar.in/spec-driven-development-invariants-not-frameworks)
Release-trackers continued their factual coverage of the 0.13–0.15 run, and **Level Up Coding** (JingJing "Chris" Bao) published a three-part practitioner series on Spec Kit's presets, extensions, and pipeline/workflow features as the path beyond linear slash-commands. [\[levelup.gitconnected.com\]](https://levelup.gitconnected.com/from-linear-commands-to-automated-pipelines-how-spec-kit-orchestrates-nonlinear-ai-development-55ca03c5617b)
### The Companion-Tooling Ecosystem
July's most telling signal was not an article but a pattern: independent developers responded to Spec Kit's most-cited critiques by **building tools on top of it**. The recurring complaint — documentation proliferation and "who verifies the generated spec?" — turned into code.
- **artgraph** (mori-shin, July 20) — a deterministic, hash-based spec↔code drift-detection CLI with an `artgraph integrate speckit` hook, built specifically to give Spec Kit's LLM-prompt-based verification a deterministic backstop. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
- **SpecJudge** (Joaquín Ruiz, July 21) — a companion CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a *right-sized* model for the project. [\[dev.to\]](https://dev.to/jokiruiz/specjudge-which-ai-model-is-right-sized-for-your-project-ask-your-specs-2edp)
- **GAUNTLEX** (Sanjoy Ghosh, July 16) — named Spec Kit a leading SDD tool while arguing SDD leaves a security-testing gap, and shipped a tool to fill it. [\[hashnode.dev\]](https://sanjoy1234.hashnode.dev/the-testing-gap-nobody-s-talking-about-in-spec-driven-development)
- **Custom skills** — [`speckit-next`](https://qiita.com/htcd/items/ec76f2b7194be3297b93) (htcd, July 31), a skill that recommends the next command because the names and order are hard to remember, and [`prefill`](https://velog.io/@k3nta/ai-adoption-journey-1-tools) (k3nta, July 1), a skill that patches `clarify`'s blind spots.
Together these are the clearest evidence yet that Spec Kit has become a *platform* — its artifacts are stable enough, and its gaps well-enough understood, that a third-party tooling layer is forming around it. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
### Developer Articles and Blog Posts
July's articles skewed heavily multilingual — strong hands-on series in Japanese, Chinese, and Korean — with a clear thread of honest, use-it-in-anger critique.
Notable articles:
- **ta_kawano** (note.com, July 28–31) published a consolidated four-part **Kiro vs Spec Kit** head-to-head, completing a 108-task / 301-test build with Spec Kit where Kiro ran out of credit, praising measurable Success Criteria and auto-listed edge cases while flagging ~15,000 lines of generated documentation — and reframing Spec Kit as a requirements-elicitation tool. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
- **magebyte / 码哥字节** (SegmentFault, July 26) built a Go REST API through the five-step workflow, covered the three spec-persistence models and the extension/preset system, and claimed ~80% less AI "hallucination" rework. [\[segmentfault.com\]](https://segmentfault.com/a/1190000048082146)
- **Nil Seri** (Medium, July 16) published a brownfield guide adding Spec Kit to an existing Spring Boot / Maven project with Jira and Confluence integration — "from Jira ticket to verified code." [\[medium.com\]](https://medium.com/@senoritadeveloper/using-spec-kit-in-an-existing-spring-boot-maven-project-from-jira-ticket-to-verified-code-4e6da99b5d19)
- **kitroc7134** (Qiita, July 4) tested `/speckit.converge` with deliberate fault-injection on a FastAPI Todo API — detect drift → append convergence tasks → re-implement — validating June's convergence loop in the field. [\[qiita.com\]](https://qiita.com/kitroc7134/items/117d4839f259bc403626)
- **yutakaosada** (Zenn, July 25) — a Microsoft-MVP .NETラボ talk that uses Spec Kit but candidly flags AI-credit consumption, over-production of docs, and single-source-of-truth collapse, comparing it with Copilot Plan mode. [\[zenn.dev\]](https://zenn.dev/yutakaosada/articles/70e01981647159)
Additional coverage appeared on TechWealthBuzz, Hashnode, TabNews-adjacent outlets, CSDN and 腾讯云 (Chinese), Naver/velog/Tistory (Korean), and Qiita/note (Japanese) — including several "is it too heavy?" and documentation-proliferation critiques, and a [Korean instructor's piece](https://blog.naver.com/gaussian88/224363268926) citing Spec Kit's star growth from ~90k in May to ~120k in July. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
### Community Growth by the Numbers
| Metric | Start of July | End of July | Change |
| --- | --- | --- | --- |
| GitHub stars | 117,423 | 124,655 | +7,232 (+6%) |
| Forks | 10,382 | 11,125 | +743 |
| Contributors | 245 | ~258 | +~13 |
| Releases (total) | 177 | 205 | +28 (v0.12.3–v0.15.1) |
| Community extensions | 125 | 144 | +19 |
| Community presets | 23 | 29 | +6 |
| Community workflows | 1 | 2 | +1 |
| Community bundles | 1 | 1 | steady |
| Agent integrations | 34 | 37 | +3 (net) |
| Discussions (open) | 457 | ~467 | +~10 |
## SDD Ecosystem & Industry Trends
### From Tool to Platform
July's clearest ecosystem signal was structural: the conversation moved from "how do I use Spec Kit?" to "what do I build *around* it?" The companion tools — artgraph for deterministic drift, SpecJudge for model sizing, GAUNTLEX for the testing gap, and a growing set of custom agent skills — treat Spec Kit's artifacts (constitution, spec, tasks, run-state) as a stable substrate to build against. The public community catalog reinforces the point: the loudest theme across the 144 cataloged extensions is verification and quality (review, validate, drift, sync, verify, audit), and core SDD verbs are increasingly *re-expressed* by extensions rather than merely overridden — evidence of demand for composable, overridable core commands. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Competitive Landscape
The "which SDD tool?" comparison remained the dominant content genre, but July's framing narrowed: where June's surveys ran a seven-tool field, July's most substantive pieces increasingly went head-to-head **Spec Kit vs Kiro** (ta_kawano, [faruryo](https://qiita.com/faruryo/items/87a14728299e89f80ff4), Towards AI). The recurring verdict held — Spec Kit is the heaviest and most flexible option, strong on measurable Success Criteria, requirements elicitation, and greenfield decomposition, while its documentation proliferation and cognitive load are the consistent trade-off. The convergence-invariants analyses (ranjankumar.in) went further, arguing the frameworks are converging on the same structural primitives, which shifts the competitive question from "which tool" to "which ecosystem and governance model." On that axis, Spec Kit's widening catalog, agent-neutrality, and now a forming companion-tooling layer are its differentiators. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
## Roadmap
Areas under discussion or in progress for future development:
- **Upstream of the spec** — the `assess` Idea Assessment Pipeline extends SDD before the spec exists. Expect the capture→evidence→refine→design→decision flow to deepen, and the boundary between idea assessment and `/speckit.specify` to be a key area to refine as the pipeline sees real use. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
- **The Python migration** — the `py` script type and the port of the core scripts, git extension, and agent-context updater establish Python as the path out of the bash/PowerShell parity tax. Completing the port and making `py` a well-trodden default (rather than sh/ps) is the payoff: an entire class of Windows-parity bugs disappears. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Agent-native runtime events** — the first-class events layer lets integrations wire Spec Kit into each agent's own runtime through canonical event names and per-agent adapters. The layer is **actively evolving** — early signals point to opencode context injection and JSON-envelope agent hooks. Expect more agents to gain event adapters and extension authors to lean on the declarative `events:` surface as the integration layer shifts from lowest-common-denominator overlay to genuinely native behavior. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Copilot skills as the default** — July shipped a warning ahead of the skills-default rollout, and the signals now point to the **default flip being in progress** — moving `specify init --integration copilot` to the skills-based layout and making the default init integration overridable via an environment variable, with the markdown-command layout becoming the legacy path. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **A Copilot-native surface** — the first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) repo (a listed community friend) wraps the `specify` CLI as a **Copilot skills plugin** (nine skills across setup, init, extensions, presets, bundles, workflows, and self-upgrade) for the Copilot CLI and App, aligned to CLI v0.15.0. The emerging direction is a **visual, Copilot-driven surface** — early work explores canvas dashboards for the Spec-Driven Development flow, a Bug Fix Pipeline, and `assess` — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
- **The companion-tooling layer** — artgraph, SpecJudge, GAUNTLEX, and custom skills signal a third-party ecosystem forming on Spec Kit artifacts. The open question is whether the project absorbs these patterns (as it did drift → `converge`) or leaves them to the ecosystem; the verification/drift demand in the extension catalog suggests continued upstream pull. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Security and robustness as a standing discipline** — July's hardening wave (bounded reads, strict redirects, TOCTOU elimination, fail-loudly validation) shifted from feature to routine, and the signals point to it **continuing as an ongoing campaign** — a stdin read cap to close a DoS path and a broader atomicity push (atomic temp-file writes, subprocess timeouts, structured logging, and narrowed exception handlers). Sustaining the no-unbounded-read invariant as the surface (bundles, workflows, catalogs, events) grows is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across July's balanced reviews (ta_kawano, yutakaosada, and multiple Japanese/Korean pieces). The `assess` upstream gate, the lean/TinySpec presets, `/speckit.converge`, and the forming companion-tooling layer all provide answers; surfacing them to new users is the persistent opportunity. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
+6 -6
View File
@@ -1,14 +1,14 @@
# Spec Kit - June 2026 Newsletter
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June brought broad validation — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and coverage spanned dozens of languages. A summary is in the table below, followed by details.
| **Spec Kit Core (Jun 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. Press coverage spanned dozens of languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
***
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. Coverage appeared in dozens of languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
## Spec Kit Project Updates
@@ -84,7 +84,7 @@ On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Develop
### Press and Industry Coverage
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
June's press coverage spanned dozens of languages and platforms.
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
@@ -102,7 +102,7 @@ June was the **highest-volume coverage month on record — 75 substantive articl
### Developer Articles and Blog Posts
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
June's coverage skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
Notable English-language articles:
@@ -137,7 +137,7 @@ Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substac
### The Category Consolidates
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
Across June's broad article coverage, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
### Competitive Landscape
+2 -2
View File
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in May 2026 — a month defined by three m
| **Spec Kit Core (May 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and many more across multiple languages. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
***
@@ -76,7 +76,7 @@ May produced the broadest press coverage to date, with publications from the mai
### Developer Articles and Blog Posts
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
May produced a wave of independent coverage across many languages.
Notable non-English coverage:
+13
View File
@@ -59,6 +59,19 @@ Content resolution functions for composition:
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
### Constitution lifecycle
Initialization resolves `constitution-template` through the full stack and seeds
`.specify/memory/constitution.md` once. Existing files are preserved byte-for-byte. On subsequent
`/constitution` runs, the command resolves the current composed template at runtime and uses the live
constitution as the source of project-specific values and amendments.
Preset installation, removal, enablement, disablement, and priority changes do not materialize
`constitution-template` by default. When the enabled preset registry contains `constitution-sync`,
those operations may reconcile the live file, but only if its provenance hash proves it is still
generated content. Missing files may be seeded when the preset is installed; authored or edited
constitutions are never overwritten.
## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
+10
View File
@@ -15,6 +15,16 @@ If no preset is installed, core templates are used — exactly the same behavior
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
`constitution-template` follows the same runtime model. Project initialization seeds
`.specify/memory/constitution.md` once so downstream commands always have a constitution to read.
After that, installing, removing, enabling, disabling, or reprioritizing presets does not rewrite the
live constitution. Each `/constitution` run resolves the current composed `constitution-template`,
then applies existing project values and amendments to that scaffold.
Teams that intentionally want preset stack changes to refresh an unchanged generated constitution can
install the bundled `constitution-sync` preset. It restores guarded install-time materialization in
addition to its command-time propagation behavior; authored constitutions remain protected.
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Command Overrides
+30 -1
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-28T00:00:00Z",
"updated_at": "2026-08-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
@@ -541,6 +541,35 @@
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"model-routing-governance": {
"name": "Model Routing Governance",
"id": "model-routing-governance",
"version": "0.1.4",
"description": "Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-model-routing-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/archive/refs/tags/v0.1.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-model-routing-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/blob/v0.1.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 4,
"commands": 2,
"scripts": 2
},
"tags": [
"model-routing",
"agents",
"governance",
"provider-neutral",
"cross-platform"
],
"created_at": "2026-08-10T00:00:00Z",
"updated_at": "2026-08-10T00:00:00Z"
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-24T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {
"lean": {
@@ -30,7 +30,7 @@
"name": "Constitution Template Sync",
"id": "constitution-sync",
"version": "1.0.0",
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
"description": "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
+15 -6
View File
@@ -1,13 +1,15 @@
# Constitution Template Sync
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
project's own templates and command files. After you update the constitution, it aligns
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
An **opt-in** preset that restores materialized constitution workflows. It refreshes an unchanged
generated `.specify/memory/constitution.md` when constitution-providing presets are installed,
removed, enabled, disabled, or reprioritized. After `/constitution` updates the live file, it also
aligns `plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
guidance docs so they reflect the current principles.
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
preset model. Installing this preset opts you back into it: you get the guidance materialized into
reviewed, committed artifacts instead of relying on runtime resolution alone.
preset model. Installing this preset opts you back into materialization: preset stack changes refresh
the generated constitution, and `/constitution` propagates its guidance into reviewed, committed
artifacts instead of relying on runtime resolution alone.
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
@@ -28,7 +30,12 @@ versioned preset a core team maintains.
## What it does
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
Its presence enables core's guarded install-time constitution reconciliation. Installing the preset
materializes the currently resolved `constitution-template`; later stack changes re-materialize it
only while the live file still matches its recorded generated-content hash. Human edits disable
automatic replacement.
It also ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
appends a propagation pass that, after the constitution is written:
@@ -43,6 +50,8 @@ appends a propagation pass that, after the constitution is written:
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
constitution every run; this preset adds materialized copies on top — it does not replace the
source of truth.
- It does **not** overwrite an authored or edited constitution. Install-time reconciliation only
replaces content whose provenance proves it is an unchanged generated file.
- It does **not** edit versioned, package-owned files — templates or command files provided or
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
+1 -1
View File
@@ -4,7 +4,7 @@ preset:
id: "constitution-sync"
name: "Constitution Template Sync"
version: "1.0.0"
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
description: "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts."
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.15.2.dev0"
version = "0.16.2"
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"
+43 -8
View File
@@ -12,6 +12,7 @@
# --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation)
# --template NAME Include composed template content in JSON output
# --help, -h Show help message
#
# OUTPUTS:
@@ -26,9 +27,10 @@ JSON_MODE=false
REQUIRE_TASKS=false
INCLUDE_TASKS=false
PATHS_ONLY=false
TEMPLATE_NAME=""
for arg in "$@"; do
case "$arg" in
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
JSON_MODE=true
;;
@@ -41,6 +43,14 @@ for arg in "$@"; do
--paths-only)
PATHS_ONLY=true
;;
--template)
shift
if [[ $# -eq 0 ]]; then
echo "ERROR: --template requires a template name" >&2
exit 1
fi
TEMPLATE_NAME="$1"
;;
--help|-h)
cat << 'EOF'
Usage: check-prerequisites.sh [OPTIONS]
@@ -52,6 +62,7 @@ OPTIONS:
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--template NAME Include composed template content in JSON output
--help, -h Show this help message
EXAMPLES:
@@ -68,10 +79,11 @@ EOF
exit 0
;;
*)
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
exit 1
;;
esac
shift
done
# Source common functions
@@ -156,6 +168,16 @@ if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
docs+=("tasks.md")
fi
TEMPLATE_CONTENT=""
if [[ -n "$TEMPLATE_NAME" ]]; then
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
exit 1
fi
fi
# Output results
if $JSON_MODE; then
# Build JSON array of documents
@@ -165,10 +187,18 @@ if $JSON_MODE; then
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
if [[ -n "$TEMPLATE_NAME" ]]; then
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg template_content "$TEMPLATE_CONTENT" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}'
else
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
fi
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
@@ -176,7 +206,12 @@ if $JSON_MODE; then
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
if [[ -n "$TEMPLATE_NAME" ]]; then
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")"
else
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
fi
fi
else
# Text output
+322 -108
View File
@@ -398,6 +398,101 @@ json_escape() {
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
_python3_command() {
if command -v python3 >/dev/null 2>&1 &&
python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
printf '%s\n' "python3"
elif command -v python >/dev/null 2>&1 &&
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
printf '%s\n' "python"
elif command -v py >/dev/null 2>&1 &&
py -3 -c 'import sys' >/dev/null 2>&1; then
printf '%s\n' "py -3"
else
return 1
fi
}
_sorted_extension_ids() {
local ext_dir="$1"
local python_spec
if python_spec=$(_python3_command); then
local -a python_cmd
read -r -a python_cmd <<< "$python_spec"
local py_stderr sorted_ids
py_stderr=$(mktemp)
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
import json, os, re, sys
from pathlib import Path
root = Path(os.environ['SPECKIT_EXTENSIONS'])
registered = {}
registry = root / '.registry'
if os.path.lexists(registry):
if not registry.is_file():
print('registry_invalid: not a regular file', file=sys.stderr)
sys.exit(1)
try:
data = json.loads(registry.read_text(encoding='utf-8'))
except Exception as exc:
print('registry_invalid: ' + str(exc), file=sys.stderr)
sys.exit(1)
if not isinstance(data, dict):
print('registry_invalid: root must be a mapping', file=sys.stderr)
sys.exit(1)
raw_extensions = data.get('extensions', {})
if not isinstance(raw_extensions, dict):
print('registry_invalid: extensions must be a mapping', file=sys.stderr)
sys.exit(1)
registered = raw_extensions
def priority(value):
if isinstance(value, bool):
return 10
try:
parsed = int(value)
return parsed if parsed >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
ranked = []
for ext_id, meta in registered.items():
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
ranked.append((priority(meta.get('priority')), ext_id))
for path in root.iterdir():
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
ranked.append((10, path.name))
for _, ext_id in sorted(ranked):
print(ext_id)
" 2>"$py_stderr"); then
rm -f "$py_stderr"
printf '%s\n' "$sorted_ids"
return 0
else
echo "Error: invalid extension registry $ext_dir/.registry" >&2
rm -f "$py_stderr"
return 1
fi
fi
if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then
if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then
echo "Error: invalid extension registry $ext_dir/.registry" >&2
return 1
fi
echo "Error: Python 3 is required to honor the extension registry" >&2
return 2
fi
local ext extension_id
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
extension_id=$(basename "$ext")
case "$extension_id" in *[!a-z0-9-]*) continue ;; esac
printf '%s\n' "$extension_id"
done
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -408,6 +503,8 @@ resolve_template() {
local repo_root="$2"
local base="$repo_root/.specify/templates"
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
# Priority 1: Project overrides
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
@@ -416,19 +513,32 @@ resolve_template() {
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
local python_spec=""
local -a python_cmd=()
if python_spec=$(_python3_command); then
read -r -a python_cmd <<< "$python_spec"
fi
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
# Read preset IDs sorted by priority (lower number = higher precedence).
# The python3 call is wrapped in an if-condition so that set -e does not
# abort the function when python3 exits non-zero (e.g. invalid JSON).
local sorted_presets=""
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
import json, re, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
def priority(meta):
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
return 10
try:
value = int(meta.get('priority', 10))
return value if value >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
print(pid)
except Exception:
sys.exit(1)
@@ -438,6 +548,8 @@ except Exception:
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$presets_dir/$preset_id/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
@@ -447,6 +559,8 @@ except Exception:
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$preset/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
else
@@ -455,6 +569,8 @@ except Exception:
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
candidate="$preset/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
fi
@@ -462,13 +578,17 @@ except Exception:
# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
# Skip hidden directories (e.g. .backup, .cache)
case "$(basename "$ext")" in .*) continue;; esac
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
done <<< "$sorted_extensions"
fi
# Priority 4: Core templates
@@ -492,6 +612,8 @@ resolve_template_content() {
local repo_root="$2"
local base="$repo_root/.specify/templates"
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()
@@ -499,133 +621,206 @@ resolve_template_content() {
# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
if ! cat "$override"; then
echo "Error: failed to read template layer $override" >&2
return 2
fi
return 0
fi
local effective_base_found=false
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
local registry_parsed=false
local python_spec=""
local -a python_cmd=()
if python_spec=$(_python3_command); then
read -r -a python_cmd <<< "$python_spec"
fi
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
import json, re, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
def priority(meta):
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
return 10
try:
value = int(meta.get('priority', 10))
return value if value >= 1 else 10
except (TypeError, ValueError, OverflowError):
return 10
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
local yaml_warned=false
while IFS= read -r preset_id; do
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
registry_parsed=true
fi
fi
if [ "$registry_parsed" = false ]; then
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local fallback_id
fallback_id=$(basename "$preset")
case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac
sorted_presets+="${sorted_presets:+$'\n'}$fallback_id"
done
fi
if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
local manifest_declared=false
if [ -f "$manifest" ]; then
if [ "${#python_cmd[@]}" -eq 0 ]; then
echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2
return 2
fi
local result
local py_stderr
local parse_status
py_stderr=$(mktemp)
if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
sys.exit(2)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
yaml_warned=true
fi
rm -f "$py_stderr"
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
# Reject absolute paths and parent traversal
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
" 2>"$py_stderr"); then
parse_status=0
else
parse_status=$?
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
if [ "$parse_status" -ne 0 ]; then
if [ "$parse_status" -eq 2 ]; then
echo "Error: PyYAML is required to resolve preset template composition" >&2
else
echo "Error: invalid preset manifest $manifest" >&2
fi
rm -f "$py_stderr"
return 2
fi
if [ -n "$result" ]; then
local declaration
IFS=$'\t' read -r declaration strategy manifest_file <<< "$result"
[ "$declaration" = "found" ] && manifest_declared=true
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
rm -f "$py_stderr"
fi
done
local candidate=""
if [ -n "$manifest_file" ]; then
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
if [ -z "$candidate" ]; then
cf="$presets_dir/$preset_id/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
if [ "$strategy" = "replace" ]; then
effective_base_found=true
break
fi
fi
done <<< "$sorted_presets"
fi
fi
# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
local sorted_extensions=""
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
return 2
fi
while IFS= read -r extension_id; do
[ -n "$extension_id" ] || continue
local ext="$ext_dir/$extension_id"
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
effective_base_found=true
break
fi
done
done <<< "$sorted_extensions"
fi
# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi
@@ -642,12 +837,18 @@ except Exception:
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
if ! cat "${layer_paths[0]}"; then
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
return 2
fi
return 0
fi
if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
if ! cat "${layer_paths[0]}"; then
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
return 2
fi
return 0
fi
@@ -663,12 +864,16 @@ except Exception:
done
if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
echo "Error: template '$template_name' has composing layers but no replace base" >&2
return 2
fi
# Read the base content; compose layers above the base (higher priority)
local content
content=$(cat "${layer_paths[$base_idx]}"; printf x)
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
return 2
fi
content="${content%x}"
for (( i=base_idx-1; i>=0; i-- )); do
@@ -676,17 +881,26 @@ except Exception:
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
echo "Error: failed to read template layer $path" >&2
return 2
fi
layer_content="${layer_content%x}"
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
prepend)
content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x)
content="${content%x}"
;;
append)
content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x)
content="${content%x}"
;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
@@ -695,7 +909,7 @@ except Exception:
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
esac
done
+19 -4
View File
@@ -339,12 +339,27 @@ if [ "$DRY_RUN" != true ]; then
exit 1
fi
NEEDS_SPEC=false
SPEC_TEMPLATE_FOUND=false
SPEC_TEMPLATE_CONTENT=""
if [ ! -f "$SPEC_FILE" ]; then
NEEDS_SPEC=true
if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}"
SPEC_TEMPLATE_FOUND=true
else
resolve_status=$?
if [ "$resolve_status" -ne 1 ]; then
exit "$resolve_status"
fi
fi
fi
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
if [ "$NEEDS_SPEC" = true ]; then
if [ "$SPEC_TEMPLATE_FOUND" = true ]; then
printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
JSON_MODE=false
TEMPLATE_NAME=""
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 <template-name> [--json]"
exit 0
;;
-*)
echo "ERROR: Unknown option '$arg'" >&2
exit 1
;;
*)
if [[ -n "$TEMPLATE_NAME" ]]; then
echo "ERROR: Unexpected argument '$arg'" >&2
exit 1
fi
TEMPLATE_NAME="$arg"
;;
esac
done
if [[ -z "$TEMPLATE_NAME" ]]; then
echo "ERROR: Template name is required" >&2
exit 1
fi
REPO_ROOT=$(get_repo_root)
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
exit 1
fi
if $JSON_MODE; then
if has_jq; then
jq -cn \
--arg template_name "$TEMPLATE_NAME" \
--arg template_content "$TEMPLATE_CONTENT" \
'{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}'
else
printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")"
fi
else
printf '%s' "$TEMPLATE_CONTENT"
fi
+6 -4
View File
@@ -43,21 +43,23 @@ if [[ -f "$IMPL_PLAN" ]]; then
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
fi
else
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
if $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN" >&2
else
echo "Copied plan template to $IMPL_PLAN"
fi
else
resolve_status=$?
rm -f "$IMPL_PLAN"
if [ "$resolve_status" -ne 1 ]; then
exit "$resolve_status"
fi
if $JSON_MODE; then
echo "Warning: Plan template not found" >&2
else
echo "Warning: Plan template not found"
fi
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
fi
+7 -4
View File
@@ -51,7 +51,9 @@ fi
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}"
else
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
@@ -69,7 +71,8 @@ if $JSON_MODE; then
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
--arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
@@ -77,8 +80,8 @@ if $JSON_MODE; then
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
+18 -2
View File
@@ -12,6 +12,7 @@
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Template NAME Include composed template content in JSON output
# -Help, -h Show help message
[CmdletBinding()]
@@ -20,6 +21,7 @@ param(
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[string]$Template,
[switch]$Help
)
@@ -37,6 +39,7 @@ OPTIONS:
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Template NAME Include composed template content in JSON output
-Help, -h Show this help message
EXAMPLES:
@@ -129,13 +132,26 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
$templateContent = $null
if ($Template) {
$templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)")
exit 1
}
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
$result = [ordered]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
}
if ($Template) {
$result.TEMPLATE_CONTENT = $templateContent
}
[PSCustomObject]$result | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
+239 -82
View File
@@ -332,6 +332,82 @@ function Get-Python3Command {
return $null
}
function Get-NormalizedPriority {
param($Value)
if ($Value -is [bool]) { return 10 }
if ($Value -is [string]) {
$integerText = $Value.Trim()
if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 }
$Value = $integerText.Replace('_', '')
}
try {
$parsedPriority = [System.Numerics.BigInteger]$Value
} catch {
return 10
}
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
}
function Get-SortedExtensionIds {
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)
$registeredNames = @()
$ranked = @()
$registryFile = Join-Path $ExtensionsDir '.registry'
# Detect any filesystem entry at the registry path without following symlinks.
# Test-Path follows links and reports $false for a dangling symlink, so a
# broken .registry symlink would otherwise bypass this guard and let the
# directory scan below enable every on-disk extension. Enumerating the parent
# directory still observes a broken symlink as an entry.
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq '.registry' } |
Select-Object -First 1
if ($registryEntry) {
if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) {
throw "Invalid extension registry ${registryFile}: not a regular file"
}
try {
$data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
} catch {
throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)"
}
if ($null -eq $data -or $data -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: root must be a mapping"
}
$extensionsProperty = $data.PSObject.Properties['extensions']
if ($extensionsProperty) {
if ($extensionsProperty.Value -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping"
}
$extensions = $extensionsProperty.Value
} else {
$extensions = [PSCustomObject]@{}
}
$registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name })
foreach ($entry in $extensions.PSObject.Properties) {
if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) {
continue
}
$enabledProperty = $entry.Value.PSObject.Properties['enabled']
if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue }
$priority = 10
$priorityProperty = $entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
$priority = Get-NormalizedPriority -Value $priorityProperty.Value
}
$ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name }
}
}
foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) {
if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) {
$ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name }
}
}
return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id }
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
@@ -343,6 +419,8 @@ function Resolve-Template {
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null }
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
@@ -357,7 +435,7 @@ function Resolve-Template {
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
@@ -372,30 +450,20 @@ function Resolve-Template {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) { return $priorityProperty.Value }
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
if ($presetEntries.Count -gt 1) {
$allNumeric = $true
$allStrings = $true
foreach ($entry in $presetEntries) {
$priority = & $priorityFor $entry
if ($null -eq $priority -or $priority -isnot [ValueType]) {
$allNumeric = $false
}
if ($null -eq $priority -or $priority -isnot [string]) {
$allStrings = $false
}
}
if (-not $allNumeric -and -not $allStrings) {
throw 'Registry priorities are not mutually orderable'
}
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { & $priorityFor $_ } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
@@ -408,12 +476,16 @@ function Resolve-Template {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $preset.FullName "$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
@@ -421,8 +493,11 @@ function Resolve-Template {
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) { return $candidate }
}
}
@@ -443,6 +518,10 @@ function Resolve-TemplateContent {
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') {
return $null
}
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
@@ -452,49 +531,77 @@ function Resolve-TemplateContent {
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
$layerPaths += $override
$layerStrategies += 'replace'
return [System.IO.File]::ReadAllText(
$override,
[System.Text.Encoding]::UTF8
)
}
$effectiveBaseFound = $false
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
$sortedPresets = @()
$registryParsed = $false
}
}
if ($sortedPresets.Count -gt 0) {
$pyCmd = Get-Python3Command
if (-not $pyCmd) {
# Check if any preset has strategy fields that would be ignored
foreach ($pid in $sortedPresets) {
$mf = Join-Path $presetsDir "$pid/preset.yml"
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ($presetId in $sortedPresets) {
if (-not $registryParsed) {
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object Name |
ForEach-Object { $_.Name }
}
$pyCmd = @(Get-Python3Command)
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifestDeclared = $false
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and $pyCmd) {
if ((Test-Path $manifest) -and -not $pyCmd) {
throw "Python 3 and PyYAML are required to resolve preset template composition"
}
if (Test-Path $manifest) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
@@ -505,32 +612,71 @@ try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
sys.exit(2)
try:
with open(sys.argv[1]) as f:
with open(sys.argv[1], encoding='utf-8') as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
"@ $manifest $TemplateName 2>$pyStderrFile
if ($LASTEXITCODE -ne 0) {
if ($LASTEXITCODE -eq 2) {
throw "PyYAML is required to resolve preset template composition"
}
throw "Invalid preset manifest $manifest"
}
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 2
$strategy = $parts[0].ToLowerInvariant()
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
}
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
$parts = $stratResult.Trim() -split "`t", 3
$manifestDeclared = $parts[0] -eq 'found'
$strategy = $parts[1].ToLowerInvariant()
if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] }
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
throw
}
}
# Try manifest file path first, then convention path
@@ -545,42 +691,45 @@ except Exception:
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate) {
if (-not $candidate -and -not $manifestDeclared) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
if ($strategy -eq 'replace') {
$effectiveBaseFound = $true
break
}
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
$effectiveBaseFound = $true
break
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) {
if (-not $effectiveBaseFound -and (Test-Path $core)) {
$layerPaths += $core
$layerStrategies += 'replace'
}
@@ -590,7 +739,7 @@ except Exception:
# If the top (highest-priority) layer is replace, it wins entirely --
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Check if any layer uses a non-replace strategy
@@ -600,7 +749,7 @@ except Exception:
}
if (-not $hasComposition) {
return (Get-Content $layerPaths[0] -Raw)
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Find the effective base: scan from highest priority (index 0) downward
@@ -612,14 +761,22 @@ except Exception:
break
}
}
if ($baseIdx -lt 0) { return $null }
if ($baseIdx -lt 0) {
throw "Template '$TemplateName' has composing layers but no replace base"
}
$content = Get-Content $layerPaths[$baseIdx] -Raw
$content = [System.IO.File]::ReadAllText(
$layerPaths[$baseIdx],
[System.Text.Encoding]::UTF8
)
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = Get-Content $path -Raw
$layerContent = [System.IO.File]::ReadAllText(
$path,
[System.Text.Encoding]::UTF8
)
switch ($strat) {
'replace' { $content = $layerContent }
+8 -5
View File
@@ -262,13 +262,16 @@ if (-not $DryRun) {
exit 1
}
$needsSpec = -not (Test-Path -PathType Leaf $specFile)
$content = $null
if ($needsSpec) {
$content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
if ($needsSpec) {
if ($null -ne $content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env pwsh
param(
[Parameter(Position=0)]
[string]$TemplateName,
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Output "Usage: resolve-template.ps1 <template-name> [-Json]"
exit 0
}
if (-not $TemplateName) {
[Console]::Error.WriteLine("ERROR: Template name is required")
exit 1
}
. "$PSScriptRoot/common.ps1"
$repoRoot = Get-RepoRoot
$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot")
exit 1
}
if ($Json) {
[PSCustomObject]@{
TEMPLATE_NAME = $TemplateName
TEMPLATE_CONTENT = $templateContent
} | ConvertTo-Json -Compress
} else {
[Console]::Out.Write($templateContent)
}
+2 -4
View File
@@ -41,10 +41,8 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
}
} else {
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($null -ne $content) {
$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
+8 -2
View File
@@ -57,12 +57,17 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if ($null -eq $tasksTemplateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
} else {
$tasksTemplate = ''
}
# Output results
if ($Json) {
@@ -70,6 +75,7 @@ if ($Json) {
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
TASKS_TEMPLATE = $tasksTemplate
TASKS_TEMPLATE_CONTENT = $tasksTemplateContent
} | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
+76 -8
View File
@@ -9,10 +9,22 @@ from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -28,6 +40,7 @@ OPTIONS:
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--template NAME Include composed template content in JSON output
--help, -h Show this help message
EXAMPLES:
@@ -49,6 +62,7 @@ class Args:
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
template_name: str | None = None
def _parse_args(argv: list[str]) -> Args:
@@ -56,8 +70,11 @@ def _parse_args(argv: list[str]) -> Args:
require_tasks = False
include_tasks = False
paths_only = False
template_name = None
for arg in argv:
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
@@ -66,6 +83,15 @@ def _parse_args(argv: list[str]) -> Args:
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg == "--template":
index += 1
if index >= len(argv):
print(
"ERROR: --template requires a template name",
file=sys.stderr,
)
raise SystemExit(1)
template_name = argv[index]
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
@@ -75,12 +101,14 @@ def _parse_args(argv: list[str]) -> Args:
file=sys.stderr,
)
raise SystemExit(1)
index += 1
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
template_name=template_name,
)
@@ -130,14 +158,31 @@ def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
print(f"TASKS: {paths.tasks}")
def _status_marker(ok: bool) -> str:
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console - a pipe or a file redirect, which is how agents and workflow steps
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
raised UnicodeEncodeError and aborted the report right after
"AVAILABLE_DOCS:". "[OK]"/"[FAIL]" is the ASCII rendering these markers
already have in-tree: see Test-FileExists in scripts/powershell/common.ps1
and normalize_status_text in tests/parity_helpers.py.
"""
glyph = "✓" if ok else "✗"
try:
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
except (LookupError, UnicodeEncodeError):
return "[OK]" if ok else "[FAIL]"
return glyph
def _check_file(path: Path, description: str) -> None:
marker = "✓" if path.is_file() else "✗"
print(f" {marker} {description}")
print(f" {_status_marker(path.is_file())} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "✓" if _dir_has_entries(path) else "✗"
print(f" {marker} {description}")
print(f" {_status_marker(_dir_has_entries(path))} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
@@ -194,9 +239,32 @@ def main(argv: list[str] | None = None) -> int:
return 1
docs = _available_docs(paths, args.include_tasks)
template_content = None
if args.template_name:
try:
template_content = resolve_template_content(
args.template_name, paths.repo_root
)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if template_content is None:
print(
f"ERROR: Could not resolve required {args.template_name} from "
f"the template override stack for {paths.repo_root}",
file=sys.stderr,
)
return 1
if args.json_mode:
payload: dict[str, object] = {
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
}
if args.template_name:
payload["TEMPLATE_CONTENT"] = template_content
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
_json_line(payload)
)
else:
_print_text_results(paths, args.include_tasks)
+280 -19
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -182,12 +183,30 @@ def get_feature_paths(
)
_SAFE_COMPONENT_PATTERN = re.compile(r"[a-z0-9-]+")
def _is_safe_component(value: object) -> bool:
return (
isinstance(value, str)
and _SAFE_COMPONENT_PATTERN.fullmatch(value) is not None
)
def _normalize_priority(value: object) -> int:
if isinstance(value, bool):
return 10
try:
priority = int(value)
except (TypeError, ValueError, OverflowError):
return 10
return priority if priority >= 1 else 10
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
registry = presets_dir / ".registry"
if registry.is_file():
# Mirrors bash: any failure while reading or sorting the registry
# (invalid JSON, non-dict shapes, unorderable priority values) falls
# back to the directory scan below.
# Invalid JSON or registry shapes fall back to the directory scan below.
try:
data = json.loads(registry.read_text(encoding="utf-8"))
presets = data.get("presets", {})
@@ -195,11 +214,18 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
pid
for pid, meta in sorted(
presets.items(),
key=lambda kv: kv[1].get("priority", 10)
if isinstance(kv[1], dict)
else 10,
key=lambda kv: (
_normalize_priority(kv[1].get("priority"))
if isinstance(kv[1], dict)
else 10,
kv[0],
),
)
if (
_is_safe_component(pid)
and isinstance(meta, dict)
and bool(meta.get("enabled", True))
)
if isinstance(meta, dict) and meta.get("enabled", True) is not False
]
except Exception:
pass
@@ -207,12 +233,78 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
return sorted(
p.name
for p in presets_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
if p.is_dir() and _is_safe_component(p.name)
)
except OSError:
return []
def _sorted_extension_ids(extensions_dir: Path) -> list[str]:
registry = extensions_dir / ".registry"
registered_ids: set[str] = set()
extensions: dict[object, object] = {}
if os.path.lexists(registry):
if not registry.is_file():
raise TemplateResolutionError(
f"Invalid extension registry {registry}: not a regular file"
)
try:
data = json.loads(registry.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TemplateResolutionError(
f"Failed to parse extension registry {registry}: {exc}"
) from exc
if not isinstance(data, dict):
raise TemplateResolutionError(
f"Invalid extension registry {registry}: root must be a mapping"
)
raw_extensions = data.get("extensions", {})
if not isinstance(raw_extensions, dict):
raise TemplateResolutionError(
f"Invalid extension registry {registry}: "
"'extensions' must be a mapping"
)
extensions = raw_extensions
registered_ids = {
ext_id for ext_id in extensions if isinstance(ext_id, str)
}
ranked: list[tuple[int, str]] = []
for ext_id, metadata in extensions.items():
if (
_is_safe_component(ext_id)
and isinstance(metadata, dict)
and bool(metadata.get("enabled", True))
):
ranked.append((_normalize_priority(metadata.get("priority")), ext_id))
try:
ranked.extend(
(10, path.name)
for path in extensions_dir.iterdir()
if (
path.is_dir()
and _is_safe_component(path.name)
and path.name not in registered_ids
)
)
except OSError:
pass
return [ext_id for _, ext_id in sorted(ranked)]
def _conventional_template(
base_dir: Path, template_name: str
) -> Path | None:
for candidate in (
base_dir / "templates" / f"{template_name}.md",
base_dir / f"{template_name}.md",
):
if candidate.is_file():
return candidate
return None
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
"""Resolve a template name to a file path using the priority stack.
@@ -222,6 +314,9 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
4. .specify/templates/ (core)
"""
if not _is_safe_component(template_name):
return None
base = repo_root / ".specify" / "templates"
override = base / "overrides" / f"{template_name}.md"
@@ -231,21 +326,18 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
presets_dir = repo_root / ".specify" / "presets"
if presets_dir.is_dir():
for preset_id in _sorted_preset_ids(presets_dir):
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
if candidate.is_file():
candidate = _conventional_template(
presets_dir / preset_id, template_name
)
if candidate is not None:
return candidate
ext_dir = repo_root / ".specify" / "extensions"
if ext_dir.is_dir():
try:
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
except OSError:
extensions = []
for ext in extensions:
if ext.name.startswith("."):
continue
candidate = ext / "templates" / f"{template_name}.md"
if candidate.is_file():
for extension_id in _sorted_extension_ids(ext_dir):
ext = ext_dir / extension_id
candidate = _conventional_template(ext, template_name)
if candidate is not None:
return candidate
core = base / f"{template_name}.md"
@@ -254,6 +346,175 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
return None
class TemplateResolutionError(RuntimeError):
"""Raised when template layers exist but cannot be composed safely."""
# Mirror the canonical PresetManifest contract (see src/specify_cli/presets)
# so runtime resolution rejects the same structurally malformed manifests.
_VALID_TEMPLATE_TYPES = ("template", "command", "script")
_VALID_TEMPLATE_STRATEGIES = ("replace", "prepend", "append", "wrap")
_VALID_SCRIPT_STRATEGIES = ("replace", "wrap")
def _validate_manifest_template_entry(entry: object) -> None:
"""Validate a single manifest template entry against the canonical rules."""
if not isinstance(entry, dict):
raise ValueError("manifest template entries must be mappings")
if "type" not in entry or "name" not in entry or "file" not in entry:
raise ValueError("manifest template entry missing type, name, or file")
for field in ("type", "name", "file"):
if not isinstance(entry[field], str):
raise ValueError(f"manifest template {field} must be a string")
if entry["type"] not in _VALID_TEMPLATE_TYPES:
raise ValueError(f"invalid manifest template type '{entry['type']}'")
strategy = entry.get("strategy", "replace")
if not isinstance(strategy, str):
raise ValueError("manifest template strategy must be a string")
strategy = strategy.lower()
if strategy not in _VALID_TEMPLATE_STRATEGIES:
raise ValueError(f"invalid manifest template strategy '{strategy}'")
if entry["type"] == "script" and strategy not in _VALID_SCRIPT_STRATEGIES:
raise ValueError(
f"invalid manifest script strategy '{strategy}'"
)
def _preset_template_layer(
preset_dir: Path, template_name: str
) -> tuple[Path, str] | None:
"""Return the preset template path and composition strategy."""
manifest_path = preset_dir / "preset.yml"
conventional = _conventional_template(preset_dir, template_name)
try:
import yaml
except ImportError as exc:
if manifest_path.is_file():
raise TemplateResolutionError(
"PyYAML is required to resolve preset template composition"
) from exc
return (conventional, "replace") if conventional is not None else None
if manifest_path.is_file():
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict):
raise ValueError("manifest root must be a mapping")
if "provides" not in manifest:
raise ValueError("manifest missing provides section")
provides = manifest["provides"]
if not isinstance(provides, dict):
raise ValueError("manifest provides must be a mapping")
if "templates" not in provides:
raise ValueError("manifest provides missing templates")
templates = provides["templates"]
if not isinstance(templates, list):
raise ValueError("manifest templates must be a list")
if not templates:
raise ValueError("manifest must provide at least one template")
for entry in templates:
_validate_manifest_template_entry(entry)
for entry in templates:
if (
entry.get("name") != template_name
or entry.get("type", "template") != "template"
):
continue
file_value = entry.get("file", "")
strategy = entry.get("strategy", "replace")
relative = Path(file_value)
if (
not relative
or relative.is_absolute()
or ".." in relative.parts
):
return None
candidate = preset_dir / relative
if not candidate.is_file():
return None
return candidate, strategy.lower()
except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc:
raise TemplateResolutionError(
f"Failed to parse preset manifest {manifest_path}: {exc}"
) from exc
return (conventional, "replace") if conventional is not None else None
def resolve_template_content(template_name: str, repo_root: Path) -> str | None:
"""Resolve and compose template content through the project layer stack."""
if not _is_safe_component(template_name):
return None
layers: list[tuple[Path, str]] = []
def compose_from_base() -> str:
try:
content = layers[-1][0].read_bytes().decode("utf-8")
for path, strategy in reversed(layers[:-1]):
layer_content = path.read_bytes().decode("utf-8")
if strategy == "prepend":
content = f"{layer_content}\n\n{content}"
elif strategy == "append":
content = f"{content}\n\n{layer_content}"
elif strategy == "wrap":
placeholder = "{CORE_TEMPLATE}"
if placeholder not in layer_content:
raise TemplateResolutionError(
f"Wrap layer {path} is missing {placeholder}"
)
content = layer_content.replace(placeholder, content)
else:
raise TemplateResolutionError(
f"Unknown template composition strategy '{strategy}' in {path}"
)
except (OSError, UnicodeError) as exc:
raise TemplateResolutionError(
f"Failed to read template layer for '{template_name}': {exc}"
) from exc
return content
override = (
repo_root
/ ".specify"
/ "templates"
/ "overrides"
/ f"{template_name}.md"
)
if override.is_file():
layers.append((override, "replace"))
return compose_from_base()
presets_dir = repo_root / ".specify" / "presets"
for preset_id in _sorted_preset_ids(presets_dir):
layer = _preset_template_layer(presets_dir / preset_id, template_name)
if layer is not None:
layers.append(layer)
if layer[1] == "replace":
return compose_from_base()
extensions_dir = repo_root / ".specify" / "extensions"
for extension_id in _sorted_extension_ids(extensions_dir):
extension_dir = extensions_dir / extension_id
candidate = _conventional_template(extension_dir, template_name)
if candidate is not None:
layers.append((candidate, "replace"))
return compose_from_base()
core = repo_root / ".specify" / "templates" / f"{template_name}.md"
if core.is_file():
layers.append((core, "replace"))
return compose_from_base()
if not layers:
return None
raise TemplateResolutionError(
f"Template '{template_name}' has composing layers but no replace base"
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
+26 -7
View File
@@ -7,16 +7,25 @@ import datetime
import json
import re
import shlex
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import get_repo_root, persist_feature_json, resolve_template
from common import (
TemplateResolutionError,
get_repo_root,
persist_feature_json,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_repo_root, persist_feature_json, resolve_template
from common import (
TemplateResolutionError,
get_repo_root,
persist_feature_json,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -374,12 +383,22 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
template_content = None
needs_spec = not spec_file.is_file()
if needs_spec:
try:
template_content = resolve_template_content(
"spec-template", repo_root
)
except TemplateResolutionError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
feature_dir.mkdir(parents=True, exist_ok=True)
if not spec_file.is_file():
template = resolve_template("spec-template", repo_root)
if template is not None and template.is_file():
shutil.copy(template, spec_file)
if needs_spec:
if template_content is not None:
spec_file.write_bytes(template_content.encode("utf-8"))
else:
print(
"Warning: Spec template not found; created empty spec file",
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Resolve composed template content from the project template stack."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
try:
from common import (
TemplateResolutionError,
get_repo_root,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
TemplateResolutionError,
get_repo_root,
resolve_template_content,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("template_name")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
repo_root = get_repo_root(Path(__file__))
try:
content = resolve_template_content(args.template_name, repo_root)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if content is None:
print(
f"ERROR: Could not resolve required {args.template_name} from the "
f"template override stack for {repo_root}",
file=sys.stderr,
)
return 1
if args.json:
print(
json.dumps(
{
"TEMPLATE_NAME": args.template_name,
"TEMPLATE_CONTENT": content,
},
ensure_ascii=False,
separators=(",", ":"),
)
)
else:
sys.stdout.write(content)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17 -6
View File
@@ -4,15 +4,22 @@
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
try:
from common import get_feature_paths, resolve_template
from common import (
TemplateResolutionError,
get_feature_paths,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_feature_paths, resolve_template
from common import (
TemplateResolutionError,
get_feature_paths,
resolve_template_content,
)
def _json_line(payload: object) -> str:
@@ -55,9 +62,13 @@ def main(argv: list[str] | None = None) -> int:
file=status_stream,
)
else:
template = resolve_template("plan-template", paths.repo_root)
if template is not None and template.is_file():
shutil.copy(template, paths.impl_plan)
try:
template_content = resolve_template_content("plan-template", paths.repo_root)
except TemplateResolutionError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if template_content is not None:
paths.impl_plan.write_bytes(template_content.encode("utf-8"))
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
else:
print("Warning: Plan template not found", file=status_stream)
+38 -8
View File
@@ -10,17 +10,21 @@ from pathlib import Path
try:
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template,
resolve_template_content,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
FeaturePaths,
TemplateResolutionError,
format_speckit_command,
get_feature_paths,
resolve_template,
resolve_template_content,
)
@@ -55,14 +59,31 @@ def _available_docs(paths: FeaturePaths) -> list[str]:
return docs
def _status_marker(ok: bool) -> str:
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console - a pipe or a file redirect, which is how agents and workflow steps
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
raised UnicodeEncodeError and aborted the report mid-listing.
"[OK]"/"[FAIL]" is the ASCII rendering these markers already have in-tree:
see Test-FileExists in scripts/powershell/common.ps1 and
normalize_status_text in tests/parity_helpers.py.
"""
glyph = "✓" if ok else "✗"
try:
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
except (LookupError, UnicodeEncodeError):
return "[OK]" if ok else "[FAIL]"
return glyph
def _check_file(path: Path, description: str) -> None:
marker = "✓" if path.is_file() else "✗"
print(f" {marker} {description}")
print(f" {_status_marker(path.is_file())} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "✓" if _dir_has_entries(path) else "✗"
print(f" {marker} {description}")
print(f" {_status_marker(_dir_has_entries(path))} {description}")
def main(argv: list[str] | None = None) -> int:
@@ -103,8 +124,14 @@ def main(argv: list[str] | None = None) -> int:
docs = _available_docs(paths)
tasks_template = resolve_template("tasks-template", paths.repo_root)
if tasks_template is None or not tasks_template.is_file():
try:
tasks_template_content = resolve_template_content(
"tasks-template", paths.repo_root
)
except TemplateResolutionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if tasks_template_content is None:
print(
"ERROR: Could not resolve required tasks-template from the template "
f"override stack for {paths.repo_root}",
@@ -121,18 +148,21 @@ def main(argv: list[str] | None = None) -> int:
return 1
if json_mode:
tasks_template = resolve_template("tasks-template", paths.repo_root)
sys.stdout.write(
_json_line(
{
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
"TASKS_TEMPLATE": str(tasks_template),
"TASKS_TEMPLATE": str(tasks_template) if tasks_template else "",
"TASKS_TEMPLATE_CONTENT": tasks_template_content,
}
)
)
else:
tasks_template = resolve_template("tasks-template", paths.repo_root)
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"TASKS_TEMPLATE: {tasks_template}")
print(f"TASKS_TEMPLATE: {tasks_template or 'not found'}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
+2
View File
@@ -77,7 +77,9 @@ from ._version import (
from ._agent_config import (
AGENT_CONFIG as AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR as DEFAULT_INIT_INTEGRATION_ENV_VAR,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
resolve_default_init_integration as resolve_default_init_integration,
)
from ._init_options import (
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
+32
View File
@@ -1,6 +1,8 @@
"""Agent configuration constants derived from the integration registry."""
from __future__ import annotations
import os
import sys
from typing import Any
@@ -17,6 +19,36 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot"
#: Environment variable used to override the fallback integration that
#: ``specify init`` selects in non-interactive sessions. Follows the existing
#: ``SPECKIT_INTEGRATION_*`` namespace (see ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE``
#: and ``SPECKIT_INTEGRATION_CATALOG_URL``).
DEFAULT_INIT_INTEGRATION_ENV_VAR = "SPECKIT_INTEGRATION_DEFAULT"
def resolve_default_init_integration() -> str:
"""Return the default init integration, honoring an env-var override.
Reads :data:`DEFAULT_INIT_INTEGRATION_ENV_VAR`
(``SPECKIT_INTEGRATION_DEFAULT``). When it names a registered integration
key, that key is returned; otherwise the hardcoded
:data:`DEFAULT_INIT_INTEGRATION` (``"copilot"``) is used. An invalid value
emits a warning to stderr rather than silently falling back, so operators
can tell a typo from an intentional default.
"""
override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip()
if not override:
return DEFAULT_INIT_INTEGRATION
if override in AGENT_CONFIG:
return override
print(
f"Warning: {DEFAULT_INIT_INTEGRATION_ENV_VAR}='{override}' is not a "
f"recognized integration; falling back to '{DEFAULT_INIT_INTEGRATION}'. "
f"Choose from: {', '.join(sorted(AGENT_CONFIG.keys()))}.",
file=sys.stderr,
)
return DEFAULT_INIT_INTEGRATION
SCRIPT_TYPE_CHOICES: dict[str, str] = {
"sh": "POSIX Shell (bash/zsh)",
"ps": "PowerShell",
+21 -3
View File
@@ -10,6 +10,7 @@ import struct
import tarfile
import unicodedata
import zipfile
import zlib
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
@@ -69,6 +70,19 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
#: Decompression failures a truncated or corrupt gzip stream raises from
#: ``tarfile``. Most are wrapped in ``TarError``, but two escape raw, and
#: neither derives from ``TarError`` or ``OSError``, so both bypass a
#: ``(TarError, OSError)`` handler:
#:
#: * ``EOFError`` -- from the gzip layer when the stream ends before its
#: end-of-stream marker, i.e. a truncated archive.
#: * ``zlib.error`` -- from a corrupt deflate block. ``tarfile`` converts this
#: to ``ReadError`` while reading a member *header*, but the forward seek it
#: performs to skip member *data* sits outside that conversion, so a corrupt
#: region past the first header escapes raw.
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
"application/gzip": "tar.gz",
"application/x-gzip": "tar.gz",
@@ -166,7 +180,11 @@ def detect_archive_format(
try:
with tarfile.open(fileobj=archive_file, mode="r:gz"):
is_tar_gz = True
except tarfile.TarError:
except _TAR_DECOMPRESSION_ERRORS:
# A truncated gzip stream raises a bare EOFError here rather
# than a TarError, so catching only TarError let it escape
# this probe as a raw exception instead of leaving
# ``is_tar_gz`` False and reporting the format mismatch.
pass
archive_file.seek(0)
except OSError as exc:
@@ -1077,7 +1095,7 @@ def safe_extract_tar(
mode="r:gz",
fileobj=archive_file,
)
except (tarfile.TarError, OSError) as exc:
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
with archive:
@@ -1149,7 +1167,7 @@ def safe_extract_tar(
f"of {max_total_bytes} bytes",
)
validated.append((member, normalized_name, is_dir))
except (tarfile.TarError, OSError) as exc:
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
_raise_from(
error_type,
f"Invalid tar.gz archive: {archive_path}",
+1 -1
View File
@@ -9,7 +9,7 @@ and ``specify init``'s next-steps output stay consistent.
from __future__ import annotations
# Agents that render $speckit-<name> (chat invocation) when in skills mode.
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"})
# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
+2 -2
View File
@@ -192,8 +192,8 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
os.replace(temp_path, target_file)
except Exception:
if temp_path and temp_path.exists():
temp_path.unlink()
if temp_path:
temp_path.unlink(missing_ok=True)
raise
try:
+6 -2
View File
@@ -157,7 +157,11 @@ class CommandRegistrar:
return ""
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
fm,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=float("inf"),
)
return f"---\n{yaml_str}---\n"
@@ -679,7 +683,7 @@ class CommandRegistrar:
_integ = get_integration(agent_name)
if _integ is not None:
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
except Exception:
except (ImportError, ValueError, KeyError):
pass
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
+53 -23
View File
@@ -45,7 +45,12 @@ def _fail(message: str) -> None:
"""Print an actionable error to stderr and exit non-zero."""
# 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)
# Escape the message: every caller passes ``str(exc)`` from a BundlerError
# that interpolates untrusted data (a CLI argument, a catalog url, a
# bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag
# -- silently swallowing the text, or raising MarkupError on an unbalanced
# closer and replacing the whole message with a traceback.
err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None)
raise typer.Exit(code=1)
@@ -131,13 +136,13 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
def _resolve_init_integration(override: str | None, manifest) -> str:
"""Precedence (FR-013): explicit override → bundle-declared → default."""
from ..._agent_config import DEFAULT_INIT_INTEGRATION
from ..._agent_config import resolve_default_init_integration
if override:
return override
if manifest is not None and manifest.integration is not None:
return manifest.integration.id
return DEFAULT_INIT_INTEGRATION
return resolve_default_init_integration()
# ===== Consume =====
@@ -332,9 +337,10 @@ def bundle_list(
console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n")
for record in records:
console.print(
f" [bold]{record.bundle_id}[/bold] v{record.version} "
f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] "
f"v{_escape_markup(str(record.version))} "
f"[dim]({len(record.contributed_components)} components, "
f"installed {record.installed_at})[/dim]"
f"installed {_escape_markup(str(record.installed_at))})[/dim]"
)
@@ -394,13 +400,13 @@ def bundle_install(
)
console.print(
f"[cyan]No Spec Kit project here; initializing with integration "
f"'{init_integration}'…[/cyan]"
f"'{_escape_markup(str(init_integration))}'…[/cyan]"
)
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
project_root = require_project_root()
for overlap in _bundle_overlaps(project_root, manifest, offline=offline):
console.print(f"[yellow]![/yellow] {overlap}")
console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}")
# For an already-initialized project, the project's recorded active
# integration is authoritative — an explicit --integration must not be
@@ -415,7 +421,7 @@ def bundle_install(
integration_explicit=bool(integration) and detected is None,
)
for warning in plan.warnings:
console.print(f"[yellow]![/yellow] {warning}")
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
result = install_bundle(
project_root,
@@ -428,7 +434,7 @@ def bundle_install(
return
console.print(
f"[green]✓[/green] Installed '{result.bundle_id}' "
f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' "
f"({len(result.installed)} added, {len(result.skipped)} already present)."
)
@@ -480,7 +486,10 @@ def bundle_update(
integration_explicit=bool(integration) and detected is None,
)
install_bundle(project_root, plan, installer, manifest=manifest, refresh=True)
console.print(f"[green]✓[/green] Updated '{target}' to v{plan.version}.")
console.print(
f"[green]✓[/green] Updated '{_escape_markup(str(target))}' "
f"to v{_escape_markup(str(plan.version))}."
)
except BundlerError as exc:
_fail(str(exc))
return
@@ -502,7 +511,7 @@ def bundle_remove(
return
console.print(
f"[green]✓[/green] Removed '{result.bundle_id}' "
f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' "
f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)."
)
@@ -542,13 +551,16 @@ def bundle_validate(
return
for warning in report.warnings:
console.print(f"[yellow]![/yellow] {warning}")
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
if not report.ok:
console.print("[red]Manifest is invalid:[/red]")
for error in report.errors:
console.print(f" [red]-[/red] {error}")
console.print(f" [red]-[/red] {_escape_markup(str(error))}")
raise typer.Exit(code=1)
console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.")
console.print(
f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} "
"is well-formed and valid."
)
@bundle_app.command("build")
@@ -571,8 +583,9 @@ def bundle_build(
return
console.print(
f"[green]✓[/green] Built {result.artifact_path.name} "
f"({result.file_count} files) → {result.artifact_path}"
f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} "
f"({result.file_count} files) → "
f"{_escape_markup(str(result.artifact_path))}"
)
@@ -591,7 +604,7 @@ def bundle_init(
init_integration = _resolve_init_integration(integration, None)
console.print(
f"[cyan]Initializing a Spec Kit project with integration "
f"'{init_integration}'…[/cyan]"
f"'{_escape_markup(str(init_integration))}'…[/cyan]"
)
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
project_root = require_project_root()
@@ -599,7 +612,10 @@ def bundle_init(
_fail(str(exc))
return
console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.")
console.print(
f"[green]✓[/green] Spec Kit project ready at "
f"{_escape_markup(str(project_root))}."
)
if bundle:
bundle_install(bundle, integration=integration, offline=offline)
@@ -623,10 +639,11 @@ def catalog_list() -> None:
only_builtin = all(s.scope == Scope.BUILTIN for s in sources)
for source in sources:
console.print(
f" [bold]{source.id}[/bold] priority={source.priority} "
f" [bold]{_escape_markup(str(source.id))}[/bold] "
f"priority={source.priority} "
f"policy={source.install_policy.value} scope={source.scope.value}"
)
console.print(f" [dim]{source.url}[/dim]")
console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]")
if only_builtin:
console.print("\n[dim]Using the built-in default stack.[/dim]")
@@ -651,7 +668,7 @@ def catalog_add(
return
console.print(
f"[green]✓[/green] Added catalog '{source.id}' "
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
f"(priority {source.priority}, {source.install_policy.value})."
)
@@ -670,7 +687,10 @@ def catalog_remove(
_fail(str(exc))
return
console.print(f"[green]✓[/green] Removed catalog source '{removed}'.")
console.print(
f"[green]✓[/green] Removed catalog source "
f"'{_escape_markup(str(removed))}'."
)
# ZIP magic-byte signatures used to detect .zip payloads from REST API asset
@@ -771,7 +791,17 @@ def _local_manifest_source(arg: str):
error_type=BundlerError,
label="bundle manifest",
)
data = _yaml.safe_load(io.BytesIO(raw))
try:
data = _yaml.safe_load(io.BytesIO(raw))
except _yaml.YAMLError as exc:
# The sibling directory/bundle.yml branches reach YAML through
# load_yaml(), which turns a parse failure into a BundlerError. This
# branch parses inline, so without this it raises a raw YAMLError --
# neither a ValueError nor an OSError -- which escapes
# bundle_install()'s `except BundlerError` as a traceback.
raise BundlerError(
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
) from exc
return BundleManifest.from_dict(data)
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
+13 -2
View File
@@ -24,8 +24,19 @@ def event_run(
"""Resolve and run an event-driven command script with stdin payload."""
from ..events import resolve_and_run_event_command
# Read payload from stdin if available
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
MAX_STDIN_BYTES = 1 * 1024 * 1024
if not sys.stdin.isatty():
raw = sys.stdin.read(MAX_STDIN_BYTES)
if not sys.stdin.eof:
raise typer.Exit(
code=1,
message="stdin payload exceeds 1 MiB limit; "
"truncate or pipe a smaller payload",
)
payload = raw
else:
payload = "{}"
# Run the event command
project_root = Path.cwd() # The agent runs events from project root
+69 -20
View File
@@ -3,19 +3,22 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
import typer
from rich.live import Live
from rich.markup import escape as _escape_markup
from rich.panel import Panel
from .._agent_config import (
AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from .._assets import (
_locate_bundled_preset,
@@ -169,6 +172,25 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
return f"{manifest.name} v{manifest.version} installed"
def _shell_quote_arg(value: str) -> str:
"""Quote *value* as one argument for the shells of the host OS.
The Next Steps ``cd`` line is copy-pasted into whichever shell ran
``specify init``, so it is quoted for the host the same way
``_version._render_argv`` renders its copy-pasteable installer command:
``list2cmdline`` on Windows, ``shlex.quote`` elsewhere. The Windows branch
must emit double quotes -- ``cd 'my project'`` is a path-not-found in
cmd.exe, while ``cd "my project"`` is accepted by cmd.exe, PowerShell and
Git Bash alike. A value needing no quoting is returned unchanged.
Whitespace only. PowerShell also glob-expands ``[``/``]`` and expands
``$``/backtick inside double quotes, so such a name still needs
``Set-Location -LiteralPath`` there -- syntax invalid in cmd.exe and sh, so
this shell-neutral line cannot cover it.
"""
return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value)
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
@@ -351,7 +373,10 @@ def register(app: typer.Typer) -> None:
if integration:
resolved_integration = get_integration(integration)
if not resolved_integration:
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
console.print(
f"[red]Error:[/red] Unknown integration: "
f"'{_escape_markup(str(integration))}'"
)
available = ", ".join(sorted(INTEGRATION_REGISTRY))
console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1)
@@ -428,26 +453,27 @@ def register(app: typer.Typer) -> None:
project_path = Path(project_name).resolve()
dir_existed_before = project_path.exists()
if project_path.exists():
safe_name = _escape_markup(str(project_name))
if not project_path.is_dir():
console.print(
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
f"[red]Error:[/red] '{safe_name}' exists but is not a directory."
)
raise typer.Exit(1)
existing_items = list(project_path.iterdir())
if force:
if existing_items:
console.print(
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
f"[yellow]Warning:[/yellow] Directory '{safe_name}' is not empty ({len(existing_items)} items)"
)
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print(
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]"
)
else:
error_panel = Panel(
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
f"Directory already exists: '[cyan]{safe_name}[/cyan]'\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
@@ -461,22 +487,23 @@ def register(app: typer.Typer) -> None:
if integration:
if integration not in AGENT_CONFIG:
console.print(
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
f"[red]Error:[/red] Invalid integration '{_escape_markup(str(integration))}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
)
raise typer.Exit(1)
selected_ai = integration
elif not _stdin_is_interactive():
default_integration = resolve_default_init_integration()
console.print(
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
"Use --integration to choose a different agent.[/dim]"
)
selected_ai = DEFAULT_INIT_INTEGRATION
selected_ai = default_integration
else:
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
selected_ai = select_with_arrows(
ai_choices,
"Choose your coding agent integration:",
DEFAULT_INIT_INTEGRATION,
resolve_default_init_integration(),
)
if not integration:
@@ -499,12 +526,14 @@ def register(app: typer.Typer) -> None:
setup_lines = [
"[cyan]Specify Project Setup[/cyan]",
"",
f"{'Project':<15} [green]{project_path.name}[/green]",
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
f"{'Project':<15} [green]{_escape_markup(project_path.name)}[/green]",
f"{'Working Path':<15} [dim]{_escape_markup(str(current_dir))}[/dim]",
]
if not here:
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
setup_lines.append(
f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]"
)
console.print(
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
@@ -531,7 +560,7 @@ def register(app: typer.Typer) -> None:
if script_type:
if script_type not in SCRIPT_TYPE_CHOICES:
console.print(
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
f"[red]Error:[/red] Invalid script type '{_escape_markup(str(script_type))}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
)
raise typer.Exit(1)
selected_script = script_type
@@ -570,8 +599,6 @@ def register(app: typer.Typer) -> None:
tracker.add(key, label)
if extensions:
from rich.markup import escape as _escape_markup
for i, ext_spec in enumerate(extensions):
tracker.add(
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
@@ -634,6 +661,30 @@ def register(app: typer.Typer) -> None:
)
manifest.save()
if force:
from ..integrations._helpers import (
_register_extensions_for_agent,
_register_presets_for_agent,
)
_register_extensions_for_agent(
project_path,
resolved_integration.key,
force=True,
continuing=(
"The project was re-initialized, but installed extensions"
" may need re-registration."
),
)
_register_presets_for_agent(
project_path,
resolved_integration.key,
continuing=(
"The project was re-initialized, but installed presets"
" may need re-registration."
),
)
integration_settings = _with_integration_setting(
{},
resolved_integration.key,
@@ -802,8 +853,6 @@ def register(app: typer.Typer) -> None:
# Install extensions specified via --extension
if extensions:
from rich.markup import escape as _escape_markup
from ..extensions._commands import _refresh_events_and_warn
speckit_ver = get_speckit_version()
@@ -893,7 +942,7 @@ def register(app: typer.Typer) -> None:
if agent_folder:
security_notice = Panel(
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
f"Consider adding [cyan]{_escape_markup(str(agent_folder))}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
title="[yellow]Agent Folder Security[/yellow]",
border_style="yellow",
padding=(1, 2),
@@ -904,7 +953,7 @@ def register(app: typer.Typer) -> None:
steps_lines = []
if not here:
steps_lines.append(
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
f"1. Go to the project folder: [cyan]cd {_escape_markup(_shell_quote_arg(str(project_name)))}[/cyan]"
)
step_num = 2
else:
+288 -33
View File
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
return [str(script_abs), *rest]
def _run_inline(command_name, payload, project_root, timeout):
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
"""Resolve and run the event command with stdlib only (no specify_cli)."""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout):
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -283,6 +283,46 @@ def _run_inline(command_name, payload, project_root, timeout):
return 2
def _emit(output, envelope, native_event=""):
"""Write handler output to stdout in the agent's context-injection shape.
Not every agent injects a hook's plain-text stdout as model context:
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
user-facing noise, never context), Copilot discards non-JSON stdout, and
Cursor parses stdout as JSON. The native hook command passes the envelope
as the dispatcher's 5th argument (see events_context_envelope on the
integration classes), and the native event name as the 6th argument so
hookSpecificOutput can include hookEventName:
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
additionalContext → {"additionalContext": ...} (top-level, Copilot)
additional_context → {"additional_context": ...} (top-level, Cursor)
suppress → emit nothing (strict-JSON agents on events whose
output can't be used)
plain (default) → passthrough (Claude/Codex inject plain stdout)
Empty output emits nothing under any envelope (an empty additionalContext
is useless noise).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
return
sys.stdout.write(output)
def main():
if len(sys.argv) < 3:
sys.exit(0)
@@ -297,6 +337,16 @@ def main():
timeout = int(sys.argv[3])
except (TypeError, ValueError):
timeout = 120
# Optional 5th arg: context-injection envelope for stdout (C13): plain
# (default), hookSpecificOutput, additionalContext, additional_context,
# or suppress. Unknown values fall back to plain passthrough.
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
envelope = "plain"
# Optional 6th arg: native event name for hookSpecificOutput's
# hookEventName field (required by Qwen's hooks spec; included by
# Gemini/Tabnine/Devin which derive from the same protocol).
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
project_root = Path(__file__).parent.parent.resolve()
@@ -307,14 +357,14 @@ def main():
from specify_cli.events import resolve_and_run_event_command
sys.exit(
resolve_and_run_event_command(
command_name, _event_name, payload, project_root, timeout=timeout
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
)
)
except ImportError:
except (ImportError, TypeError):
pass
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
sys.exit(_run_inline(command_name, payload, project_root, timeout))
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
if __name__ == "__main__":
@@ -362,17 +412,21 @@ function resolveDispatcher(directory: string): void {{
) as string;
}}
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
if (!DISPATCHER) return;
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
if (!DISPATCHER) return '';
try {{
// execFileSync with an argv array invokes the interpreter directly — no
// shell — so command/event strings with metacharacters can't break out
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
// execFileSync timeout is ms with a buffer so the outer cap fires after
// the dispatcher's inner subprocess (S3).
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
// the dispatcher's inner subprocess (S3). stdout is captured and
// returned so context-injection hooks (experimental.chat.system.transform,
// chat.message) can push it into their outputs; stderr stays inherited so
// dispatcher errors remain visible (C11).
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
input: JSON.stringify({{ input, output }}),
stdio: ['pipe', 'inherit', 'inherit'],
stdio: ['pipe', 'pipe', 'inherit'],
encoding: 'utf-8',
timeout: (timeoutSec + {buffer}) * 1000,
}});
}} catch (e) {{
@@ -382,6 +436,12 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
}}
}}
// Cache session_start handler output per sessionID so non-idempotent
// handlers (setup, telemetry, file-mutating scripts) run once per session
// instead of on every LLM request (experimental.chat.system.transform
// fires per LLM turn). Evicted on session.deleted.
const sessionStartCache = new Map<string, string>();
{event_entries}
export default (async ({{ client, project, directory, $ }}) => {{
@@ -488,7 +548,15 @@ def _resolve_event_command_argv(
"""
from .integrations.base import IntegrationBase
content = template_path.read_text(encoding="utf-8")
try:
content = template_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# An unreadable or undecodable template cannot declare a runnable
# script. Degrade to "no argv" like every other failure in this
# resolver (missing frontmatter, malformed YAML, absent scripts)
# instead of leaking a raw traceback through
# resolve_and_run_event_command.
return None
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m:
return None
@@ -524,7 +592,13 @@ def _resolve_event_command_argv(
else:
base = project_root / ".specify"
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
try:
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
except ValueError:
# Mirror the generated dispatcher's _resolve_argv: a scripts: value
# shlex cannot tokenize (e.g. an unclosed quote) declares no runnable
# script, so degrade to "no argv" instead of raising.
return None
if not tokens:
return None
script_abs = base / tokens[0]
@@ -585,12 +659,26 @@ def resolve_and_run_event_command(
project_root: Path,
*,
timeout: int = 120,
envelope: str = "plain",
native_event: str = "",
) -> int:
"""Core entry point to resolve and execute an event-driven command.
*timeout* is the per-handler timeout in seconds, passed through from the
native hook config via the dispatcher (S4) so a handler configured above
the previous fixed 120s cap can run for its full duration.
*envelope* selects how the handler's stdout is emitted for the agent's
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
output can't be used).
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
used when *envelope* is ``hookSpecificOutput``.
"""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -610,7 +698,7 @@ def resolve_and_run_event_command(
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit_event_stdout(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -624,6 +712,35 @@ def resolve_and_run_event_command(
return 2
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
"""Write handler stdout in the agent's context-injection shape (C13).
Mirrors the ``_emit`` helper inside the generated dispatcher template;
keep both in sync. Empty output emits nothing under any envelope.
*native_event* is the agent's native hookEventName, required inside
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
return
sys.stdout.write(output)
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
# Resolved events map: each canonical event name maps to an *ordered list* of
@@ -737,8 +854,10 @@ def resolve_events(
if override_file.exists():
try:
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("Could not parse %s; ignoring override", override_file)
except (OSError, UnicodeError, yaml.YAMLError):
logger.warning(
"Could not read or parse %s; ignoring override", override_file
)
override = {}
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
if isinstance(integrations, dict) and integration_key in integrations:
@@ -1016,7 +1135,17 @@ def _dispatcher_command(
When *timeout_seconds* is given, the resolved timeout (in the
integration's native unit) is appended as a 4th argument so the dispatcher
and inner runner honor the per-handler timeout instead of a fixed 120s cap
that would kill a handler configured for longer (S4).
that would kill a handler configured for longer (S4). When omitted, a
default of 60s is emitted so the positional argument order
(command event timeout envelope native_event) stays aligned — otherwise
the envelope would land in the timeout slot and the dispatcher would
silently fall back to plain stdout.
When the integration declares a context-injection envelope for this
canonical event (``events_context_envelope``, C13), the envelope token is
appended as a 5th argument so the dispatcher wraps stdout in the JSON
shape the agent's hook protocol requires. Plain-passthrough agents
(Claude/Codex) declare no envelope and get no extra argument.
"""
if target_os == "host":
interpreter = _resolve_interpreter(project_root)
@@ -1036,16 +1165,44 @@ def _dispatcher_command(
# operator. Prefix & for the explicit windows target only.
prefix = "& " if target_os == "windows" else ""
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
if timeout_seconds is not None:
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
# Always emit the timeout (4th positional arg) so the dispatcher's argv
# parsing stays aligned when an envelope (5th) or native_event (6th)
# follows. Without it the envelope would land in the timeout slot and
# the dispatcher would fall back to plain stdout (R3).
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
envelope = _context_envelope_for(integration, event_name)
if envelope:
base += f" {_shell_quote(envelope, target_os)}"
# hookSpecificOutput requires the native hookEventName inside the
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
# Append the native event name as a 6th dispatcher argument so the
# dispatcher can populate hookEventName in the JSON output.
if envelope == "hookSpecificOutput":
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
if native_event:
base += f" {_shell_quote(native_event, target_os)}"
return base
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
"""Resolve the context-injection envelope for an integration + event (C13).
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
integration declares no envelope for the event (plain stdout passthrough).
"""
mapping = getattr(integration, "events_context_envelope", None) or {}
if canonical_event in mapping:
return mapping[canonical_event]
return mapping.get("*")
def install_integration_events(
integration: IntegrationBase,
project_root: Path,
@@ -1193,11 +1350,12 @@ def install_integration_events(
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
lines.append('speckit_marker = true')
lines.append('')
_merge_toml_fragment(config_path, "\n".join(lines))
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
# S5: only track when the merge wrote (skips on unreadable file).
if _merge_toml_fragment(config_path, "\n".join(lines)):
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "json-flat":
# Cursor hooks.json custom merge. Flat command-string entries, one
@@ -1588,6 +1746,14 @@ def _build_opencode_plugin(
an argv array (C9). Both the ``input`` and ``output`` callback arguments
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
arguments and post_tool_use can inspect the result.
Context-injection natives get dedicated hook bodies: for
``experimental.chat.system.transform`` the handlers' concatenated stdout
is pushed into ``output.system`` (system-prompt injection, re-applied per
LLM request so the context survives compaction); for ``chat.message`` it
is pushed as a synthetic text part on the user message (C11). Other
natives keep their side-effect behavior (tool.execute.* args mutation;
session.* lifecycle events via the generic ``event`` hook).
"""
event_entries: list[str] = []
plugin_returns: list[str] = []
@@ -1602,11 +1768,16 @@ def _build_opencode_plugin(
ev_lit = json.dumps(ev)
native_lit = json.dumps(native)
is_injection = native in ("experimental.chat.system.transform", "chat.message")
# Build the body: one runEvent() call per handler wrapped in try/catch,
# forwarding both input and output (C7). An optional tool-name matcher
# guard applies to tool.execute.* hooks. All handlers execute before
# any aggregate error is thrown.
# any aggregate error is thrown. Injection hooks additionally collect
# each handler's stdout and return the concatenation.
body_lines: list[str] = [" const errors: string[] = [];"]
if is_injection:
body_lines.append(" const contexts: string[] = [];")
for cfg in handlers:
command = str(cfg.get("command", ""))
command_lit = json.dumps(command)
@@ -1628,6 +1799,10 @@ def _build_opencode_plugin(
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
)
elif is_injection:
body_lines.append(
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
)
else:
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
@@ -1646,14 +1821,65 @@ def _build_opencode_plugin(
f" _{ev}(input, output);\n"
f" }},"
)
elif native == "experimental.chat.system.transform":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# OpenCode fires experimental.chat.system.transform for non-session
# operations (e.g. agent generation) with no sessionID. Guard so
# canonical session_start handlers only run when a session is
# present, preventing their output from being injected into
# internal prompts. Cache the handler output per sessionID so
# non-idempotent handlers (setup, telemetry, file-mutating
# scripts) execute once per session instead of on every LLM
# request; the cache is evicted on session.deleted.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" if (!input.sessionID) return;\n"
f" let ctx = sessionStartCache.get(input.sessionID);\n"
f" if (ctx === undefined) {{\n"
f" ctx = _{ev}(input, output);\n"
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
f" }}\n"
f" if (ctx) output.system.push(ctx);\n"
f" }},"
)
elif native == "chat.message":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Part id must start with "prt" (opencode's Identifier brand): an
# invalid id fails the user-part schema validation and crashes the
# whole session (C12). Derive it from the last existing part so the
# brand survives an opencode prefix change, falling back to "prt_"
# when output.parts is empty.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" const ctx = _{ev}(input, output);\n"
f" if (!ctx) return;\n"
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
f" }},"
)
else:
event_entries.append(
f"function _{ev}(input: any, output: any) {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Evict the sessionStartCache when the session is deleted so the
# cache doesn't grow unbounded across sessions.
eviction = ""
if native == "session.deleted":
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
event_handlers.append(
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
)
if event_handlers:
@@ -1715,11 +1941,27 @@ def _remove_opencode_entries(config_path: Path) -> bool:
return False
def _merge_toml_fragment(dst: Path, fragment: str) -> None:
def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
"""Merge Specify-owned TOML entries into *dst*, regenerating the file.
An unreadable or undecodable pre-existing file aborts the merge instead
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
Returns False when skipped so callers avoid tracking the untouched file
(S5).
"""
_ensure_safe_destination(dst)
existing = ""
if dst.exists():
existing = dst.read_text(encoding="utf-8")
try:
existing = dst.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
logger.warning(
"Could not read %s (it may be unreadable or not UTF-8); "
"skipping event-config merge to preserve user content.",
dst,
)
logger.debug("Read error detail: %s", exc)
return False
existing = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
@@ -1728,6 +1970,7 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> None:
)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
return True
def _remove_toml_entries(dst: Path) -> bool:
@@ -1741,7 +1984,19 @@ def _remove_toml_entries(dst: Path) -> bool:
# the config after install can't make teardown overwrite a file outside
# the project (the merge/write path already validates; teardown must too).
_ensure_safe_destination(dst)
existing = dst.read_text(encoding="utf-8")
try:
existing = dst.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
# An unreadable or undecodable file is left untouched rather than
# crashing teardown — it contains only user content as far as we can
# tell, and the caller drops the manifest claim either way (S9).
logger.warning(
"Could not read %s (it may be unreadable or not UTF-8); "
"skipping event-config cleanup to preserve user content.",
dst,
)
logger.debug("Read error detail: %s", exc)
return False
cleaned = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
+395 -15
View File
@@ -61,6 +61,13 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset(
)
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
# Naming pattern for provides.templates / provides.scripts entries. Unlike
# commands, these are not namespaced (they aren't invoked via a command
# name), so they follow the same plain slug pattern as extension.id.
VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$")
VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"})
VALID_EFFECTS = frozenset({"read-only", "read-write"})
DEFAULT_HOOK_PRIORITY = 10
@@ -285,9 +292,25 @@ class ExtensionManifest:
raise ValidationError(
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
)
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a ValidationError, so it
# escapes every caller that already handles a malformed manifest (see
# list_installed()'s "Corrupted extension" fallback, which catches
# ValidationError only, making one bad extension exit ``specify
# extension list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
if not isinstance(ext[field], str):
raise ValidationError(
f"Invalid extension.{field}: expected a string, "
f"got {type(ext[field]).__name__}"
)
# Validate extension ID format
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
@@ -325,6 +348,25 @@ class ExtensionManifest:
)
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
# Presence alone is not enough: check_compatibility() feeds this value to
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
# which a non-string escapes two different ways. A float/int/bool/None
# raises TypeError from the constructor, while a list or dict is an
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
# inside .contains(). Neither is a CompatibilityError, so both bypass the
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
# a non-empty string here.
if (
not isinstance(requires["speckit_version"], str)
or not requires["speckit_version"].strip()
):
raise ValidationError(
"Invalid requires.speckit_version: expected a non-empty string, "
f"got {type(requires['speckit_version']).__name__}"
)
# Validate provides section
provides = self.data["provides"]
@@ -333,11 +375,17 @@ class ExtensionManifest:
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
)
commands = provides.get("commands", [])
templates = provides.get("templates", [])
scripts = provides.get("scripts", [])
hooks = self.data.get("hooks")
events = self.data.get("events")
if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list")
if "templates" in provides and not isinstance(templates, list):
raise ValidationError("Invalid provides.templates: expected a list")
if "scripts" in provides and not isinstance(scripts, list):
raise ValidationError("Invalid provides.scripts: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping")
if "events" in self.data:
@@ -347,9 +395,17 @@ class ExtensionManifest:
has_commands = bool(commands)
has_hooks = bool(hooks)
has_events = bool(events)
has_templates = bool(templates)
has_scripts = bool(scripts)
if not has_commands and not has_hooks and not has_events:
raise ValidationError("Extension must provide at least one command, hook, or event")
if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts:
raise ValidationError(
"Extension must provide at least one command, hook, or event "
"(or a declared template/script)"
)
self._validate_provided_artifacts(templates, section="templates", singular="template")
self._validate_provided_artifacts(scripts, section="scripts", singular="script")
# Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
@@ -391,6 +447,16 @@ class ExtensionManifest:
)
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
# The pattern match below would raise a bare TypeError on a
# non-string name (``name: 2``), escaping the ValidationError
# contract. The 'file' field needs no check here:
# relative_extension_path_violation() below already rejects a
# non-string value.
if not isinstance(cmd["name"], str):
raise ValidationError(
f"Invalid command name: expected a string, "
f"got {type(cmd['name']).__name__}"
)
# Validate the 'file' field at manifest-load time using the single
# shared policy in relative_extension_path_violation(), so manifest
@@ -500,6 +566,80 @@ class ExtensionManifest:
f"The extension author should update the manifest."
)
@staticmethod
def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None:
"""Validate provides.templates / provides.scripts entries.
Mirrors the shape/path-safety checks PresetManifest applies to its
non-command templates, minus 'type' (the section name already
distinguishes template vs script) and 'strategy' (extension-provided
artifacts are always 'replace' -- see the forced-replace resolver
behavior for extension layers in presets/__init__.py). A present
'strategy' key is rejected rather than silently ignored, so an author
who copies a preset-style entry gets a clear error instead of a
silently-dropped field. Duplicate names within a section are also
rejected: the resolver returns the first matching entry by name
(``PresetResolver._extension_manifest_declared_template``), so a
later duplicate would be silently unreachable while still being
exposed by ``ExtensionManifest.templates``/``.scripts``.
"""
seen_names: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
raise ValidationError(
f"Each entry in 'provides.{section}' must be a mapping"
)
if "name" not in entry or "file" not in entry:
raise ValidationError(f"{singular.capitalize()} missing 'name' or 'file'")
name = entry["name"]
if not isinstance(name, str):
raise ValidationError(
f"Invalid {singular} name: expected a string, got {type(name).__name__}"
)
if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(name):
raise ValidationError(
f"Invalid {singular} name '{name}': "
"must be lowercase alphanumeric with hyphens only"
)
if name in seen_names:
raise ValidationError(
f"Duplicate {singular} name '{name}' in 'provides.{section}'"
)
seen_names.add(name)
file_value = entry["file"]
reason = relative_extension_path_violation(file_value)
if reason:
label = repr(file_value) if isinstance(file_value, str) else f"for {singular} '{name}'"
raise ValidationError(f"Invalid {singular} 'file' {label}: {reason}")
if "description" in entry and not isinstance(entry["description"], str):
raise ValidationError(
f"Invalid {singular} description for '{name}': expected a string"
)
if "strategy" in entry:
raise ValidationError(
f"Invalid {singular} entry '{name}': 'strategy' is not authorable for "
"extension-provided artifacts, which always use 'replace' semantics"
)
if section == "scripts" and "runtimes" in entry:
runtimes = entry["runtimes"]
if not isinstance(runtimes, list) or not all(
isinstance(r, str) for r in runtimes
):
raise ValidationError(
f"Invalid runtimes for script '{name}': expected a list of strings"
)
invalid = sorted(set(runtimes) - VALID_SCRIPT_RUNTIMES)
if invalid:
raise ValidationError(
f"Invalid runtimes {invalid} for script '{name}': "
f"must be one of {sorted(VALID_SCRIPT_RUNTIMES)}"
)
@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
@@ -562,6 +702,24 @@ class ExtensionManifest:
"""Get list of provided commands."""
return self.data.get("provides", {}).get("commands", [])
@property
def config(self) -> List[Dict[str, Any]]:
"""Get list of provided config templates, normalized to dictionaries."""
raw = self.data.get("provides", {}).get("config", [])
if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw):
return []
return raw
@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of declared templates (provides.templates)."""
return self.data.get("provides", {}).get("templates", [])
@property
def scripts(self) -> List[Dict[str, Any]]:
"""Get list of declared scripts (provides.scripts)."""
return self.data.get("provides", {}).get("scripts", [])
@property
def hooks(self) -> Dict[str, Any]:
"""Get hook definitions."""
@@ -597,6 +755,13 @@ class ExtensionRegistry:
if not self.registry_path.exists():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
# A non-regular file (e.g. a directory at the registry path) is not a
# readable registry. Recover to empty so construction — used by the
# install/enable/disable flows — does not crash. Resolution paths that
# must fail closed consult is_corrupt() instead of relying on this.
if not self.registry_path.is_file():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
@@ -607,10 +772,47 @@ class ExtensionRegistry:
if not isinstance(data.get("extensions"), dict):
data["extensions"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh. A registry whose
# bytes cannot be decoded as UTF-8 is the same corruption class as
# malformed JSON — only the exception type differs, and it is
# raised by the text-mode read before JSON parsing begins. OSError
# is deliberately not caught: the data may be intact on disk, and
# starting fresh would let a later _save() wipe it.
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
def is_corrupt(self) -> bool:
"""Report whether an existing registry file is present but unreadable.
``_load`` deliberately recovers from a corrupt registry by normalizing
it to an empty mapping so install/enable/disable flows keep working.
Resolution paths, however, must fail closed: a corrupt registry that
normalizes to ``{}`` would otherwise cause every on-disk extension
directory to be admitted as an unregistered, enabled extension. This
probe lets those callers distinguish "no registry" (safe) from
"registry exists but is invalid" (unsafe) without changing recovery
behavior. An absent registry returns ``False``; a directory, broken
or dangling symlink, non-regular file, unreadable file, non-mapping
root, or non-mapping ``extensions`` value returns ``True``.
"""
# os.path.lexists (not Path.exists) so a dangling symlink is detected
# rather than followed to a non-existent target and mistaken for an
# absent registry — which would reopen the fail-open directory scan.
if not os.path.lexists(self.registry_path):
return False
if not self.registry_path.is_file():
return True
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return True
if not isinstance(data, dict):
return True
if "extensions" in data and not isinstance(data["extensions"], dict):
return True
return False
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
@@ -1817,6 +2019,17 @@ class ExtensionManager:
required = manifest.requires_speckit_version
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
# Defense in depth: the manifest validator now rejects a non-string
# requires.speckit_version, but this method is public and also reachable
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
# cover a non-string -- scalars raise TypeError from the constructor, and
# a list/dict is iterable so it constructs here and only breaks inside
# .contains(). Reject up front so this always reports a CompatibilityError.
if not isinstance(required, str):
raise CompatibilityError(
"Invalid version specifier: expected a string, got "
f"{type(required).__name__} ({required!r})"
)
try:
SpecifierSet(required) # Just to validate
except InvalidSpecifier:
@@ -2059,8 +2272,19 @@ class ExtensionManager:
_staged_modes = _loaded_modes
for staged_name in sorted(staged_names):
staged_file = rescue_staging_dir / staged_name
staged_stat = staged_file.stat()
staged_bytes = staged_file.read_bytes()
# A staged backup that cannot be read or stat'ed must not
# crash the retry with a raw OSError: like an uncomparable
# live config below, treat it as a conflict so both copies
# are preserved and the user resolves it while dest_dir is
# still untouched. Every sibling read in this path (live
# twin, packaged baseline, mode sidecar) already catches
# OSError.
try:
staged_stat = staged_file.stat()
staged_bytes = staged_file.read_bytes()
except OSError:
conflicting.add(staged_name)
continue
# Prefer the sidecar-recorded mode; fall back to the staged
# file's own mode for backwards-compat with staging dirs
# written before the sidecar was introduced.
@@ -2152,10 +2376,24 @@ class ExtensionManager:
"a regular file or remove it — then reinstall."
)
if cfg_file.is_file():
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)
# A kept config that cannot be read or stat'ed must not
# crash the reinstall with a raw OSError — and must not
# reach the rmtree below unrescued. Like the symlink
# guard above, reject while dest_dir is untouched so the
# preserved bytes are never lost.
try:
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)
except OSError as exc:
raise ValidationError(
"Preserved extension config for "
f"'{manifest.id}' cannot be read "
f"({cfg_file.name}) in {dest_dir}: {exc}. "
"Resolve manually — fix its permissions or "
"remove it — then reinstall."
) from exc
if stranded_configs and not staging_is_complete:
# Write a durable backup outside dest_dir before any
@@ -2489,6 +2727,149 @@ class ExtensionManager:
extension_dir, speckit_version, priority=priority, force=force
)
def _config_root_is_contained(self, specify_dir: Path) -> bool:
"""Report whether `.specify` is a real directory inside the project.
Checked component by component so a symlink anywhere on the path is
rejected before it becomes the containment root. A missing `.specify`
is fine: scaffolding creates it under the project root.
"""
try:
root = self.project_root.resolve()
except OSError:
return False
current = self.project_root
for part in specify_dir.relative_to(self.project_root).parts:
current = current / part
if current.is_symlink():
return False
if not current.exists():
return True
try:
if current.resolve().relative_to(root) is None:
return False
except (OSError, ValueError):
return False
return current.is_dir()
@staticmethod
def _target_follows_preserved_convention(target_name: str) -> bool:
"""True when a scaffold target survives remove/backup/restore.
Those paths only handle top-level ``*-config.yml`` and
``*-config.local.yml`` files, so anything nested or otherwise named is
not preserved across an update.
"""
if "/" in target_name or "\\" in target_name:
return False
return target_name.endswith("-config.yml") or target_name.endswith(
"-config.local.yml"
)
def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]:
"""Deploy config templates from an installed extension to the project.
Reads the extension's manifest provides.config section and copies
each config template to the project's .specify/ directory. Existing
config files are never overwritten (user customizations are preserved).
Args:
extension_id: ID of the installed extension
Returns:
Tuple of (deployed, skipped_existing, failed) where each is a list
of config file names.
"""
ext_dir = self.extensions_dir / extension_id
manifest_path = ext_dir / "extension.yml"
if not manifest_path.exists():
return [], [], []
manifest = ExtensionManifest(manifest_path)
deployed = []
skipped_existing = []
failed = []
provides = manifest.data.get("provides", {})
raw_config = provides.get("config", [])
config_is_malformed = (
"config" in provides
and (
not isinstance(raw_config, list)
or not all(isinstance(entry, dict) for entry in raw_config)
)
)
if config_is_malformed:
return deployed, skipped_existing, ["provides.config"]
ext_dir_resolved = ext_dir.resolve()
# Config is deployed beneath the extension's own directory because that
# is where it is read from: ConfigManager._get_project_config() loads
# `.specify/extensions/<id>/<id>-config.yml`, and the bundled scripts
# and READMEs use the same location. Writing to `.specify/<name>` put
# the file somewhere nothing ever looks.
config_dir = self.project_root / ".specify" / "extensions" / extension_id
# Resolving that directory and trusting the result as the containment
# root lets a symlinked component point outside the project: every
# target would then satisfy relative_to and copy2 would write
# externally. Refuse a symlinked component up front, matching the
# project safe-write path in shared_infra.
if not self._config_root_is_contained(config_dir):
return deployed, skipped_existing, ["provides.config"]
config_dir_resolved = config_dir.resolve()
for config_entry in manifest.config:
template_name = config_entry.get("template", "")
target_name = config_entry.get("name", template_name)
failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config"
if not isinstance(template_name, str) or not template_name:
failed.append(failure_name)
continue
if not isinstance(target_name, str) or not target_name:
failed.append(failure_name)
continue
# Only scaffold what removal actually preserves. remove(keep_config)
# keeps top-level files ending in -config.yml / -config.local.yml and
# rmtree's every subdirectory; the backup path globs the same
# top-level pattern. A nested or differently-named target would be
# silently destroyed by `extension add --force` and replaced with the
# template default, losing the user's customization.
if not self._target_follows_preserved_convention(target_name):
failed.append(failure_name)
continue
template_candidate = ext_dir / template_name
template_path = template_candidate.resolve()
target_path = (config_dir / target_name).resolve()
try:
template_path.relative_to(ext_dir_resolved)
target_path.relative_to(config_dir_resolved)
except ValueError:
failed.append(failure_name)
continue
if template_candidate.is_symlink() or not template_path.is_file():
failed.append(failure_name)
continue
if target_path.exists():
skipped_existing.append(target_name)
continue
try:
# mkdir belongs inside the handler: a nested target like
# foo/config.yml must land in `failed` when `.specify/foo` is a
# file or cannot be created, not raise out of scaffolding after
# `extension add` has already installed the extension.
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_path, target_path)
except OSError:
failed.append(target_name)
continue
deployed.append(target_name)
return deployed, skipped_existing, failed
def install_from_zip(
self,
zip_path: Path,
@@ -4078,11 +4459,10 @@ class ConfigManager:
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
kept alongside ``OSError`` as belt-and-braces: ``_load()`` now starts
fresh on non-UTF-8 registry bytes itself, but catching it here too
keeps this call site degrading gracefully rather than breaking *every*
config read if that handling ever regresses.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
+47 -2
View File
@@ -1071,8 +1071,29 @@ def extension_add(
if reg_skills:
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/")
# Scaffold config templates automatically
deployed, skipped, failed = manager.scaffold_config(manifest.id)
config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
# Only warn when configuration is actually unresolved. Scaffolding that
# deployed or preserved every template has already answered this, and an
# extension without provides.config has nothing to configure; the blanket
# warning contradicted the output directly above it.
if failed or not (deployed or skipped):
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: {config_home}/")
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
@@ -2552,6 +2573,30 @@ def extension_enable(
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)
# Scaffold config templates on enable
try:
deployed, skipped, failed = manager.scaffold_config(extension_id)
except Exception as exc:
console.print(
f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension "
f"'{_escape_markup(str(display_name))}'."
)
console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]")
deployed, skipped, failed = [], [], []
config_home = f".specify/extensions/{_escape_markup(str(extension_id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
@extension_app.command("disable")
def extension_disable(
+2 -2
View File
@@ -70,8 +70,8 @@ def with_integration_setting(
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
# None), the previously-stored ``parsed_options`` are retained above, so
# deriving the separator from the argument (None) would drop an
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
# the default ".".
# options-dependent separator (e.g. Copilot ``--commands`` -> ".") back to
# the default "-".
current["invoke_separator"] = integration.effective_invoke_separator(
current.get("parsed_options"), project_root
)
+2
View File
@@ -56,6 +56,7 @@ def _register_builtins() -> None:
from .cline import ClineIntegration
from .codebuddy import CodebuddyIntegration
from .codex import CodexIntegration
from .command_code import CommandCodeIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
@@ -95,6 +96,7 @@ def _register_builtins() -> None:
_register(ClineIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CommandCodeIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
@@ -331,6 +331,14 @@ def integration_switch(
selected_script = _resolve_script_type(project_root, script)
# Resolve and validate target options before uninstalling the current
# integration. Invalid options must not leave the project partially
# switched with the previous integration already removed.
target_raw_options, target_parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
target_integration.is_skills_mode(target_parsed_options, project_root)
# Phase 1: Uninstall current integration (if any)
if installed_key:
current_integration = get_integration(installed_key)
@@ -403,7 +411,10 @@ def integration_switch(
fallback_key = installed_keys[0]
fallback_integration = get_integration(fallback_key)
if fallback_integration is not None:
raw_options, parsed_options = _resolve_integration_options(
(
fallback_raw_options,
fallback_parsed_options,
) = _resolve_integration_options(
fallback_integration, current, fallback_key, None
)
_set_default_integration_or_exit(
@@ -412,8 +423,8 @@ def integration_switch(
fallback_key,
fallback_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=fallback_raw_options,
parsed_options=fallback_parsed_options,
)
else:
_write_integration_json(
@@ -423,13 +434,6 @@ def integration_switch(
_remove_integration_json(project_root)
current = _read_integration_json(project_root)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
@@ -445,11 +449,11 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options,
target_integration, current, target, target_parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
target_integration, target, target_parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
@@ -471,14 +475,14 @@ def integration_switch(
target_integration.key,
target_integration.config,
project_root,
parsed_options,
target_parsed_options,
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
parsed_options=target_parsed_options,
script_type=selected_script,
raw_options=raw_options,
raw_options=target_raw_options,
events=events_map,
)
manifest.save()
@@ -489,8 +493,8 @@ def integration_switch(
target_integration,
_dedupe_integration_keys([*installed_keys, target_integration.key]),
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=target_raw_options,
parsed_options=target_parsed_options,
)
except Exception as exc:
+16 -2
View File
@@ -225,8 +225,8 @@ class IntegrationBase(ABC):
on-disk layout to avoid silently migrating an existing project to a
different mode. The default ignores it.
The default (command-first integrations, e.g. Copilot's default
layout) is skills mode only when ``--skills`` was requested.
The default for command-first integrations is skills mode only when
``--skills`` was requested.
``SkillsIntegration`` overrides this to return ``True`` by default;
skills-first integrations that expose a legacy opt-out (e.g. Bob)
override it to honor their own flag.
@@ -957,6 +957,20 @@ class IntegrationBase(ABC):
"""Return True if this integration supports agent-native events."""
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
# Context-injection envelope for hook stdout, keyed by canonical event
# (with "*" as the fallback). Not every agent injects a hook's plain-text
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
# protocols (plain text becomes user-facing noise), Copilot discards
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
# "suppress" → emit nothing (strict-JSON agents on events whose
# output can't be used)
# Absent (no matching key and no "*") → plain stdout passthrough
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
events_context_envelope: dict[str, str] = {}
# -- Convenience helpers for subclasses -------------------------------
def install(
+10 -1
View File
@@ -207,7 +207,7 @@ class IntegrationCatalog(CatalogStackBase):
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=IntegrationCatalogError,
label=f"catalog from {entry.url}",
)
).decode("utf-8")
)
shape_error = _catalog_shape_error(catalog_data)
@@ -237,6 +237,15 @@ class IntegrationCatalog(CatalogStackBase):
raise IntegrationCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
)
except UnicodeDecodeError as exc:
# A non-UTF-8 response body fails at .decode() before json.loads()
# ever runs, so JSONDecodeError below does not cover it (the two are
# sibling ValueError subclasses, not parent/child). Without this the
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
# "warn and skip this catalog" handler and kills the whole command.
raise IntegrationCatalogError(
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
)
except json.JSONDecodeError as exc:
raise IntegrationCatalogError(
f"Invalid JSON in catalog from {entry.url}: {exc}"
@@ -0,0 +1,41 @@
"""Command Code integration — skills-based agent.
Command Code loads agent skills from ``.commandcode/skills/speckit-<name>/SKILL.md``
(project) or ``~/.commandcode/skills/`` (personal). Skills are invoked in chat
with ``$speckit-<name>``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class CommandCodeIntegration(SkillsIntegration):
"""Integration for Command Code CLI."""
key = "command-code"
config = {
"name": "Command Code",
"folder": ".commandcode/",
"commands_subdir": "skills",
"install_url": "https://commandcode.ai/docs",
"requires_cli": True,
}
registrar_config = {
"dir": ".commandcode/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Command Code)",
),
]
+153 -61
View File
@@ -1,13 +1,19 @@
"""Copilot integration — GitHub Copilot in VS Code.
Copilot has several unique behaviors compared to standard markdown agents:
Copilot supports two layouts:
- Skills are the default and use ``speckit-<name>/SKILL.md`` directories under
``.github/skills/``
- ``--commands`` uses ``.agent.md`` files, companion ``.prompt.md`` files, and
a VS Code settings merge
The two modes are mutually exclusive. The commands layout remains supported,
but is no longer the preferred default.
The commands layout 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
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
instead. The two modes are mutually exclusive.
"""
from __future__ import annotations
@@ -19,9 +25,24 @@ import warnings
from pathlib import Path
from typing import Any
import typer
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
_COPILOT_CORE_COMMANDS = {
"analyze",
"checklist",
"clarify",
"constitution",
"converge",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
}
def _copilot_executable() -> str:
"""Return the executable name for Copilot CLI on this platform.
@@ -57,22 +78,24 @@ def _allow_all() -> bool:
return True
def _warn_legacy_markdown_default() -> None:
"""Warn that Copilot's default markdown scaffold is being phased out."""
warnings.warn(
"Copilot legacy markdown mode is deprecated and will stop being the "
'default in a future Spec Kit release; pass --integration-options "--skills" '
"to opt in to Copilot skills mode now.",
UserWarning,
stacklevel=3,
)
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
"""Reject the two explicit Copilot layout selectors used together."""
opts = parsed_options or {}
if opts.get("skills") and opts.get("commands"):
from ..._console import console
console.print(
"[red]Error:[/red] --skills and --commands are mutually exclusive; "
"pass only one."
)
raise typer.Exit(1)
class _CopilotSkillsHelper(SkillsIntegration):
"""Internal helper used when Copilot is scaffolded in skills mode.
Not registered in the integration registry — only used as a delegate
by ``CopilotIntegration`` when ``--skills`` is passed.
Not registered in the integration registry — only used as the default
skills-layout delegate by ``CopilotIntegration``.
"""
key = "copilot"
@@ -94,13 +117,11 @@ class _CopilotSkillsHelper(SkillsIntegration):
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
When ``--skills`` is passed via ``--integration-options``, commands
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
The default IDE integration (``requires_cli: False``) installs skills under
``.github/skills/``. Pass ``--commands`` via ``--integration-options`` to
install the supported ``.agent.md`` + ``.prompt.md`` layout instead.
Workflow dispatch additionally requires the ``copilot`` CLI to be installed
separately.
"""
key = "copilot"
@@ -117,6 +138,7 @@ class CopilotIntegration(IntegrationBase):
"args": "$ARGUMENTS",
"extension": ".agent.md",
}
invoke_separator = "-"
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
@@ -130,40 +152,101 @@ class CopilotIntegration(IntegrationBase):
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Copilot sessionStart and userPromptSubmitted inject a top-level
# additionalContext field into the model-facing prompt (C13). Non-JSON
# stdout is discarded harmlessly by Copilot on other events, so no other
# event needs an envelope.
events_context_envelope = {
"session_start": "additionalContext",
"user_prompt_submit": "additionalContext",
}
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
_skills_mode: bool = True
def effective_invoke_separator(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
"""Return the separator for the resolved Copilot layout."""
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Copilot is skills mode when ``--skills`` was requested.
"""Copilot defaults to skills; ``--commands`` opts into commands mode.
On the init path ``setup()`` has already recorded the choice in
``self._skills_mode``; on the ``use``/``install`` path (where no
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
round-trips because ``--skills`` is persisted in the stored options.
Explicit flags override on-disk detection. Without a flag, existing
projects retain their managed Spec Kit layout while fresh projects use
skills. This prevents ``use`` and ``upgrade`` from silently migrating
projects created before skills became the default.
"""
if parsed_options and parsed_options.get("skills"):
opts = parsed_options or {}
_validate_mode_options(opts)
if opts.get("skills"):
return True
return self._skills_mode
if opts.get("commands"):
return False
if project_root is not None:
project_root = Path(project_root)
manifest_path = (
project_root
/ ".specify"
/ "integrations"
/ "copilot.manifest.json"
)
if manifest_path.is_file():
try:
manifest_files = IntegrationManifest.load(
self.key, Path(project_root)
).files
except (OSError, ValueError):
manifest_files = None
if manifest_files is not None and any(
path.startswith(".github/skills/speckit-")
and path.endswith("/SKILL.md")
for path in manifest_files
):
return True
if manifest_files is not None and any(
path.startswith(".github/agents/speckit.")
and path.endswith(".agent.md")
for path in manifest_files
):
return False
github_dir = project_root / ".github"
has_managed_skills = any(
(
github_dir
/ "skills"
/ f"speckit-{command}"
/ "SKILL.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
has_managed_commands = any(
(
github_dir
/ "agents"
/ f"speckit.{command}.agent.md"
).is_file()
or (
github_dir
/ "prompts"
/ f"speckit.{command}.prompt.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
if has_managed_commands and not has_managed_skills:
return False
return True
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
"""Skills projects render ``/speckit-<cmd>``; commands use ``.``.
Copilot is dual-layout, so — like Bob — the command-reference
separator depends on the persisted ``ai_skills`` state rather than a
@@ -171,7 +254,7 @@ class CopilotIntegration(IntegrationBase):
Copilot skills project consistent with ``build_command_invocation``
(which emits ``/speckit-<stem>``).
"""
return "-" if skills_enabled else self.invoke_separator
return "-" if skills_enabled else "."
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -184,7 +267,22 @@ class CopilotIntegration(IntegrationBase):
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
help=(
"Force the default skills layout (.github/skills/), "
"overriding on-disk auto-detection"
),
),
)
opts.append(
IntegrationOption(
"--commands",
is_flag=True,
default=False,
help=(
"Scaffold .github/agents/*.agent.md commands with companion "
".github/prompts/*.prompt.md files instead of the default "
"skills layout"
),
),
)
return opts
@@ -228,8 +326,8 @@ class CopilotIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command.
Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
Commands mode: agents are not slash-commands — return args as prompt.
Skills mode (default): ``/speckit-<stem>`` slash-command dispatch.
"""
if self._skills_mode:
stem = command_name
@@ -266,15 +364,11 @@ class CopilotIntegration(IntegrationBase):
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
# Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode
if not skills_mode and project_root:
skills_dir = project_root / ".github" / "skills"
if skills_dir.is_dir():
skills_mode = any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
skills_mode = (
self.is_skills_mode(project_root=project_root)
if project_root
else self._skills_mode
)
if skills_mode:
prompt = "/speckit-" + stem.replace(".", "-")
@@ -366,20 +460,18 @@ class CopilotIntegration(IntegrationBase):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install copilot commands, companion prompts, and VS Code settings.
"""Install Copilot skills or the opt-in commands layout.
When ``parsed_options["skills"]`` is truthy, delegates to skills
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
Skills are the default. ``parsed_options["commands"]`` selects
``.agent.md`` files, companion prompts, and the VS Code settings merge.
Existing managed command layouts are preserved when no mode is explicit.
"""
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
self._skills_mode = self.is_skills_mode(parsed_options, project_root)
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
created = self._setup_default(project_root, manifest, parsed_options, **opts)
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
# Install agent runtime events
event_files = self.emit_events(
@@ -388,14 +480,14 @@ class CopilotIntegration(IntegrationBase):
created.extend(event_files)
return created
def _setup_default(
def _setup_commands(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
"""Commands mode: .agent.md + .prompt.md + VS Code settings merge."""
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
@@ -48,6 +48,14 @@ class CursorAgentIntegration(SkillsIntegration):
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
# Cursor sessionStart injects a top-level additional_context (snake_case)
# field (C13). beforeSubmitPrompt has no context output field (block/allow
# only), and plain text on any hook fails Cursor's JSON parse — suppress
# everything else.
events_context_envelope = {
"*": "suppress",
"session_start": "additional_context",
}
def build_exec_args(
self,
@@ -44,6 +44,13 @@ class DevinIntegration(SkillsIntegration):
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
# json-root-nested writer/remover operate directly on the root event keys.
events_format = "json-root-nested"
# Devin's hooks protocol is JSON-stdout; additionalContext is the
# documented injection field for SessionStart/UserPromptSubmit (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
def build_exec_args(
self,
@@ -33,6 +33,15 @@ class GeminiIntegration(TomlIntegration):
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
# text becomes a user-facing systemMessage, never context. Inject via
# hookSpecificOutput.additionalContext on the two context events and
# suppress stdout everywhere else (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
# which use seconds. The shared formatter converts via _native_timeout (#7)
# so the default 60s becomes 60000ms instead of terminating the dispatcher
@@ -121,13 +121,27 @@ class HermesIntegration(SkillsIntegration):
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description
# Parse frontmatter for description. Locate the closing ``---`` on
# its own line rather than with ``raw.split("---", 2)`` — a bare
# substring split stops at the first ``---`` *anywhere*, including
# one inside a value such as ``description: Separate sections
# with ---``, which truncates the frontmatter and drops later keys.
# The block between the delimiters is parsed unstripped so trailing
# newlines in literal (``|``) block scalars survive.
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
fm_lines = raw.splitlines(keepends=True)
fm_close = next(
(
i
for i in range(1, len(fm_lines))
if fm_lines[i].rstrip() == "---"
),
None,
)
if fm_close is not None:
try:
fm = yaml.safe_load(parts[1])
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
@@ -143,10 +157,26 @@ class HermesIntegration(SkillsIntegration):
project_root=project_root,
)
# Strip the processed frontmatter — we rebuild it for skills.
# Scan for the closing ``---`` on its own line rather than
# ``split("---", 2)`` so a ``---`` embedded in a value does not
# truncate the frontmatter and spill it into the body.
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
body_lines = processed_body.splitlines(keepends=True)
close_idx = next(
(
i
for i in range(1, len(body_lines))
if body_lines[i].rstrip() == "---"
),
None,
)
if close_idx is not None:
# Keep whatever trails the ``---`` marker on the closing
# line so the body stays byte-for-byte identical to
# ``split("---", 2)[2]`` for well-formed templates.
processed_body = body_lines[close_idx][3:] + "".join(
body_lines[close_idx + 1 :]
)
# Select description
description = frontmatter.get("description", "")
+14 -4
View File
@@ -317,20 +317,30 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
try:
content = skill_file.read_text(encoding="utf-8")
except OSError:
except (OSError, UnicodeError):
return False
if not content.startswith("---"):
return False
parts = content.split("---", 2)
if len(parts) < 3:
# Locate the closing ``---`` on its own line rather than with
# ``content.split("---", 2)`` — a bare substring split stops at the first
# ``---`` *anywhere*, including one inside a value such as
# ``description: Separate sections with ---``, which truncates the parsed
# frontmatter and can drop the metadata block this check relies on (so a
# Speckit-generated skill would not be recognized on teardown).
lines = content.splitlines(keepends=True)
close_idx = next(
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
None,
)
if close_idx is None:
return False
try:
import yaml
frontmatter = yaml.safe_load(parts[1])
frontmatter = yaml.safe_load("".join(lines[1:close_idx]))
except Exception:
return False
+1 -2
View File
@@ -451,8 +451,7 @@ class IntegrationManifest:
_ensure_safe_manifest_destination(self.project_root, path)
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
temp_path.unlink(missing_ok=True)
return path
@classmethod
@@ -23,7 +23,15 @@ class OpencodeIntegration(MarkdownIntegration):
CANONICAL_TO_NATIVE = {
"pre_tool_use": "tool.execute.before",
"post_tool_use": "tool.execute.after",
"session_start": "session.created",
# session_start maps to the system-prompt transform hook (not the
# session.created event) so the handler's stdout is injected into the
# system prompt — session.created has no output channel. The hook
# fires per LLM request, which keeps the context present across
# compaction at the cost of running the handler per turn.
"session_start": "experimental.chat.system.transform",
# user_prompt_submit maps to chat.message so handler stdout is
# injected as a synthetic text part on the user's message.
"user_prompt_submit": "chat.message",
"session_end": "session.deleted",
}
events_config_file = "opencode.json"
@@ -30,6 +30,12 @@ class QwenIntegration(MarkdownIntegration):
}
events_config_file = ".qwen/settings.json"
events_format = "json-nested"
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Qwen Code's command hooks measure timeout in milliseconds (default
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
@@ -33,6 +33,12 @@ class TabnineIntegration(TomlIntegration):
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
+329 -63
View File
@@ -56,6 +56,7 @@ from ..shared_infra import (
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
def _content_sha256(content: bytes) -> str:
@@ -178,7 +179,7 @@ def _substitute_core_template(
by the core template body and core_frontmatter holds the core template's parsed
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
unchanged / empty when the placeholder is absent or the core template file does
not exist.
not exist or cannot be read.
"""
if "{CORE_TEMPLATE}" not in body:
return body, {}
@@ -208,7 +209,23 @@ def _substitute_core_template(
if core_file is None:
return body, {}
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
# Treat an unreadable/undecodable core template like a missing one so a
# single corrupted project override cannot crash command registration —
# the wrap-strategy callers already skip an unreadable preset source with
# a warning (CommandRegistrar.register_pack).
try:
core_content = core_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
import warnings
warnings.warn(
f"Ignoring core template for command '{cmd_name}': could not read "
f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).",
stacklevel=2,
)
return body, {}
core_frontmatter, core_body = registrar.parse_frontmatter(core_content)
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
@@ -307,9 +324,25 @@ class PresetManifest:
# Validate preset metadata
pack = self.data["preset"]
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a PresetValidationError,
# so it escapes every caller that already handles a malformed manifest
# (see list_installed()'s "Corrupted preset" fallback, which catches
# PresetValidationError only, making one bad preset exit ``specify
# preset list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in pack:
raise PresetValidationError(f"Missing preset.{field}")
if not isinstance(pack[field], str):
raise PresetValidationError(
f"Invalid preset.{field}: expected a string, "
f"got {type(pack[field]).__name__}"
)
# Validate pack ID format
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
@@ -328,6 +361,25 @@ class PresetManifest:
requires = self.data["requires"]
if "speckit_version" not in requires:
raise PresetValidationError("Missing requires.speckit_version")
# Presence alone is not enough: check_compatibility() feeds this value to
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
# which a non-string escapes two different ways. A float/int/bool/None
# raises TypeError from the constructor, while a list or dict is an
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
# inside .contains(). Neither is a PresetCompatibilityError, so both
# bypass the CLI's "Compatibility Error" handler and exit 1 with a raw
# traceback naming no field. An unquoted ``speckit_version: 1.0`` is an
# easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already
# requires a non-empty string here.
if (
not isinstance(requires["speckit_version"], str)
or not requires["speckit_version"].strip()
):
raise PresetValidationError(
"Invalid requires.speckit_version: expected a non-empty string, "
f"got {type(requires['speckit_version']).__name__}"
)
# Validate provides section
provides = self.data["provides"]
@@ -367,6 +419,19 @@ class PresetManifest:
"Template missing 'type', 'name', or 'file'"
)
# 'name' feeds re.match and 'file' feeds os.path.normpath below;
# both raise a bare TypeError on a non-string, which is not a
# PresetValidationError and so escapes the callers that handle a
# malformed manifest. The sibling extension manifest already
# rejects a non-string command 'file' via
# relative_extension_path_violation().
for field in ("type", "name", "file"):
if not isinstance(tmpl[field], str):
raise PresetValidationError(
f"Invalid template {field}: expected a string, "
f"got {type(tmpl[field]).__name__}"
)
if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES:
raise PresetValidationError(
f"Invalid template type '{tmpl['type']}': "
@@ -502,7 +567,12 @@ class PresetRegistry:
if not isinstance(data.get("presets"), dict):
data["presets"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError):
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh. A registry whose
# bytes cannot be decoded as UTF-8 is the same corruption class
# as malformed JSON — only the exception type differs. OSError is
# deliberately not caught: the data may be intact on disk, and
# starting fresh would let a later _save() wipe it.
return {
"schema_version": self.SCHEMA_VERSION,
"presets": {}
@@ -727,6 +797,18 @@ class PresetManager:
PresetCompatibilityError: If pack is incompatible
"""
required = manifest.requires_speckit_version
# Defense in depth: the manifest validator now rejects a non-string
# requires.speckit_version, but this method is public and also reachable
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
# cover a non-string -- scalars raise TypeError from the constructor, and
# a list/dict is iterable so it constructs here and only breaks inside
# .contains(). Reject up front so this always reports a
# PresetCompatibilityError.
if not isinstance(required, str):
raise PresetCompatibilityError(
"Invalid version specifier: expected a string, got "
f"{type(required).__name__} ({required!r})"
)
try:
SpecifierSet(required) # Just to validate
except InvalidSpecifier:
@@ -2157,7 +2239,10 @@ class PresetManager:
]
if dir_core_ext_names:
self._unregister_skills_in_dir(
dir_core_ext_names, skills_dir, dir_agent
dir_core_ext_names,
skills_dir,
dir_agent,
restore_from_bundled_core=True,
)
for _skill_name, cmd_name, top_layer in override_skills:
@@ -2675,7 +2760,7 @@ class PresetManager:
if frontmatter.get("strategy") == "wrap":
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
for key in ("scripts", "agent_scripts", "argument-hint"):
if key not in frontmatter and key in core_frontmatter:
frontmatter[key] = core_frontmatter[key]
@@ -2992,6 +3077,7 @@ class PresetManager:
preset_dir: Union[Path, str],
*,
additional_owned_sources: Optional[Dict[str, str]] = None,
restore_from_bundled_core: bool = False,
) -> Dict[Path, tuple[Optional[str], List[str]]]:
"""Restore original SKILL.md files after a preset is removed.
@@ -2999,6 +3085,17 @@ class PresetManager:
regenerate the skill from the core command template. If no core
template exists, the skill directory is removed.
Args:
restore_from_bundled_core: When True, a missing project-local
core template (the common case — ``specify init`` never
populates ``.specify/templates/commands``) falls back to
the bundled core_pack/repo-root templates so the skill is
restored instead of deleted (#3928). Callers that are
retiring a skill because its command now renders elsewhere
(a command file superseding it) must leave this False so
the skill is removed rather than resurrected with core
content that would duplicate the winning command.
``registered_skills`` records exactly which agent directories this
preset actually wrote to (see :meth:`_register_skills`), so removal
restores precisely those directories rather than guessing at every
@@ -3072,6 +3169,7 @@ class PresetManager:
renderer_agent,
pack_id=pack_id,
additional_owned_sources=additional_owned_sources,
restore_from_bundled_core=restore_from_bundled_core,
)
if mutated_names:
restored[skills_dir] = (
@@ -3104,6 +3202,7 @@ class PresetManager:
selected_ai,
pack_id=pack_id,
additional_owned_sources=additional_owned_sources,
restore_from_bundled_core=restore_from_bundled_core,
)
return (
{skills_dir: (selected_ai, mutated_names)}
@@ -3169,6 +3268,30 @@ class PresetManager:
if source in owned_sources:
shutil.rmtree(skill_subdir)
@staticmethod
def _warn_unrestored_skill(
skill_name: str, source_file: Path, exc: BaseException
) -> None:
"""Warn that a skill kept preset content because its restore source is unreadable.
Skipping the restore is the safe recovery — the alternative branch
deletes the skill outright — but it is still a partial removal: the
preset directory and registry entry go away while this ``SKILL.md``
keeps the removed preset's content, and reconciliation never revisits
it because the name is left out of ``mutated_names``. Name the skill
and the source so the condition is actionable instead of silent.
"""
import warnings
warnings.warn(
f"Skill '{skill_name}' still contains the removed preset's content: "
f"its restore source '{source_file}' could not be read "
f"({exc.__class__.__name__}: {exc}). The skill was left in place "
f"rather than deleted. Fix or remove that file and re-run "
f"'specify preset add'/'specify preset remove' to refresh it.",
stacklevel=2,
)
def _unregister_skills_in_dir(
self,
skill_names: List[str],
@@ -3177,6 +3300,7 @@ class PresetManager:
*,
pack_id: Optional[str] = None,
additional_owned_sources: Optional[Dict[str, str]] = None,
restore_from_bundled_core: bool = False,
) -> List[str]:
"""Restore original SKILL.md files within a single skills directory.
@@ -3187,6 +3311,7 @@ class PresetManager:
placeholder resolution and argument-hint formatting.
additional_owned_sources: Generated non-preset source markers
accepted as owned for specific skill names.
restore_from_bundled_core: See ``_unregister_skills``.
Returns:
Skill names whose files were restored or removed.
@@ -3262,14 +3387,51 @@ class PresetManager:
if current_source not in owned_sources:
continue
# Try to find the core command template
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
if core_file and not core_file.exists():
extension_restore = extension_restore_index.get(skill_name)
# Try to find the core command template. Project-local overrides
# in core_templates_dir take precedence, but that directory is
# rarely populated — the real core commands ship in the bundled
# core_pack (wheel install) or the repo-root templates/ tree
# (source checkout). Callers that want a genuine restore (a
# preset was removed outright, not superseded by another
# renderer) opt into that fallback via restore_from_bundled_core
# so the skill is restored instead of deleted (#3928). An
# installed extension providing a core-named command resolves
# ahead of bundled core elsewhere, so skip the bundled fallback
# when an extension restore exists — otherwise it would win
# over the higher-priority extension layer below.
core_file = core_templates_dir / f"{short_name}.md"
if (
not core_file.exists()
and restore_from_bundled_core
and extension_restore is None
):
from .. import _locate_core_pack, _repo_root
_core_pack = _locate_core_pack()
if _core_pack is not None:
core_file = _core_pack / "commands" / f"{short_name}.md"
else:
core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md"
if not core_file.exists():
core_file = None
if core_file:
# Restore from core template
content = core_file.read_text(encoding="utf-8")
# Restore from core template. An unreadable/undecodable
# source cannot produce restored content, so leave the
# existing skill untouched rather than leaking a raw
# OSError/UnicodeDecodeError out of `preset remove` — and
# rather than falling through to the rmtree below, which
# would delete a skill precisely when its replacement
# cannot be generated. Matches the `continue` guards above
# (unsafe name, missing subdir, foreign owner), which also
# skip without recording the name as mutated.
try:
content = core_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
self._warn_unrestored_skill(skill_name, core_file, exc)
continue
frontmatter, body = registrar.parse_frontmatter(content)
if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders(
@@ -3309,9 +3471,17 @@ class PresetManager:
mutated_names.append(skill_name)
continue
extension_restore = extension_restore_index.get(skill_name)
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
# Same boundary as the core-template branch above: an
# unreadable extension source leaves the skill in place
# instead of crashing or being deleted.
try:
content = extension_restore["source_file"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
self._warn_unrestored_skill(
skill_name, extension_restore["source_file"], exc
)
continue
frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
@@ -3448,7 +3618,9 @@ class PresetManager:
"registered_skills", registered_skills
)
if persisted_skills:
self._unregister_skills(persisted_skills, dest_dir)
self._unregister_skills(
persisted_skills, dest_dir, restore_from_bundled_core=True
)
try:
if dest_dir.exists():
shutil.rmtree(dest_dir)
@@ -3476,13 +3648,10 @@ class PresetManager:
stacklevel=2,
)
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
# Materialize constitution-template changes only for projects that opt
# into the constitution-sync preset. The core /constitution command
# resolves this template on demand; constitution-sync preserves the
# previous install-time behavior for teams that want reviewed snapshots.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest
@@ -3490,14 +3659,13 @@ class PresetManager:
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
"""Seed memory/constitution.md when constitution-sync opts into snapshots.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
Installing constitution-sync itself materializes the currently resolved
stack. Later preset installs only reconcile when they provide a
``constitution-template``. Authored constitutions are never overwritten.
"""
provides_constitution = any(
provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
@@ -3518,7 +3686,7 @@ class PresetManager:
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
"""Reconcile an opted-in generated constitution without failing a change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
@@ -3530,7 +3698,11 @@ class PresetManager:
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
"""Materialize the winning layer when constitution-sync is enabled."""
sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID)
if sync_metadata is None or not sync_metadata.get("enabled", True):
return
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
@@ -3786,6 +3958,7 @@ class PresetManager:
restorable_skills,
pack_dir,
additional_owned_sources=override_sources,
restore_from_bundled_core=True,
)
try:
from ..agents import CommandRegistrar
@@ -4835,6 +5008,18 @@ class PresetResolver:
self._manifest_cache[key] = None
return self._manifest_cache[key]
@staticmethod
def _is_safe_registry_id(value: object) -> bool:
return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None
def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]:
registry = PresetRegistry(self.presets_dir)
return [
(pack_id, metadata)
for pack_id, metadata in registry.list_by_priority()
if self._is_safe_registry_id(pack_id)
]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
@@ -4868,6 +5053,64 @@ class PresetResolver:
return tmpl, None
return None, None
def _extension_manifest_declared_template(
self, ext_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
"""Resolve an extension's manifest-declared command/template/script entry and usable file.
Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)``
where ``entry`` is the matching ``provides.<type>`` mapping, or ``None`` if the
extension has no (valid) manifest or doesn't declare this ``(name, type)``.
``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a
regular file that stays within ``ext_dir`` (guards against path traversal via a
malformed manifest, mirroring ``resolve_extension_command_via_manifest``);
``None`` otherwise.
The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is
``None``, callers must NOT fall back to convention-based lookup — that would mask
a typo or pick up an undeclared file. Shared by ``resolve()`` and
``collect_all_layers()`` so their manifest-first resolution cannot silently
diverge (the divergence flagged in review on #4012).
"""
if template_type not in ("command", "template", "script"):
return None, None
ext_manifest_path = ext_dir / "extension.yml"
if not ext_manifest_path.exists():
return None, None
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
try:
ext_manifest = ExtensionManifest(ext_manifest_path)
except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError):
return None, None
if template_type == "command":
entries = ext_manifest.commands
elif template_type == "template":
entries = ext_manifest.templates
else:
entries = ext_manifest.scripts
for entry in entries:
if entry.get("name") != template_name:
continue
file_rel = entry.get("file")
if not file_rel:
return entry, None
rel_path = Path(file_rel)
if rel_path.is_absolute():
return entry, None
candidate = ext_dir / rel_path
try:
# Resolve only for the containment check, not for the
# returned path -- resolving the returned path would follow
# symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir
# on macOS) and diverge from the unresolved paths convention
# lookup returns for the same directory.
candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside
except (OSError, ValueError):
return entry, None
return entry, (candidate if candidate.is_file() else None)
return None, None
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
"""Build unified list of registered and unregistered extensions sorted by priority.
@@ -4882,6 +5125,16 @@ class PresetResolver:
return []
registry = ExtensionRegistry(self.extensions_dir)
# Fail closed on a corrupt registry. ExtensionRegistry._load() recovers
# by normalizing an unreadable registry to an empty mapping, which would
# otherwise cause the directory scan below to admit every on-disk
# directory as an unregistered, enabled extension — a fail-open path
# that could supply constitution content from an invalid registry state.
if registry.is_corrupt():
raise PresetValidationError(
f"Invalid extension registry {registry.registry_path}: "
"refusing to enumerate extensions"
)
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
# This prevents corrupted entries from being picked up as "unregistered" dirs
registered_extension_ids = registry.keys()
@@ -4893,6 +5146,8 @@ class PresetResolver:
# Only include enabled extensions in the result
for ext_id, metadata in all_registered:
if not self._is_safe_registry_id(ext_id):
continue
# Skip disabled extensions
if not metadata.get("enabled", True):
continue
@@ -4901,7 +5156,7 @@ class PresetResolver:
# Add unregistered directories with implicit priority=10
for ext_dir in self.extensions_dir.iterdir():
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
if not ext_dir.is_dir() or not self._is_safe_registry_id(ext_dir.name):
continue
if ext_dir.name not in registered_extension_ids:
all_extensions.append((10, ext_dir.name, None))
@@ -4967,8 +5222,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number wins)
if not skip_presets and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, _metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
@@ -5004,6 +5258,16 @@ class PresetResolver:
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
# The extension manifest is authoritative, same as preset manifests
# above: check it before convention-based lookup so a declared entry
# at a non-conventional path wins over a stale conventional file.
entry, manifest_candidate = self._extension_manifest_declared_template(
ext_dir, template_name, template_type
)
if manifest_candidate is not None:
return manifest_candidate
if entry is not None:
continue
for subdir in subdirs:
if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}"
@@ -5161,13 +5425,11 @@ class PresetResolver:
return {"path": resolved_str, "source": "project override"}
if str(self.presets_dir) in resolved_str and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
try:
resolved.relative_to(pack_dir)
meta = registry.get(pack_id)
version = meta.get("version", "?") if meta else "?"
version = metadata.get("version", "?")
return {
"path": resolved_str,
"source": f"{pack_id} v{version}",
@@ -5253,8 +5515,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
if self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
strategy = "replace"
@@ -5297,7 +5558,7 @@ class PresetResolver:
fm_strategy = fm_data.get("strategy")
if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES:
strategy = fm_strategy.lower()
except (yaml.YAMLError, OSError):
except (UnicodeDecodeError, yaml.YAMLError, OSError):
# Best-effort legacy frontmatter parsing: keep default
# strategy ("replace") when content is unreadable/invalid.
pass
@@ -5313,27 +5574,15 @@ class PresetResolver:
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
# Try convention-based lookup first
candidate = _find_in_subdirs(ext_dir)
# If not found and this is a command, check extension manifest
if candidate is None and template_type == "command":
ext_manifest_path = ext_dir / "extension.yml"
if ext_manifest_path.exists():
try:
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
ext_manifest = ExtensionManifest(ext_manifest_path)
for cmd in ext_manifest.commands:
if cmd.get("name") == template_name:
cmd_file = cmd.get("file")
if cmd_file:
c = ext_dir / cmd_file
if c.exists():
candidate = c
break
except (ExtValidationError, yaml.YAMLError):
# Invalid extension manifest — fall back to
# convention-based lookup (already attempted above).
pass
# The extension manifest is authoritative, same as preset manifests
# above: check it before convention-based lookup so a declared entry
# at a non-conventional path wins over a stale conventional file, and
# a declared-but-missing file isn't silently masked by convention.
entry, candidate = self._extension_manifest_declared_template(
ext_dir, template_name, template_type
)
if entry is None:
candidate = _find_in_subdirs(ext_dir)
if candidate:
if ext_meta:
version = ext_meta.get("version", "?")
@@ -5465,7 +5714,7 @@ class PresetResolver:
if not layers:
return None
def _read_layer_content(layer: Dict[str, Any]) -> str:
def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
@@ -5475,8 +5724,18 @@ class PresetResolver:
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
Returns None when the layer cannot be read or decoded:
collect_all_layers deliberately keeps a non-UTF-8 legacy layer
(with its "replace" default) so unrelated commands still
resolve, so the same tolerance must apply here — the documented
contract is "Composed content string, or None if not found",
not a raw UnicodeDecodeError at composition time.
"""
text = layer["path"].read_text(encoding="utf-8")
try:
text = layer["path"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
@@ -5514,6 +5773,8 @@ class PresetResolver:
# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = _read_layer_content(layers[base_layer_idx])
if content is None:
return None
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1
@@ -5557,7 +5818,12 @@ class PresetResolver:
# Apply composition layers from bottom to top
for layer in reversed_layers[start_idx:]:
layer_content = layer["path"].read_text(encoding="utf-8")
try:
layer_content = layer["path"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Same tolerance as _read_layer_content: an unreadable layer
# means the composed result cannot be produced.
return None
strategy = layer["strategy"]
if is_command:
@@ -5614,7 +5880,7 @@ class PresetResolver:
# Inherit scripts/agent_scripts from base frontmatter if missing
if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text:
base_fm = _parse_fm_yaml(base_frontmatter_text)
for key in ("scripts", "agent_scripts"):
for key in ("scripts", "agent_scripts", "argument-hint"):
if key not in top_fm and key in base_fm:
top_fm[key] = base_fm[key]
+23 -5
View File
@@ -9,6 +9,7 @@ keeps working.
from __future__ import annotations
import os
import re
from pathlib import Path
import typer
@@ -352,9 +353,26 @@ def preset_resolve(
from .. import _require_specify_project
from . import PresetResolver
is_command = "." in template_name
valid_name = (
re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name)
if is_command
else re.fullmatch(r"[a-z0-9-]+", template_name)
)
if valid_name is None:
typer.echo(
f"Error: invalid template name '{template_name}'; "
"use lowercase letters, digits, and hyphens, with non-empty "
"dot-separated segments for commands",
err=True,
)
raise typer.Exit(1)
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
template_type = "command" if is_command else "template"
layers = resolver.collect_all_layers(template_name, template_type)
safe_template_name = _escape_markup(str(template_name))
if layers:
@@ -377,7 +395,7 @@ def preset_resolve(
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
composed = resolver.resolve_content(template_name, template_type)
except Exception as exc:
composed = None
console.print(
@@ -416,7 +434,7 @@ def preset_resolve(
)
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
result = resolver.resolve_with_source(template_name, template_type)
if result:
console.print(
f" [bold]{safe_template_name}[/bold]: "
@@ -800,8 +818,8 @@ def preset_catalog_remove(
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
console.print("[red]Error:[/red] Failed to read preset catalog config.")
except Exception as e:
console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}")
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
+47 -2
View File
@@ -16,6 +16,22 @@ from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# Managed ``.specify/.gitignore``. Keeps machine-local Spec Kit state out of
# version control while leaving shareable project files (specs, constitution,
# templates, scripts, extension config) tracked. Patterns are relative to the
# ``.specify/`` directory the file lives in.
SPECIFY_GITIGNORE_CONTENT = """\
# Machine-local Spec Kit state — not meant to be shared.
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
# Local pointer to the current feature directory. Rewritten every time you
# switch features, so it is per-checkout state rather than something to share.
feature.json
# Per-machine extension config overrides.
extensions/*/local-config.yml
"""
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
@@ -262,8 +278,7 @@ def _write_shared_bytes(
_ensure_safe_shared_destination(project_path, dest)
os.replace(temp_path, dest)
finally:
if temp_path.exists():
temp_path.unlink()
temp_path.unlink(missing_ok=True)
_BASH_FORMAT_COMMAND_RE = re.compile(
@@ -608,6 +623,36 @@ def install_shared_infra(
)
planned_templates.append((dst, rel, content))
# Managed ``.specify/.gitignore`` — keeps machine-local state (the
# ``feature.json`` pointer and per-machine ``local-config.yml`` overrides)
# out of git while leaving everything else shareable. Routed through the
# same overwrite/skip/preserve policy as templates so ``--force`` refreshes
# it and user edits are preserved. Like every other shared-infra file it is
# tracked in ``speckit.manifest.json`` (not the per-integration manifest) and
# is therefore intentionally left in place by ``integration uninstall``.
specify_dir = project_path / ".specify"
if _ensure_or_bucket_dir(specify_dir):
gitignore_dst = specify_dir / ".gitignore"
gitignore_rel = gitignore_dst.relative_to(project_path).as_posix()
seen_rels.add(gitignore_rel)
if _safe_dest_or_bucket(gitignore_dst, gitignore_rel):
write, bucket = _decide_overwrite(gitignore_rel, gitignore_dst)
if write:
planned_templates.append(
(gitignore_dst, gitignore_rel, SPECIFY_GITIGNORE_CONTENT)
)
elif bucket == "preserved":
preserved_user_files.append(gitignore_rel)
else:
skipped_files.append(gitignore_rel)
if gitignore_dst.is_file() and gitignore_rel not in prior_hashes:
try:
manifest.record_existing(gitignore_rel, recovered=True)
except (OSError, ValueError) as exc:
console.print(
f"[yellow]⚠[/yellow] could not record {gitignore_rel} in manifest: {exc}"
)
for dst_path, rel, content, mode in planned_copies:
if not _ensure_or_bucket_dir(dst_path.parent):
continue
+6
View File
@@ -1594,6 +1594,12 @@ def workflow_status(
except ValueError as exc:
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
except OSError as exc:
# An unreadable state.json (bad permissions, a directory in its
# place, I/O error) must fail as cleanly as the malformed-JSON
# case above -- `workflow resume` already handles OSError here.
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
if json_output:
# Build on the shared run/resume payload so the common fields
+12 -2
View File
@@ -761,6 +761,11 @@ class RunState:
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
if state_data["run_id"] != run_id:
raise ValueError(
f"Invalid run state: stored run_id {state_data['run_id']!r} "
f"does not match requested run_id {run_id!r}"
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
@@ -1705,8 +1710,13 @@ class WorkflowEngine:
continue
state_path = run_dir / "state.json"
if state_path.exists():
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
try:
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue
if not isinstance(state_data, dict) or "run_id" not in state_data:
continue
runs.append(state_data)
return runs
+13 -1
View File
@@ -671,8 +671,20 @@ def evaluate_condition(condition: str, context: Any) -> bool:
result = evaluate_expression(condition, context)
# Treat plain "false"/"true" strings as booleans so that
# condition: "false" (without {{ }}) behaves as expected.
#
# Strip before matching: the string a condition resolves to is most often
# captured command output, and a ``shell`` step stores ``proc.stdout``
# verbatim, so ``run: echo false`` resolves to ``"false\n"``. Without the
# strip that trailing newline matches neither branch and falls through to
# ``bool("false\n")`` -> True, silently taking an ``if`` step's ``then``
# branch (and keeping a ``while``/``do-while`` looping) on a step that
# printed "false". A workflow cannot strip it itself -- the registered
# filters are default/join/map/contains/from_json, there is no ``trim``.
# ``InitStep._resolve_bool`` and the catalog readers already strip before
# matching boolean text. ``bool(result)`` below still sees the raw string,
# so no non-boolean text changes truthiness.
if isinstance(result, str):
lower = result.lower()
lower = result.strip().lower()
if lower == "false":
return False
if lower == "true":
+6 -1
View File
@@ -87,7 +87,12 @@ def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None,
else:
return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}."
if operation not in VALID_OPERATIONS:
# ``operation`` comes straight from hand-edited YAML, so it may be an
# unhashable mapping/sequence (``operation: {insert_after: a}`` when the
# shorthand form is nested by mistake). Membership-testing an unhashable
# value against the frozenset raises TypeError, which would escape this
# never-raising validator; check the type first, like 'anchor' below.
if not isinstance(operation, str) or operation not in VALID_OPERATIONS:
return None, f"Edit at index {idx} has invalid operation {operation!r}."
if not isinstance(anchor, str) or not anchor:
@@ -11,7 +11,10 @@ from __future__ import annotations
import os
from typing import Any
from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES
from specify_cli._agent_config import (
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
@@ -54,7 +57,8 @@ class InitStep(StepBase):
Initialize in the target directory instead of creating a new one.
``integration``
Integration key (e.g. ``copilot``). Defaults to the workflow's
default integration, then to ``DEFAULT_INIT_INTEGRATION``.
default integration, then to the resolved default init integration
(``SPECKIT_INTEGRATION_DEFAULT`` env var, else ``copilot``).
``integration_options``
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
@@ -81,7 +85,7 @@ class InitStep(StepBase):
# Apply the same default that specify init uses in non-interactive mode
# so that output.integration reflects the actual integration used.
if not integration:
integration = DEFAULT_INIT_INTEGRATION
integration = resolve_default_init_integration()
integration_options = self._resolve(
config.get("integration_options"), context
@@ -91,9 +95,17 @@ class InitStep(StepBase):
force = self._resolve_bool(config.get("force"), context)
# Workflows run unattended; skip the agent CLI presence check by default.
ignore_agent_tools = self._resolve_bool(
config.get("ignore_agent_tools", True), context
)
# ``config.get(key, True)`` applies that default only when the key is
# ABSENT: a bare ``ignore_agent_tools:`` in YAML parses to None, which
# ``_resolve_bool`` then turns into False -- flipping the documented
# default and re-enabling the agent-CLI presence check this step promises
# to skip, so an unattended run fails with "Agent Detection Error" for
# any integration whose CLI is not installed. Normalize an explicit null
# to the default, mirroring the while/do-while ``max_iterations`` handling.
raw_ignore_agent_tools = config.get("ignore_agent_tools")
if raw_ignore_agent_tools is None:
raw_ignore_agent_tools = True
ignore_agent_tools = self._resolve_bool(raw_ignore_agent_tools, context)
argv: list[str] = ["init"]
if here:
+5 -5
View File
@@ -1,9 +1,9 @@
---
description: Generate a custom checklist for the current feature based on user requirements.
scripts:
sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
sh: scripts/bash/check-prerequisites.sh --json --template checklist-template
ps: scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template
py: scripts/python/check_prerequisites.py --json --template checklist-template
---
## Checklist Purpose: "Unit Tests for English"
@@ -72,7 +72,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Execution Steps
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
@@ -127,7 +127,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
6. **Generate checklist** - Create "Unit Tests for Requirements":
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
+19 -6
View File
@@ -4,6 +4,10 @@ handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
scripts:
sh: scripts/bash/resolve-template.sh constitution-template --json
ps: scripts/powershell/resolve-template.ps1 constitution-template -Json
py: scripts/python/resolve_template.py constitution-template --json
---
## User Input
@@ -70,13 +74,22 @@ and commands read the constitution at runtime and are not modified here.
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
You are updating the project constitution at `.specify/memory/constitution.md`. The active
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
preset/template resolution stack.
Follow this execution flow:
1. Load the existing constitution at `.specify/memory/constitution.md`.
1. Run `{SCRIPT}` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
- The shared resolver applies project overrides, composing preset layers, and extension layers
before the core template fallback. It MUST succeed before continuing.
- If it fails, stop and report the resolution error; do not continue with only one contributing
template layer.
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
values and amendments. Preserve information that is still applicable when applying the newly
resolved scaffold.
- If it does not exist, use the resolved template as the initial document.
- Do not write back to any versioned template layer.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
@@ -90,7 +103,7 @@ Follow this execution flow:
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
3. Draft the updated constitution content using the resolved template as the required structure:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
@@ -128,7 +141,7 @@ If the user supplies partial updates (e.g., only one principle revision), still
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
## Post-Execution Checks
+2 -2
View File
@@ -60,7 +60,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
@@ -79,7 +79,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
+111
View File
@@ -217,6 +217,31 @@ def test_catalog_remove_builtin_is_refused(project: Path):
assert "built-in" in result.output
# Every ``bundle`` error path funnels through ``_fail(str(exc))``, and the
# BundlerError messages interpolate untrusted data -- including the command's
# own argument. An unbalanced closer used to raise MarkupError instead of the
# error, leaving the user with a traceback and no message at all.
@pytest.mark.parametrize(
"argv, expected",
[
(
["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"],
"ssh://ex[/red]ample.com/c.json",
),
(["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"),
(["bundle", "update", "no[/red]such"], "no[/red]such"),
(["bundle", "remove", "no[/red]such"], "no[/red]such"),
],
)
def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str):
result = runner.invoke(app, argv)
assert result.exit_code == 1
# A MarkupError would surface here as an exception rather than a clean exit.
assert isinstance(result.exception, SystemExit)
assert expected in strip_ansi(result.output)
def test_validate_reports_invalid_manifest(project: Path):
data = valid_manifest_dict()
del data["bundle"]["license"]
@@ -237,6 +262,33 @@ def test_validate_accepts_valid_manifest(project: Path):
assert "valid" in result.output
def test_validate_escapes_manifest_markup_in_errors(project: Path):
data = valid_manifest_dict()
# An invalid constraint is echoed back inside the validation error.
data["requires"] = {"speckit_version": ">=1.0[/bold]"}
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
result = runner.invoke(app, ["bundle", "validate", "--offline"])
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert ">=1.0[/bold]" in strip_ansi(result.output)
def test_validate_escapes_manifest_markup_in_warnings(project: Path):
data = valid_manifest_dict()
# Step ids are not charset-validated, and the unresolved-reference warning
# echoes them -- so an otherwise *valid* manifest crashed just as readily as
# an invalid one, on the success path.
data["provides"]["steps"] = [{"id": "step[/bold]a"}]
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
result = runner.invoke(app, ["bundle", "validate", "--offline"])
assert result.exit_code == 0, repr(result.exception)
assert "step[/bold]a" in strip_ansi(result.output)
def test_validate_rejects_broken_reference(project: Path):
# Synthetic component ids resolve to nothing in any catalog → hard failure.
(project / "bundle.yml").write_text(
@@ -267,6 +319,65 @@ def test_build_produces_artifact(project: Path):
assert len(artifacts) == 1
def test_build_escapes_markup_in_output_path(project: Path):
"""The build success line echoes a caller-supplied ``--output`` path.
Brackets are legal in a directory name on both POSIX and Windows, so the
artifact is built and *then* misreported: ``[bold]`` is consumed as a style
tag, and the success line names a path that does not exist on disk.
A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a
path separator on Windows, so this uses the silent-swallow form to keep the
fixture portable.
"""
(project / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(project / "README.md").write_text("# Demo", encoding="utf-8")
out_dir = project / "dist[bold]out"
result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)])
assert result.exit_code == 0, repr(result.exception)
assert list(out_dir.glob("*.zip")), "the artifact should still be built"
assert "dist[bold]out" in strip_ansi(result.output), (
"the reported path must match the directory actually written"
)
def test_list_escapes_markup_in_records(project: Path):
"""``bundle list`` renders record fields that are never charset-validated.
``InstalledBundleRecord.from_dict`` accepts any non-empty string for
``bundle_id``/``version`` and any string for ``installed_at``, so a records
file that *loads cleanly* could still crash the command that displays it.
"""
(project / ".specify" / "bundle-records.json").write_text(
json.dumps(
{
"schema_version": "1.0",
"bundles": [
{
"bundle_id": "demo[/red]id",
"version": "1.0.0[/bold]",
"installed_at": "2026-01-01T00:00:00Z[/dim]",
"contributed_components": [],
}
],
}
),
encoding="utf-8",
)
result = runner.invoke(app, ["bundle", "list"])
assert result.exit_code == 0, repr(result.exception)
output = strip_ansi(result.output)
assert "demo[/red]id" in output
assert "1.0.0[/bold]" in output
assert "2026-01-01T00:00:00Z[/dim]" in output
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
@@ -222,32 +222,6 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
assert "old" not in content
@requires_posix_bash
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
# Regression: with blank markers (config relying on the built-in defaults),
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
# PowerShell ports. Previously the Bash config-parser transport dropped the
# trailing empty marker lines under $(...) command substitution, tripping the
# "malformed config parser output" guard so the default-marker substitution
# became unreachable and the context file was never updated.
markers = {"start": "", "end": ""}
repo_a, repo_b = twin_projects(
tmp_path, context_file="AGENTS.md", context_markers=markers
)
add_plan(repo_a)
add_plan(repo_b)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"<!-- SPECKIT START -->" in content
assert b"<!-- SPECKIT END -->" in content
assert b"at specs/001-demo/plan.md" in content
@requires_posix_bash
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
@@ -344,14 +318,19 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
@requires_posix_bash
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
# Regression: the mtime fallback must discover plan.md in nested scoped
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
# ports and the documented recursive-discovery contract (see #3024). A
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
def test_python_mtime_fallback_finds_nested_plan_matching_bash(
tmp_path: Path,
) -> None:
"""The mtime fallback must recurse into scoped layouts.
A plan created under specs/<scope>/<feature>/plan.md (as produced via
SPECIFY_FEATURE_DIRECTORY) is more than one level below specs/. The old
Python port used a one-level specs/*/plan.md glob and missed it, while the
bash/PowerShell twins recurse (#3024). This locks in the parity.
"""
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
@@ -361,7 +340,39 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/scope-a/002-nested/plan.md" in content
assert b"at specs/backend/001-nested/plan.md" in content
@requires_posix_bash
def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink(
tmp_path: Path,
) -> None:
"""A plan reached via a specs/ symlink out of the project is not selected.
``relative_to()`` is lexical, so ``specs/linked/001-x/plan.md`` looks
in-project even when ``specs/linked`` points outside the tree. Resolving
before the containment check rejects it, so the fallback finds nothing and
the ``at <plan>`` line is omitted rather than naming an out-of-project file
with an in-project-looking path. Mirrors the bash twin's ``_resolved_rel``.
"""
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
outside = repo.parent / f"outside-{repo.name}" / "001-x"
outside.mkdir(parents=True, exist_ok=True)
(outside / "plan.md").write_text("# plan\n", encoding="utf-8")
specs = repo / "specs"
specs.mkdir(parents=True, exist_ok=True)
(specs / "linked").symlink_to(outside.parent, target_is_directory=True)
# Sanity: the plan really is reachable through the symlink.
assert (specs / "linked" / "001-x" / "plan.md").is_file()
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"\nat " not in content
@requires_posix_bash
@@ -508,6 +519,31 @@ def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_mtime_fallback_finds_nested_plan_matches_powershell(
tmp_path: Path,
) -> None:
"""Python's mtime fallback must recurse like the PowerShell twin.
With no feature.json, discovery falls back to scanning under specs/. A plan
at specs/<scope>/<feature>/plan.md sits more than one level deep; the old
Python one-level glob missed it while PowerShell already recurses (#3024).
"""
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
for repo in (repo_a, repo_b):
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
ps = run_powershell(repo_a)
py = run_python(repo_b)
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
assert b"at specs/backend/001-nested/plan.md" in (repo_b / "AGENTS.md").read_bytes()
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
@@ -44,6 +44,20 @@ def test_precedence_default_when_unspecified():
assert _resolve_init_integration(None, None) == "copilot"
def test_precedence_default_honors_env_var(monkeypatch):
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
# With no override and no bundle-declared integration, the env-var default
# applies instead of the hardcoded "copilot".
assert _resolve_init_integration(None, None) == "gemini"
assert _resolve_init_integration(None, _manifest()) == "gemini"
# Explicit override and bundle-declared integration still take precedence.
assert _resolve_init_integration("claude", None) == "claude"
assert (
_resolve_init_integration(None, _manifest(integration={"id": "claude"}))
== "claude"
)
def _build_mini(tmp_path: Path) -> Path:
bundle = tmp_path / "mini"
bundle.mkdir()
@@ -186,6 +186,50 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
_local_manifest_source(str(artifact))
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
"""A malformed bundle.yml inside a .zip must raise BundlerError.
The zip branch parses YAML inline rather than through load_yaml(), so the
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
OSError, so nothing upstream caught it.
"""
artifact = tmp_path / "bad-manifest.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
with pytest.raises(BundlerError, match="Invalid YAML"):
_local_manifest_source(str(artifact))
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
"""`bundle install` reports malformed YAML the same way for all 3 sources.
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
message; the .zip source dumped a yaml.parser.ParserError traceback.
"""
bad_yaml = "bundle: [unclosed\n id: demo\n"
directory = tmp_path / "dir-src"
directory.mkdir()
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
manifest_file = tmp_path / "standalone.yml"
manifest_file.write_text(bad_yaml, encoding="utf-8")
artifact = tmp_path / "artifact.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", bad_yaml)
runner = CliRunner()
for source in (directory, manifest_file, artifact):
result = runner.invoke(app, ["bundle", "install", str(source)])
assert result.exit_code == 1, f"{source.name}: {result.output}"
assert result.exception is None or isinstance(
result.exception, SystemExit
), f"{source.name} leaked {type(result.exception).__name__}"
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
def test_invalid_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,

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