Skip to content

Fix August newsletter review findings - #40

Closed
mnriem wants to merge 101 commits into
mainfrom
mnriem-pr-4442-review-findings
Closed

Fix August newsletter review findings#40
mnriem wants to merge 101 commits into
mainfrom
mnriem-pr-4442-review-findings

Conversation

@mnriem

@mnriem mnriem commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • use the canonical feature-assess stage names
  • label the mixed extension/preset/update list as catalog changes
  • add the missing DEV Community article link and clarify citation placement

Follow-up to the review on github#4442, which arrived after that pull request merged.

Prepared on behalf of @mnriem by GitHub Copilot (model: GPT-5.6 Sol).

github-actions Bot and others added 30 commits August 20, 2026 11:03
…to SkillsIntegration (github#4205)

* Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration

Apply the remediation from the bug assessment on issue github#4199.
Qoder IDE 1.24+ dropped .qoder/commands/ scanning in favour of the
skills layout (.qoder/skills/{skill-name}/SKILL.md). Migrated
QodercliIntegration from MarkdownIntegration to SkillsIntegration,
updating config[commands_subdir] to 'skills' and
registrar_config[dir] to '.qoder/skills' with extension '/SKILL.md'.
Updated tests to use SkillsIntegrationTests base mixin.

Refs github#4199

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

* fix(qodercli): resolve failing skills-flag test and slash invocation

Builds on the qodercli->SkillsIntegration migration (PR github#4205). Qoder IDE
1.24+ is always skills-based, so it should not expose a --skills toggle.
Override the inherited SkillsIntegrationTests.test_options_include_skills_flag
to skip (mirroring Grok/Zed/Droid) and add a test asserting no --skills
option, plus a requires_cli/name/multi_install_safe check.

Also add "qodercli" to ALWAYS_SLASH_AGENTS so hooks and next-steps render
the hyphenated /speckit-<name> invocation instead of the legacy dotted
/speckit.<name> form.

Fixes the single failing test reported for github#4199.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570

* fix(qodercli): migrate legacy extension commands

Retire old flat Qoder extension commands only after their replacement skills are successfully written. Cover old-layout upgrades and both slash invocation states, and update the integration reference path.

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

Co-authored-by: Copilot App <223556219+Copilot@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: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570
…thub#4234)

Update maqa extension submitted by @GenieRobot:
- extensions/catalog.community.json (version, download_url, requires/tools, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes github#4233

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>
Update intake-sequencing-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, templates count, tags, updated_at)
- docs/community/presets.md community presets table

Closes github#4214

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>
…ithub#4190)

_download_remote_manifest's non-zip branch fed the downloaded bytes
straight to `yaml.safe_load(io.BytesIO(raw))`. PyYAML's Reader
auto-detects a UTF-16 BOM on a byte stream, so a well-formed UTF-16
bundle.yml (a realistic PowerShell `Out-File`/`>` output) was silently
*accepted* here, while `yamlio.load_yaml` decodes local sources strictly
as UTF-8 and rejects the identical content with "Could not read ...".

  BEFORE: a UTF-16 manifest downloaded via `bundle info`/`install`
  parses successfully -- exit code 0, no warning.
  AFTER: rejected with "... could not be read: ..." -- exit code 1,
  matching local directory and .zip sources.

This is the same divergence, in the sibling branch of the same function,
that was just fixed for the .zip case in commit 56aec8a (PR github#3958):
"feeding PyYAML the byte stream let its Reader honour a UTF-16 BOM and
accept a manifest yamlio.load_yaml rejects, so zip and directory sources
diverged." That fix covered `_local_manifest_source`'s `.zip` branch
(which this same function calls for zip artifacts); the direct
raw-YAML-download branch a few lines below it had the identical bug.

Also drops the now-unused `import io` from this function.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ithub#4191)

PresetResolver._manifest_declared_template returns the FIRST
'provides.templates' entry matching a given (name, type) pair:

    for tmpl in manifest.templates:
        if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
            ...
            return tmpl, ...

So a preset.yml declaring two templates with the same (name, type) --
e.g. two "command"/"specify" entries pointing at different files -- had
its second entry silently unreachable, while PresetManifest.templates
still counted and exposed both. PresetManifest._validate never checked
for this.

Reject the duplicate at manifest-validation time instead, matching the
sibling fix already applied to ExtensionManifest's provides.templates/
provides.scripts (commit 11e3176, PR github#4016): "The resolver returns the
first entry matching a declared name, so a later duplicate ... was
silently unreachable while still counted". Presets use a (name, type)
composite key rather than extensions' bare name, since the same name can
legitimately recur across different template types (e.g. a "specify"
template and a "specify" command); the fix only rejects a duplicate
within the exact same (name, type) pair.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Update Security Review extension to v2.0.0

Update security-review extension submitted by @DyanGalih:
- extensions/catalog.community.json (version, download_url, repository, author, tags, tools, updated_at)
- docs/community/extensions.md community extensions table

Closes github#4217

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

* Preserve security review tool versions

Carry the submitted minimum versions for the required git tool and optional Node.js CLI dependency into the community catalog entry.

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

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

Copilot-Session: 312140f1-9c82-4e1e-a0ca-9a687ff71e27

---------

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: 312140f1-9c82-4e1e-a0ca-9a687ff71e27
* chore: bump version to 1.0.0

* chore: begin 1.0.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Bumps the codeql-action group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@5595cca...ff2f1c6)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@5595cca...ff2f1c6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v6.4.0...8207627)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6.0.3...3d3c42e)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Add cosmosdb extension submitted by @TheovanKraay to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes github#4238

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>
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: edc3d861-f065-4747-8ed4-30e3e9f0ea99
…b#3843)

* fix: use chunked read for integration and preset manifest hash

Replace unbounded fh.read() with chunked iteration to prevent excessive
memory allocation on large or corrupted manifest files. Applies to both
integrations/catalog.py and presets/__init__.py get_hash() methods.

* test: verify full hash value in get_hash() tests to cover chunked path

The existing tests only checked the sha256: prefix, which would pass
even if the chunked hash was broken. Now verify the complete hash
matches hashlib.sha256(content).hexdigest() to exercise the multi-chunk
path introduced by the chunked read change.
…ithub#4230)

* fix(workflows): stop offering a correction that would not repair the condition

`format_condition_correction` wraps whatever it is handed — correct for a
formatter, wrong to advertise as paste-ready for two inputs it cannot repair.
Both reach the never-evaluated branch, and both were being suggested:

    condition: "   "                -> "{{ }}"
    {{ inputs.name == 'abc         -> "{{ inputs.name == 'abc }}"

Measured what pasting each one does, rather than assuming:

    "   "                       is True   ->  "{{ }}"                     is False
    "{{ inputs.name == 'abc"    is True   ->  "{{ inputs.name == 'abc }}" is False

The blank core interpolates to the empty string. The open quote survives
wrapping, so the raw-close fallback evaluates a truncated comparison whose
result is the string "False", which `evaluate_condition` then reads as the
`false` keyword. In both cases the advertised correction silently inverts the
condition — a different defect, not a fix.

Add `format_condition_remediation`, which the three step validators now call in
place of hand-building the sentence. It offers the correction only when wrapping
would actually repair the input, and otherwise names the fault, matching the
call already made for `condition_has_malformed_expression_block`.

`_has_unbalanced_quote` uses the same left-to-right scan as `_find_block_close`
and `_strip_stray_delimiters`, so "inside a string" means the same thing
everywhere in this module.

I had the second case wrong at first and said the wrapped form "stays always
true" — the new test caught it, and the message and docstring now say inverted.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  133 passed (was 116)
- tests/unit + tests/test_workflows.py  1216 passed (was 1199), 22 failed
  before and after — the pre-existing symlink tests needing Windows elevation.

Mutation-checked: removing either gate fails exactly the 9 new parametrised
cases and nothing else.

* fix(workflows): withhold the correction whenever wrapping cannot repair the core

Copilot found two more holes in the previous commit, and both were real.

1. The quote-balance gate was not sufficient. `inputs.name ==` has a non-empty,
   quote-balanced core, so a correction was still advertised:

       inputs.name ==  ->  "{{ inputs.name == }}"     True -> False

   The missing operand resolves to None, the comparison evaluates False, and the
   author again trades an always-true condition for an always-false one.

2. The message named the wrong mechanism. It said the wrapped form goes through
   the raw-close fallback. Measured: `_is_single_expression("{{ inputs.name ==
   'abc }}")` is True, so it takes the typed fast path instead.

Stop enumerating broken shapes. `_wrapping_would_not_repair` now reports the
first reason wrapping cannot yield the intended expression — empty core,
unclosed quote, unbalanced bracket, or an operator missing an operand — and the
advice names it instead of offering a suggestion.

`_has_incomplete_operand` reads `_COMPARISON_OPERATORS`, extracted from
`_evaluate_simple_expression`, so the check cannot drift from what the evaluator
actually splits on. The messages now describe the text itself rather than the
interpolator path it will take: asserting an internal route is what made the
previous two versions wrong.

Tests state the property rather than listing shapes:
`test_every_offered_correction_is_a_complete_expression` asserts that anything
advertised as paste-ready survives both validators, so a new malformed shape is
caught by the invariant rather than by another fixture row.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  182 passed (was 133)
- tests/unit + tests/test_workflows.py  1282 passed (was 1233), 22 failed
  before and after — pre-existing symlink tests needing Windows elevation.

Mutation-checked, each gate against its own cases: dropping the operand gate
fails 12, the bracket gate 3, and removing an operator from
`_COMPARISON_OPERATORS` fails 1. That last one passed vacuously at first because
the test parametrised over the constant it was checking — the same can't-fail
shape this module rejects — so it is hard-coded now.

* fix(workflows): check every operator position and match bracket types

Copilot found two more, and both were right.

1. `_has_incomplete_operand` inspected only the first occurrence of each
   operator, and its end-of-string check covered only trailing boolean keywords:

       inputs.a == inputs.b ==   -> correction still offered, True -> False
       and inputs.ready          -> correction still offered, True -> False

   That is the same defect this PR's parent commit fixed one level up — stopping
   at the first match — reintroduced in the gate meant to prevent it. It now
   splits on every top-level occurrence and requires every operand to be
   non-empty.

   A stripped core also loses the space that delimits a word operator, so
   `inputs.a not in` matched nothing. `_WORD_OPERATORS` is derived from
   `_COMPARISON_OPERATORS` and matched against both ends without it.

2. `_has_unbalanced_bracket` counted depth, so mismatched types cancelled:

       inputs.f(]   -> correction still offered, True -> False

   It tracks opener types on a stack and rejects a non-matching closer.

The docstring Copilot flagged at line 950 is unchanged on purpose: it does not
attribute the inversion to the raw-close fallback, it records that two earlier
versions did and were wrong because `_is_single_expression` accepts the wrapped
form. That thread is marked outdated and refers to the text before `6944920`.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  207 passed (was 182)
- tests/unit + tests/test_workflows.py  1307 passed (was 1282), 22 failed
  before and after — pre-existing symlink tests needing Windows elevation.

Mutation-checked: depth-only brackets fails 9, first-occurrence-only fails 3,
dropping the end-of-core word scan fails 16.

* fix(workflows): reject an unregistered filter and prose before suggesting a wrap

Copilot's remaining point was the strongest one on this PR: `reason is None` only
excluded four structural shapes, and structural shapes cannot establish that
wrapping produces a working expression. Two inputs proved it:

    inputs.items | length      -> offered; wrapped form raises
                                  ValueError("unknown filter 'length'")
    he said "hi"\nthen left    -> offered; wrapped form resolves to None,
                                  True -> False

The first replaces an always-true condition with a crash, the second inverts it.

Two checks close the gap, both reading the evaluator rather than guessing:

- `_unregistered_filter` walks the top-level `|` segments and reports the first
  name missing from `_REGISTERED_FILTERS`, the same tuple `_apply_filter` raises
  on.
- `_reads_as_prose` reports a core that is several bare terms with no operator
  and no filter joining them. Quoted spans and bracketed groups are skipped, so
  `inputs.f('a b')` and `inputs.name == 'two words'` are unaffected, and a `not `
  prefix is allowed.

`he said "hi"\nthen left` was in `OFFERED_CORRECTION_INPUTS` only because that
fixture was built as `TRICKY_CONDITIONS + [...]`. TRICKY_CONDITIONS exists to
exercise the formatter's quoting and deliberately contains prose, so reusing it
asserted the wrong thing. The list is explicit now, and the tricky-quoting entries
that really are expressions are carried over by hand — adding prose to that
fixture can no longer widen what this invariant claims.

`inputs.tags | length > 0` was also mine, and `length` is not a registered
filter; it is `join(',')` now.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  212 passed (was 207)
- tests/unit + tests/test_workflows.py  1312 passed (was 1307), 22 failed
  before and after — pre-existing symlink tests needing Windows elevation.

Mutation-checked: dropping either new gate fails 3 cases and nothing else.

* fix(workflows): ask the evaluator whether the core parses, instead of guessing

Copilot found two more shapes the structural gates did not know about:

    inputs.tags | join   -> offered; `join` is registered, but with no argument
                            `_apply_filter` raises ValueError
    inputs.count+1       -> offered; the evaluator has no arithmetic, reads it as
                            a key named "count+1", and the wrapped form resolves
                            to None, turning a truthy condition false

That is the fifth shape in four rounds, which is the argument against enumerating
shapes at all. Replace the two structural checks with two that read the evaluator:

- `_evaluator_rejects` runs the core through `_evaluate_simple_expression` against
  a probe namespace and returns its own error. Any filter under an unknown name or
  in an unsupported form is now reported by the code that will actually run, so
  `_unregistered_filter` — which restated the filter table — is gone.
- `_is_not_a_bare_path` covers what a probe cannot: a single-term core is resolved
  as a path lookup, so every dotted segment must be an identifier. `count+1` is
  not, and neither is prose, so `_reads_as_prose` is gone too.

The probe namespace resolves roots but not leaves, deliberately. A namespace that
answers every lookup also answers `inputs.count+1`, hiding the shape the probe
exists to expose.

Net effect is two helpers fewer and no restatement of the evaluator's tables.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  230 passed (was 212)
- tests/unit + tests/test_workflows.py  1330 passed (was 1312), 22 failed
  before and after — pre-existing symlink tests needing Windows elevation.

Mutation-checked: dropping either check fails 6 cases and nothing else.

* fix(workflows): stop the probe rejecting valid expressions, and match the path grammar

Copilot found a false positive in the probe, which is worse than the false
negatives the earlier rounds fixed: it withheld a correction from a condition
that was already correct.

    steps.emit.output.stdout | from_json      -> refused
    inputs.tags | join(inputs.separator)      -> refused

Both are valid; the first is exercised in tests/test_workflows.py. The probe
hands `from_json` a dict and it raises, so treating every probe error as a
rejection blamed the author for the placeholder's type. `_evaluator_rejects` now
reports only the two failures `_apply_filter` raises about the expression itself
-- an unknown filter name, and a registered filter used in an unsupported form.
Everything else a probe run raises is about probe values.

`_TERM_SUFFIX` also accepted any bracket contents and repeated indexes, while
`_resolve_dot_path` matches `^([\w-]+)\[(\d+)\]$` -- one numeric index. So
`inputs.tags[foo]` and `inputs.matrix[0][1]` passed as paths, resolved to None,
and were offered a correction that turns a truthy condition false. `_PATH_SEGMENT`
is that grammar now. It also replaces `str.isidentifier`, which was wrong in the
other direction: the resolver allows a hyphen and a leading digit in a key name.

Lint: this branch had added 3 ruff errors (2x SIM102, PIE810/UP037 on new code)
and left a top-level class without its blank lines. `ruff check` on this file is
back to the 5 pre-existing errors on `main`, all in code this PR does not touch.

On the E305 comment specifically: `ruff rule E305` reports "Selection `E305` has
no effect because preview is not enabled", and `ruff check --select E305` on this
file passes, so the repository's CI does not report it. The blank lines were still
wrong and are fixed.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  236 passed (was 230)
- tests/unit + tests/test_workflows.py  1336 passed (was 1330), 22 failed
  before and after -- pre-existing symlink tests needing Windows elevation.

Mutation-checked: treating every probe error as a rejection fails 2, loosening
the path grammar fails 2.

* fix(workflows): validate operands recursively, and keep probe-value errors out

Copilot found three more, and the first explains why this took so many rounds:
every gate so far only inspected the shape it was written for.

    inputs.a === inputs.b   -> offered; splits cleanly on `==`, and the evaluator
                               reads `= inputs.b` as a path, resolving to None
    bogus == 'x'            -> offered; unknown root, same result
    inputs.payload | from_json()  -> offered; raises at run time

`_unresolvable_term` replaces `_is_not_a_bare_path` and walks operands the way
`_evaluate_simple_expression` does -- filters, `or`/`and`/`not`, comparisons --
down to the leaves. A leaf must be a literal or a dotted path rooted in
`_NAMESPACE_ROOTS`, the roots `_build_namespace` actually supplies. Both shapes
above fall out of that without either being named.

`_evaluator_rejects` now keeps only the errors `_apply_filter` raises about the
filter *expression*. Those quote the segment back as `got '| ...'`; its value
errors name the type they received, which under a probe is the placeholder. The
previous prefix list missed `from_json()` (a wiring error) and, when widened by
filter name, wrongly rejected `steps.emit.output.stdout | from_json` (a value
error) -- the regression the round before had just fixed.

One case fell out that no review raised: `_find_top_level` matches " and " with
literal spaces, so a newline before the keyword is not an operator.
`inputs.x == 1\nand inputs.name == 'abc'` evaluates False wrapped, where the same
expression with a space evaluates True. It was in the offered fixture; it is a
refusal case now.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  253 passed (was 236)
- tests/unit + tests/test_workflows.py  1353 passed (was 1336), 22 failed
  before and after -- pre-existing symlink tests needing Windows elevation.
- ruff check on this file is back to the 5 errors already on main.

Mutation-checked: dropping the recursion fails 15, dropping the namespace-root
check fails 5, treating every probe error as a rejection fails 2.

* fix(workflows): mirror the evaluator's literal and root tests exactly

Three more from Copilot, all cases where my check approximated the evaluator
instead of matching it:

    1e3        -> offered; no "." so the evaluator calls int(), which fails, and
                  it falls through to a path lookup. float() alone accepted it.
    'a' 'b'    -> offered; the evaluator requires the opening quote's match to be
                  the final character, which first/last-character equality is not.
    inputs[0]  -> offered; `_build_namespace` hands back mappings, so an indexed
                  root resolves to None however the index is written.

All three are truthy before wrapping and False after, which is the inversion this
change exists to prevent.

`_looks_numeric` and `_is_literal` now use the evaluator's own tests rather than a
looser stand-in, and the root segment is matched without stripping an index off it
first.

Not fixed, and worth being explicit about: `inputs.tags | join(5)` is still
offered. `join` always raises for a non-string separator, but that is a *type*
rule, and `_evaluator_rejects` deliberately ignores value errors because under a
probe they usually describe the placeholder rather than the author's text. The two
cannot be told apart from the message alone -- `join: expected a string separator,
got int` and `join: ..., got NoneType` differ only in a type name the probe may
have supplied. Catching it means encoding each filter's argument types in the
validator, which is the reimplementation this PR has been backing away from.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  267 passed (was 253)
- tests/unit + tests/test_workflows.py  1367 passed (was 1353), 22 failed
  before and after -- pre-existing symlink tests needing Windows elevation.
- ruff check on this file is back to the 5 errors already on main.

Mutation-checked: restoring the bare float() fails 2, restoring the
first/last-character quote test fails 3.

* fix(workflows): mirror list literals and filter arguments in the operand check

Two shapes the leaf check did not mirror, each wrong in the opposite
direction.

A list literal is a term the evaluator understands -- it recurses into
the elements rather than resolving the brackets as a name. Resolving
them as a path reported `"['x', 'y']" is not a name the evaluator can
resolve` and withheld the correction from `inputs.tag in ['x', 'y']`,
a condition wrapping repairs completely.

A filter argument is an ordinary operand to `_apply_filter`, which
evaluates it with `_evaluate_simple_expression` like any other.
Skipping it offered `inputs.tags | join(bogus)` as paste-ready:
`bogus` is no namespace root, arrives as None, and the wrapped form
raises `join: expected a string separator, got NoneType`. Parsed with
the same pattern `_apply_filter` uses, so a form this does not
recognize is left to the evaluator probe rather than guessed at.

Every case is asserted against what the evaluator does with the
wrapped form, not against a restatement of the check.

* fix(workflows): let an indexed `item` root keep the correction

`item` is the only namespace root that is not always a mapping.
`StepContext.item` is `Any` and a fan-out assigns the item value
itself, so when that value is a list `_resolve_dot_path` indexes it and
`item[0] == 'x'` resolves. Rejecting every indexed root withheld the
correction from a condition that evaluates.

The other roots come back from `_build_namespace` as mappings, so the
index branch finds no list and returns None however the index is
written. The strip is therefore for `item` alone, and the paired test
pins that it does not widen into "any indexed root".

This narrows the root check added earlier in this branch, which was
written as though every root were a mapping.
Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(bundler): reject non-string manifest list members

Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous)

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

* docs(bundler): clarify string list validation

Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous)

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

---------

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

`SwitchStep.execute` matched with `str(value)` and no strip. The values a
switch dispatches on are overwhelmingly captured command output, and
`ShellStep` stores `proc.stdout` verbatim, so `run: echo approve` resolves
to "approve\n" — which matches no `approve:` case:

  stdout stored : 'approve\n'
  matched_case  : '__default__'      <-- silently wrong
  next steps    : ['fallback']

The switch falls through to `default:` (or dispatches nothing at all) while
still reporting COMPLETED. A workflow author cannot fix it themselves: the
registered filters are default/join/map/contains/from_json — there is no
`trim`.

spec-kit already treats exactly this as a bug wherever else it matches a
resolved string against declared literals — `evaluate_condition` strips for
this same shell-newline reason, and `InitStep._resolve_bool` does
`resolved.strip().lower()`. Switch case keys are such literals, and this was
the only site not stripping.

`expression_value` still reports the raw value, so nothing downstream loses
information, and a genuine mismatch ("approve-later") still falls through.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SwitchStep.validate` requires `expression` and type-checks `cases`, but
never checks that `cases` is PRESENT. It is the only control-flow step whose
branch payload is optional:

  if       -> requires 'then'
  fan-out  -> requires 'items' and 'step'
  fan-in   -> requires a non-empty 'wait_for'
  gate     -> requires 'message'
  switch   -> cases optional

So a switch whose branch table is absent or mistyped — `case:` for `cases:`
is the obvious slip — passes validation with zero errors:

  if   missing then : ["If step 'x' is missing 'then' field."]
  fanout missing all: ["Fan-out step 'y' is missing 'items' field.", ...]
  switch typo case: : []
  switch no cases   : []

and then at run time reports COMPLETED with
`matched_case: "__default__"` — a default it does not even declare — having
dispatched nothing, so the whole run "succeeds". That is the "silent empty
result + COMPLETED" wiring bug the fan-in guard exists to prevent.

An explicitly declared but empty `cases: {}` is still a declaration and
stays valid, pinned by a test.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update specassay-check extension submitted by @rdryfoos to:
- extensions/catalog.community.json (version, download_url, description, provides.commands)
- docs/community/extensions.md community extensions table

Closes github#4252

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>
* chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1

Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@c771a70...20cfd1b)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* fix(workflows): align setup-uv generated sources

Update the agentic workflow sources, action cache, generated metadata, and regression expectation for setup-uv v10.0.1.

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

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

Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40

---------

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>
Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40
* docs: add workflow quickstarts

Add concise setup and command recipes for SDD, structured bug fixing, and standalone idea assessment.

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

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

Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba

* docs: clarify quickstart release tags

Tell readers to replace the placeholder in every standalone quickstart with the latest tagged release.

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

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

Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba

---------

Copilot-Session: af05cfd0-746d-4292-9380-0a785a991cba
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 0a6b3d69-9459-4a26-a2f6-4d946e368c81
* docs: add project history page

Document Spec Kit's stewardship periods, major technical milestones, community catalogs, and evolution from core SDD processes to a composable toolkit.

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

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

Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597

* docs: clarify stewardship wording

Use the possessive form to make clear that the focus belongs to the maintainer team.

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

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

Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597

---------

Copilot-Session: 46d71f0b-59fc-4bdb-a57e-620210197597
Add a safe brownfield onboarding path and connect it to the docs homepage, quick start, navigation, and spec maintenance guidance.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 663b4e07-d79f-4bd1-aa86-8aeea21a2643
Use the README logo for the DocFX navbar, favicon, and landing hero, and add Upgrade to the balanced Explore the docs grid.

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

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

Copilot-Session: 78ca683f-2995-44eb-a2fa-f7600c18bbd9
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

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

Copilot-Session: 0f0ad8f8-ea22-44ca-86c7-a485c888ec91
* chore: bump version to 1.0.1

* chore: begin 1.0.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Update reconcile extension submitted by @stn1slv:
- extensions/catalog.community.json (version, download_url, requires.speckit_version, provides.hooks, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes github#4279

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>
Update archive extension submitted by @stn1slv:
- extensions/catalog.community.json (version, download_url, requires.speckit_version, updated_at)
- docs/community/extensions.md community extensions table

Closes github#4278

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>
Update specassay preset submitted by @rdryfoos:
- presets/catalog.community.json (version, download_url, updated_at)

Closes github#4253

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>
CrazyBaran and others added 29 commits September 1, 2026 13:55
…kage (github#4351)

Bundled extensions (agent-context, git, assess) have no download URL, so
`specify extension update` could offer a version bump it then failed to
install: step 5 unconditionally called catalog.download_extension(),
which errors out for catalog entries without a URL (github#4345).

Resolve the update source for bundled extensions from the copy shipped
with the running spec-kit release instead:

- `_bundled_update_source()` locates the local bundled copy and parses
  its manifest version.
- `_archive_extension_directory()` packages that copy as a ZIP so the
  update flows through the identical hardened archive pipeline
  (bounded extraction, manifest preflight, ID/version checks,
  backup/rollback) rather than growing a second install path. Symlinks
  are never followed into the archive.
- When the local copy lags the catalog (or is missing), the update is
  blocked with an explicit "upgrade spec-kit, then rerun" message
  instead of installing an intermediate version or crashing; when the
  local copy is newer than the catalog, it installs the local version.

Tests pin the install-from-local-copy route, every blocked-update
branch, the newer-local-copy case, archive content/symlink behavior,
and execute-bit restoration through the archive install route
(POSIX-only; install_from_directory's trailing
ensure_executable_scripts() re-establishes modes that ZIP extraction
drops).

Part 1 of the series requested in review on github#4351; refs github#4345.

Assisted-by: Claude Code (model: claude-fable-5)

Co-authored-by: Jakub Baranowski <cr4zybaran@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(bundler): reject unsupported catalog payload versions

Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous)

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

* test(bundler): cover compatible catalog schema versions

Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous)

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(presets): let a preset declare a required extension

A preset whose command overrides call into an extension is inert without it,
but the overrides fall through to the core workflow, so nothing errors -- the
feature just silently does less than the user expects. Until now the only
place that dependency could be stated was the README, which fails exactly the
user who did not read it.

Add an optional requires.extensions to preset.yml, accepting either a bare
extension id or a mapping with an optional version specifier and an optional
required flag. Validation mirrors the requires.speckit_version strictness from
github#3980: a non-list, a member that is neither string nor mapping, a missing or
malformed id, a non-string or unparseable version, and a non-boolean required
each raise PresetValidationError rather than surfacing later as a bare
TypeError from re.match or SpecifierSet.

On `specify preset add`, warn once for each unsatisfied dependency, naming the
extension and the command that installs it. The check runs at the single point
where the --dev, --from, and catalog paths converge, so all three behave the
same. It warns rather than fails: these presets are written to degrade safely,
and three catalog entries already declare the dependency, so failing would
break installs that work today.

The field is optional, so every existing preset stays valid and silent.

Closes github#4231

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): match dependency remediation to the reason, and flag disabled

Addresses review feedback on github#4250.

`specify extension add <id>` refuses an already-installed extension without
--force, so suggesting it for a version mismatch handed the user a command
that could only fail. Suggest `extension update` for a version mismatch and
`extension enable` for a disabled one, keeping `add` for a genuinely missing
extension.

A disabled extension was also treated as satisfied, because the registry entry
exists. Resolution skips disabled extensions, so the preset stays exactly as
inert as if the extension were absent, with no warning to explain it. Report
it as a distinct "disabled" reason, ahead of any version check -- enabling is
the prerequisite, and the version may be fine once it is.

Also correct the closing line, which said the extensions "will do nothing
until they are present" -- inaccurate for a disabled extension, which is
present.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): avoid promising unsatisfiable extension updates

Assisted-by: ChatGPT (model: GPT-5, supervised)

* docs(presets): describe requires.extensions version as a full constraint

Addresses review feedback on github#4250.

The guide said a mapping was for "a version floor", but the field accepts any
PEP 440 specifier, so upper bounds, exact pins, and exclusions were all
undocumented. Say "version constraint", show a bounded range in the example,
and state the accepted forms explicitly.

Two adjacent claims had also drifted from the behaviour and are corrected in
the same pass. The guide promised the warning would "name the command that
fixes it", which stopped being true for a version mismatch once that case
began stating the constraint instead of naming a command that cannot satisfy
every specifier. And the notes listed only missing and version-unsatisfied
dependencies as warned about, never mentioning disabled ones.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): treat stale entries as unmet and unparseable versions as uncomparable

Addresses review feedback on github#4250.

A registry entry is not proof the extension can contribute. When the entry
survives after .specify/extensions/<id> is removed, PresetResolver skips the
extension outright -- both template lookup and layer collection guard on
is_dir() -- so the preset is as inert as if it were never installed, while the
surviving entry read as satisfied. Report that state as a distinct "stale"
reason, ahead of the disabled and version checks, and remediate it with a
forced reinstall rather than a plain add.

The uncomparable-version guard also only covered non-string values. An
unparseable string such as "unknown" passed it and reached version_satisfies(),
which catches InvalidVersion and returns False -- reporting a mismatch against
a version that was never actually evaluated, and contradicting the documented
behaviour that unusable versions are not invented into mismatches. Check
parseability before comparing so only real comparisons reach the warning.

The test helper now creates the extension directory alongside the registry
entry, matching what the installer does, with an opt-out for the stale case.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): treat an unregistered extension directory as satisfied

Addresses review feedback on github#4250.

An absent registry entry was reported as a missing dependency, but it does not
mean the extension is unusable. _get_all_extensions_by_priority() admits a
safe on-disk directory as an unregistered extension at implicit priority 10,
so its artifacts resolve and the preset works -- the warning was a false alarm
telling users to install something already in use.

Treat a matching directory as present, guarded the same way resolution guards
itself: the id must be a safe registry id, and the registry must not be
corrupt, since a corrupt one makes that path fail closed and contribute
nothing. An unregistered extension has no recorded version, so a declared
constraint is uncomparable rather than unsatisfied.

This is the mirror of the stale case in the previous commit. Between them, the
check now agrees with resolution in both directions: an entry without files is
unmet, and files without an entry are satisfied.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): exclude corrupted registry ids and flag discovery-only installs

Addresses review feedback on github#4250.

ExtensionRegistry.get() returns None for a corrupted (non-dict) entry exactly
as it does for an absent one, so a corrupted entry whose directory survived
reached the unregistered-directory fallback and was reported satisfied. keys()
deliberately retains corrupted ids precisely so resolution does not re-admit
those directories, so the fallback now requires the id to be absent from
keys() -- it can no longer revive what resolution excludes. is_corrupt() does
not cover this, as it validates only the registry container.

`extension add <id>` resolves through the catalogs, and the default community
catalog is discovery-only, so installing by id is rejected for anything listed
only there. That covers all three extensions this feature exists to serve --
aide, mde, and speckit-inventory -- meaning the first live warnings would have
pointed at a command that exits 1. Note the --from <archive-url> form once,
after the list, for the two reasons that suggest an install by id.

Determining discovery-only status per dependency would mean a catalog fetch
inside `preset add`, so the note is unconditional rather than risking a
network call on an install path that has never needed one.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): keep leading-hyphen ids out of suggested extension commands

Addresses review feedback on github#4250.

`^[a-z0-9-]+$` admits a leading hyphen, so a dependency id such as `--force`
produced `specify extension add --force`. Typer parses that as an option
rather than the positional extension argument, so the advertised fix could not
run at all -- and the stale, disabled, and version remedies had the same flaw.

Reuse _command_safe_id from the extensions commands, which already rejects a
leading hyphen and falls back to a `<extension-id>` placeholder, rather than
adding a second implementation of the same rule that could drift from it. The
displayed id keeps plain Rich escaping, since only the copyable command needs
to survive the parser.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): report corrupt entries, survive an unreadable registry, correct the footer

Addresses review feedback on github#4250, plus two issues found while self-reviewing
the same code.

ExtensionRegistry construction can raise OSError -- _load() recovers from
malformed content but deliberately lets OSError through, and is_corrupt()
re-reads the file. This check runs after the install has already completed and
preset_add handles only preset-domain errors, so an unreadable registry turned
a finished install into a traceback over what is only a warning. An
unreadable registry now yields no results instead.

A corrupted entry was conflated with an absent one. get() returns None for
both, but is_installed() still counts the key, so the suggested
`extension add <id>` is refused as already installed. It is now a distinct
"corrupt" reason remediated with a forced reinstall.

The closing note asserted that dependent features "will do nothing" and that
the preset is "safe to use". Neither holds for a version mismatch: the
extension is installed and enabled, so the preset does invoke it, and the
combination is untested against the declared constraint rather than safe. The
note is now split by consequence.

Found while self-reviewing, in the same two areas this review keeps surfacing:
the id pattern used re.match with an anchored `^...$`, but `$` also matches
before a trailing newline, so "demo-ext\n" validated here while
PresetResolver._is_safe_registry_id (fullmatch) rejects it, and the newline
would have reached a printed command. And declaring one dependency twice
warned twice; exact repeats now collapse, while two entries for one id with
different constraints are still both checked, since both have to hold.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)

* fix(presets): stop advertising a bare-id install as a guaranteed fix

Addresses review feedback on github#4250.

The per-dependency line labelled `specify extension add <id>` as "Fix with",
while the closing note said an archive URL is required -- the remedy and the
note contradicted each other, and for every extension motivating this feature
the bare-id command is refused outright, since all three are listed only in
the discovery-only community catalog.

Label the remedy by the action it performs rather than asserting it fixes the
problem: "Install with", "Reinstall with", "Enable with", and "Needs" for a
version constraint, which was never a command in the first place.

Replace the contradictory note with what actually happens. The discovery-only
rejection prints the exact `--from <archive-url>` invocation to use, so the
bare command is a signpost rather than a dead end, and saying so is both
accurate and useful. Determining which catalog an extension came from would
require a catalog fetch on an install path that touches no network, so the
note stays unconditional and is now only emitted when a suggested command
actually resolves through the catalogs.

Assisted-by: Claude Code (model: Claude Opus 5, autonomous)
Replace check-then-act pattern with unlink(missing_ok=True) to eliminate
TOCTOU race condition in finally blocks.
* fix: add JSON error handling to auth config loader

Wrap json.loads() in load_auth_config() with try/except to catch
JSONDecodeError and raise a clean ValueError with a descriptive
message, matching the convention used for all other validation
failures in the same function.

* fix: add JSON error handling to auth config loader

Wrap json.loads() in load_auth_config() with try/except to catch
malformed JSON and raise a clean ValueError with path context.

Update test to expect ValueError instead of JSONDecodeError.
* fix: escape Rich markup in workflow error output

Escape user-controlled exception text with _escape_markup() to prevent
Rich from interpreting square brackets as markup tags, which could
corrupt output or raise MarkupError. Matches the pattern used in
workflow_resume and all other err.print() calls in this file.

* fix: escape Rich markup in workflow error output

Escape exception text with _escape_markup() to prevent Rich from
interpreting square brackets as markup tags in error messages.

Covers all three workflow error handlers:
- load_workflow() ValueError handler
- engine.execute() ValueError handler
- engine.execute() generic Exception handler
…ead of silently mis-binding it (github#3894)

* fix(workflows): refuse a filter mixed with a comparison operator

The pipe is detected before the boolean/comparison operators, so a filter
written on the right-hand operand was applied to the comparison's BOOLEAN
RESULT instead of to the operand:

  {{ inputs.count > inputs.limit | default(5) }}  -> False

With count=10 and limit missing, `count > limit` is evaluated first and
`default` is then applied to the resulting bool — a no-op, since a bool is
never empty — so the expression silently returns the comparison against
the *unfiltered* operand. The author meant `10 > 5` = True.

This module already refuses the mirror case rather than guessing:

  {{ inputs.missing | default('7') > '5' }}
    -> ValueError: filter 'default' used in an unsupported form

Same ambiguity, opposite handling. Refuse both the same way so an
ambiguous expression is reported instead of quietly producing the answer
the author did not ask for.

No legitimate expression is affected: applying `default` to a bool is a
no-op, and `join`/`map`/`contains` on a bool is an error, so there is no
working use of a filter on a comparison result.

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

* fix(workflows): detect a leading unary `not` in the ambiguity check

Review catch: unary `not` is a leading prefix, not an infix token, so it has
no surrounding space for the operator scan to match — the parser itself
tests it with `expr.startswith("not ")`. It was therefore absent from the
guard, and the mis-binding this PR exists to reject survived:

  {{ not inputs.missing | default(1) }}  ->  True

`not inputs.missing` is evaluated first and `default` is applied to that
boolean (a no-op), so the expression silently returns True where the author
meant `not 1` = False.

Check the prefix the same way the parser does. A `not` that follows
`and`/`or` was already caught by those tokens. Verified:

  not inputs.missing | default(1)            -> refused (operand of 'not')
  not inputs.value | default(1)               -> refused (operand of 'not')
  inputs.count > inputs.limit | default(5)    -> refused (operand of '>')
  inputs.flag and not inputs.value | default(1) -> refused (operand of 'and')
  not inputs.value / not inputs.flag          -> unchanged (True / False)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ithub#3842)

* fix: narrow bare except Exception in preset command reconciliation

Replace overly broad except Exception with specific exception types
(ImportError, FileNotFoundError, OSError, ValueError, TypeError) to
let programming errors propagate while still falling back to generic
path-based registration for expected failures.

* fix: narrow bare except Exception in preset command reconciliation

Remove ValueError and TypeError from the except tuple per Copilot feedback.
These can mask programming/contract errors that should fail fast.
Keep only ImportError, FileNotFoundError, OSError which are expected
from extension discovery/import/IO failures.
…ithub#4145)

`_read_prompts_yml` documents that it "Returns an empty list if the file is
missing, malformed, or contains no valid prompt entries", but it only
filters at the entry level (`isinstance(item, dict)`) — it never validates
the entry's `name`.

`_merge_prompt_entries` then does `name = entry.get("name", "")` followed by
`if name in generated_by_name:`, a dict membership test. A hand-edited
`.rovodev/prompts.yml` whose entry has a YAML sequence or mapping `name`
therefore raises an unhandled TypeError out of setup():

  name=list     -> TypeError: unhashable type: 'list'
  name=mapping  -> TypeError: unhashable type: 'dict'
  name=int      -> OK
  name=null     -> OK

Only the unhashable shapes crash. Every `specify init` /
`integration install` / `integration upgrade` for rovodev on that project
then aborts with a raw traceback, and the user's prompts.yml is never
rewritten.

A non-string name can never match a generated entry, so treat it like any
other unmatched entry and preserve it verbatim.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thub#3904)

* fix: remove TOCTOU race in RunState.load

Remove exists() check before open() and catch FileNotFoundError directly.
This prevents a race where the file is deleted between check and open,
while preserving the descriptive error message.

* fix: skip corrupted state.json in list_runs() instead of aborting

Catch OSError, JSONDecodeError, and UnicodeDecodeError to skip bad
entries gracefully so valid runs are still listed.

Add regression tests:
- test_list_skips_invalid_utf8_with_valid_sibling
- test_list_skips_oserror_with_valid_sibling
…ithub#4146)

`_parse_edit` collected the shorthand operation keys by iterating the
frozenset:

    shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw]

`_SHORTHAND_OPERATION_KEYS` is `VALID_OPERATIONS`, a frozenset, so its
iteration order depends on per-process string-hash randomization. The two
error messages built from that list named the offending keys in a different
order on every run for the exact same overlay file:

  ["Edit at index 0 has multiple operation keys: 'insert_after', 'remove'."]
  ["Edit at index 0 has multiple operation keys: 'remove', 'insert_after'."]
  ["Edit at index 0 has multiple operation keys: 'remove', 'insert_after'."]

Iterating `edit_raw` instead yields the user's declared order and is
deterministic. Dict keys are always hashable, so the membership test is
safe in this direction too.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…github#4148)

Both overlay writers in `overlays/_commands.py` called
`yaml.safe_dump(data, sort_keys=False)` without `allow_unicode=True`, so
every non-ASCII character was rewritten as a `\uXXXX` / `\xNN` escape inside
a double-quoted scalar. Every other YAML writer in the repo already passes
`allow_unicode=True` (agents.py, bundler/lib/yamlio.py, extensions,
integrations/base.py, ...).

Overlay files are explicitly hand-authored and hand-edited -- the format is
documented in docs/reference/workflows.md and users are told to write these
files. `overlay add`, `enable`, `disable` and `set-priority` all round-trip
the file through `safe_dump`, so merely toggling an overlay mangled a UTF-8
file the user wrote by hand:

    message: "Revisar el plan — \xBFaprobar? 日本語"

The value still parses back identically, so this is not corruption -- it is
the loss of a documented, hand-edited file's legibility.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update Charter extension to v0.6.1

Update charter extension submitted by @Huljo:

- extensions/catalog.community.json (version, download_url, etc.)

- docs/community/extensions.md community extensions table

Closes github#4404

Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

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

* Fix fragments entry in catalog community JSON

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@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: Ken Schlobohm <keschlob@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
github#4396)

The bash wrap strategy rewrote layer_content in place and then re-tested
the string it had just modified. When the resolved core content held a
literal {CORE_TEMPLATE}, every pass reintroduced the token and the loop
never terminated.

Consume the wrapper left to right instead, appending each segment and the
core content to an accumulator. Work is bounded by the placeholders in the
original wrapper and inserted content is never re-examined, matching the
single-pass semantics the PowerShell (.Replace) and Python (.replace)
ports already have -- so this aligns bash with the other two rather than
introducing new behaviour.

The regression mode is a hang rather than a wrong value, so the new parity
test passes a timeout; run() grows an optional timeout parameter for that.
Without it a reintroduced bug would stall the suite instead of failing it.

Fixes github#4385
* chore: bump version to 1.0.4

* chore: begin 1.0.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* Add Axi Extension to community catalog

Add axi extension submitted by @d0whc3r to:\n- extensions/catalog.community.json (alphabetical order)\n- docs/community/extensions.md community extensions table\n\nCloses github#3948\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

* Add tools requirement to catalog community JSON

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ken Schlobohm <keschlob@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* allow hyphen in command ref token names

the token pattern was matching only A-Z 0-9 and underscore so a command
name like speckit.agent-context.update can not be written as a token and
the token stays in the output as plain text

now the character class allows a hyphen also in both places that resolve
the token

* document that a hyphen stays inside a segment

the guide still said the token scheme does not carry hyphens, which is the
opposite of what this branch does. added the real bundled command as the
example since speckit.agent-context.update is the one that was unreachable
before this.

* replace the skills mode limitation with what skills mode actually does

the callout said a command ref token reaches codex zcode and kimi verbatim.
it does not. _resolve_command_ref_tokens inside _register_extension_skills
resolves the same token shape against the active skill style. the callout was
right that resolve_command_refs is never called there and wrong about what
follows from it.

* name the right invocation for each skills agent

the callout said kimi renders the bare slash form. it does not, kimi is in
SKILL_COLON_AGENTS only and falls through both branches to its own
build_command_invocation which returns /skill:speckit-<name>.
…owerShell twins (github#4286)

* fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins

* fix(scripts): use ASCII acronym boundaries in the Python and PowerShell twins

* test(scripts): cover the ASCII acronym boundary in the PowerShell twins

The ASCII-lookaround fix in scripts/powershell/create-new-feature.ps1 and
  extensions/git/scripts/powershell/create-new-feature-branch.ps1 had no
  PowerShell regression coverage: the accented-acronym parity cases invoked
  only bash and Python, and the existing PowerShell acronym tests used
  ASCII-separated words, so a regression there would have passed CI.

  Adds a three-way bash/Python/pwsh assertion for 'Fix eDBe sync' in the core
  parity suite and a pwsh arm to the extension parity test, both asserting
  001-fix-db-sync.

* test(scripts): cover clean_branch_name independently of generate_branch_name
* feat(workflows): add plugin slots

Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous)

* fix(workflows): add runtime fan-out guard to PluginStep

Mirror GateStep's inside_fan_out check so plugin slots inside fan-out
templates fail at execution time, not only at static validation. This
closes the gap when WorkflowEngine.execute() is called without prior
validation.

Assisted-by: opencode (model: qwen3.7-max, supervised)

* refactor(workflows): rename plugin slots to workflow slots

The feature reserves a no-op position replaced through a workflow
overlay; it does not register or resolve plugins. Rename per maintainer
feedback so 'plugin' stays available for a future genuine plugin
mechanism and avoid confusion with Spec Kit extensions:

- type: plugin -> type: slot
- PluginStep -> SlotStep
- 'plugin step/slot' -> 'workflow slot' in prose and error messages
- steps/plugin/ -> steps/slot/ (git mv)
- test_plugin_step.py -> test_slot_step.py (git mv)

Assisted-by: opencode (model: glm-5.3-flash, autonomous)

---------

Co-authored-by: Markus <markus@example.com>
Muse Code is Meta's terminal coding agent (binary: muse). It discovers
project skills at .agents/skills/<skill-id>/SKILL.md and invokes them
via the /speckit-<command> slash shortcut, so wire it up as a
SkillsIntegration sharing the .agents/skills layout with Codex/Zed
(multi_install_safe=False, same policy as docker-agent).

Includes registry wiring, invocation-style mapping, init next-steps,
discovery catalog, integrations doc, agent-context default (AGENTS.md),
issue templates, and a dedicated test module.
…4401)

* fix: drop unused scope input from the bundled speckit workflow

The Full SDD Cycle workflow prompted for full / backend-only /
frontend-only, but none of the steps read inputs.scope. Every command
only received inputs.spec, so the three choices behaved the same.

Remove the dead input from the shipped workflow and align the docs that
showed `specify workflow run speckit ... -i scope=...`. Keep scope as an
example in the generic input-typing docs for authors who do wire it up.

* Bump bundled speckit workflow to 1.0.1 after dropping scope.

Catalog installs only see the dead input removed when the published
version advances past what they already have.

---------

Co-authored-by: Gyanu <gyanum.ug20.cse@gmail.com>
Add evaluator extension submitted by @tbitcs to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes github#4414

Assisted-by: GitHub Copilot (model: gpt-5.2-codex, 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>
…ithub#4149)

Both loop steps type-check `steps` ("must be a list") but never require it
to be present, so an absent body silently becomes `[]`. `if` already
requires `then`, and `fan-out` already requires both `items` and `step`.

The mistype is unusually easy here because the fan-out step's own payload
key is the singular `step:` while the loops use `steps:`. Writing `step:` on
a `while` passed `specify workflow validate` with zero errors:

  A. while, body key typo'd as singular step:
     validate: []
     execute : StepStatus.COMPLETED | next_steps = []
  B. do-while, no steps at all:
     validate: []
     execute : StepStatus.COMPLETED | next_steps = []

At run time the step reports COMPLETED while returning no `next_steps`, so
the engine's `if result.next_steps:` block never fires and the loop the
workflow is built around never runs even once. `DoWhileStep`'s own docstring
promises "The first invocation always returns the nested steps for
execution".

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thub#4397)

* fix(scripts): name setup-plan's feature directory key FEATURE_DIR

setup-plan emitted a key called SPECS_DIR holding $FEATURE_DIR -- the
per-feature subdirectory, not the specs root. The name is already taken
elsewhere with the other meaning: create-new-feature.sh sets
SPECS_DIR="$REPO_ROOT/specs" and derives FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME".

setup-plan was also the only script in the suite using it. setup-tasks and
both check-prerequisites payloads already emit FEATURE_DIR for exactly this
value, so this brings setup-plan in line rather than inventing a convention.

Renamed in all three ports so the payloads stay identical, and in
templates/commands/plan.md, which is the only consumer -- it parses the key
by name, so it has to move in the same commit.

Verified the bash, PowerShell, and Python variants all emit
['BRANCH','FEATURE_DIR','FEATURE_SPEC','IMPL_PLAN'].

Fixes github#4017

* test(scripts): pin setup-plan's FEATURE_DIR output contract

Addresses review feedback. The existing setup-plan tests compare the ports
against each other, so all three could regress to SPECS_DIR together and
still pass. This asserts the contract absolutely, in JSON and text mode and
across bash/Python/PowerShell: the key is FEATURE_DIR, it carries the
feature directory rather than the specs root, and SPECS_DIR is absent.

The value is matched by suffix rather than full path because the ports
legitimately differ in path flavour -- under MSYS bash reports /tmp/... where
the Python and PowerShell ports report C:\... . The suffix still separates
specs/001-my-feature from a bare specs, which is the regression being
guarded; verified it rejects both /tmp/proj/specs and C:\proj\specs.
…ithub#4424)

docs/reference/workflows.md introduces its YAML block as the workflow that
ships with Spec Kit, so a reader is entitled to treat it as the real
definition. It had drifted on four points:

  version           1.0.0                      -> 1.0.1
  speckit_version   >=0.7.2                    -> >=0.8.5
  integrations.any  copilot, claude, gemini    -> also alquimia, opencode
  integration       default "copilot"          -> default "auto"

The last is the most user-visible: the guide stated the default integration
was copilot, when it is auto, resolved from the project's initialized
integration. Someone reading the guide to learn what they get by default was
being told the wrong thing.

Adds a guard so this cannot drift again. It compares parsed YAML rather than
text, so the guide stays free to format lists however reads best and only the
content has to agree. Verified it fails against the pre-sync copy, reporting
all four differences, and passes after.

Follow-up to github#4384 / github#4398, at the maintainer's suggestion.
…4320)

* fix(presets): reject falsy non-mapping catalog config shapes

`PresetCatalog._load_catalog_config` had two "shape check runs after
an emptiness check" bugs, both masking a corrupted preset-catalogs.yml
as an empty/no-op config instead of raising:

- Top level: `yaml.safe_load(...) or {}` coerced a FALSY non-mapping
  document (`[]`, `false`, `0`, `''`) to `{}` before the
  `isinstance(data, dict)` guard ran, so it was silently treated as
  "no config" — while a TRUTHY non-mapping (a bare string) already
  raised "expected a mapping at root".
- One level down: `catalogs_data = data.get("catalogs", [])` followed
  by `if not catalogs_data: return None` ran the emptiness check
  *before* the `isinstance(catalogs_data, list)` check, so a FALSY
  non-list `catalogs:` value (`{}`, `''`, `0`, `false`) was silently
  swallowed as "no catalogs" — while a TRUTHY non-list
  (`catalogs: "not-a-list"`) already raised "must be a list".

`WorkflowCatalog._load_catalog_config` and
`StepCatalog._load_catalog_config` (workflows/catalog.py) already
guard against both cases correctly, with the same explanatory
comments reused here. This preset sibling was missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FW9fAYsCBCAgdKWovtSyqt

* Potential fix for pull request finding

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

* Fix indentation in test for catalog config loading

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mnriem mnriem closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.