From af8f5a49ddb451c25af4dcd4fe5e186730132076 Mon Sep 17 00:00:00 2001
From: Yash-Chindam <108891350+Yash-Chindam@users.noreply.github.com>
Date: Thu, 3 Sep 2026 19:57:39 +0530
Subject: [PATCH 01/15] fix(scripts): name setup-plan's feature directory key
FEATURE_DIR (#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 #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.
---
scripts/bash/setup-plan.sh | 8 ++--
scripts/powershell/setup-plan.ps1 | 4 +-
scripts/python/setup_plan.py | 4 +-
templates/commands/plan.md | 2 +-
tests/test_setup_plan_python_parity.py | 54 ++++++++++++++++++++++++++
5 files changed, 63 insertions(+), 9 deletions(-)
diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh
index f3edb3d9f8..aa394183cf 100644
--- a/scripts/bash/setup-plan.sh
+++ b/scripts/bash/setup-plan.sh
@@ -70,16 +70,16 @@ if $JSON_MODE; then
jq -cn \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
- --arg specs_dir "$FEATURE_DIR" \
+ --arg feature_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \
- '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
+ '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,FEATURE_DIR:$feature_dir,BRANCH:$branch}'
else
- printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
+ printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","FEATURE_DIR":"%s","BRANCH":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
- echo "SPECS_DIR: $FEATURE_DIR"
+ echo "FEATURE_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
fi
diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1
index 300582d5eb..036a54e4d1 100644
--- a/scripts/powershell/setup-plan.ps1
+++ b/scripts/powershell/setup-plan.ps1
@@ -76,13 +76,13 @@ if ($Json) {
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
- SPECS_DIR = $paths.FEATURE_DIR
+ FEATURE_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
}
$result | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
- Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
+ Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
}
diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py
index 3b8acc4fd4..721eed8f39 100644
--- a/scripts/python/setup_plan.py
+++ b/scripts/python/setup_plan.py
@@ -82,7 +82,7 @@ def main(argv: list[str] | None = None) -> int:
{
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
- "SPECS_DIR": str(paths.feature_dir),
+ "FEATURE_DIR": str(paths.feature_dir),
"BRANCH": paths.current_branch,
}
)
@@ -90,7 +90,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
- print(f"SPECS_DIR: {paths.feature_dir}")
+ print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"BRANCH: {paths.current_branch}")
return 0
diff --git a/templates/commands/plan.md b/templates/commands/plan.md
index 664f428114..836e25070c 100644
--- a/templates/commands/plan.md
+++ b/templates/commands/plan.md
@@ -59,7 +59,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
-1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. 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 JSON for FEATURE_SPEC, IMPL_PLAN, FEATURE_DIR, BRANCH. 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 context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied).
diff --git a/tests/test_setup_plan_python_parity.py b/tests/test_setup_plan_python_parity.py
index d66c7083b3..09356a142f 100644
--- a/tests/test_setup_plan_python_parity.py
+++ b/tests/test_setup_plan_python_parity.py
@@ -374,3 +374,57 @@ def test_python_json_output_matches_powershell(repo: Path) -> None:
assert py.returncode == ps.returncode == 0
assert json_stdout(py) == json_stdout(ps)
+
+
+@requires_bash
+@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"])
+def test_all_variants_emit_feature_dir_not_specs_dir(
+ repo: Path, args: tuple[str, ...]
+) -> None:
+ r"""Pin the output key name, not just cross-port agreement.
+
+ The other tests here compare the ports against each other, so all three
+ could regress to ``SPECS_DIR`` together and still pass. This asserts the
+ contract absolutely: the key is ``FEATURE_DIR``, it carries the feature
+ directory rather than the specs root, and the old name is gone.
+ ``SPECS_DIR`` means the specs root in ``create-new-feature.sh``, so
+ re-emitting it here would reintroduce one name for two paths.
+
+ The value is matched by suffix 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
+ this guards.
+ """
+ json_mode = args == ("--json",)
+ suffix = ("specs", "001-my-feature")
+
+ commands = [bash_cmd(repo, SCRIPT, *args), py_cmd(repo, SCRIPT, *args)]
+ if HAS_POWERSHELL:
+ commands.append(ps_cmd(repo, SCRIPT, *(("-Json",) if json_mode else ())))
+
+ for cmd in commands:
+ result = run(cmd, repo)
+ assert result.returncode == 0, result.stderr
+ assert "SPECS_DIR" not in result.stdout
+
+ if json_mode:
+ payload = json_stdout(result)
+ assert isinstance(payload, dict)
+ assert sorted(payload) == [
+ "BRANCH",
+ "FEATURE_DIR",
+ "FEATURE_SPEC",
+ "IMPL_PLAN",
+ ]
+ value = payload["FEATURE_DIR"]
+ else:
+ lines = dict(
+ line.split(": ", 1)
+ for line in result.stdout.splitlines()
+ if ": " in line
+ )
+ assert "FEATURE_DIR" in lines
+ value = lines["FEATURE_DIR"]
+
+ assert tuple(value.replace("\\", "/").rstrip("/").split("/")[-2:]) == suffix
From d11eb9ce3e1bc4e32500bcaafe2c148e27e7f1b7 Mon Sep 17 00:00:00 2001
From: Yash-Chindam <108891350+Yash-Chindam@users.noreply.github.com>
Date: Thu, 3 Sep 2026 20:23:59 +0530
Subject: [PATCH 02/15] docs(workflows): sync the reference copy with the
shipped workflow (#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 #4384 / #4398, at the maintainer's suggestion.
---
docs/reference/workflows.md | 15 ++++++---
.../test_bundled_speckit_workflow.py | 32 +++++++++++++++++--
2 files changed, 39 insertions(+), 8 deletions(-)
diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md
index 41a890ba60..e2deaf181c 100644
--- a/docs/reference/workflows.md
+++ b/docs/reference/workflows.md
@@ -431,14 +431,19 @@ schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
- version: "1.0.0"
+ version: "1.0.1"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
- speckit_version: ">=0.7.2"
+ speckit_version: ">=0.8.5"
integrations:
- any: ["copilot", "claude", "gemini"]
+ any:
+ - "alquimia"
+ - "claude"
+ - "copilot"
+ - "gemini"
+ - "opencode"
inputs:
spec:
@@ -447,8 +452,8 @@ inputs:
prompt: "Describe what you want to build"
integration:
type: string
- default: "copilot"
- prompt: "Integration to use (e.g. claude, copilot, gemini)"
+ default: "auto"
+ prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
steps:
- id: specify
diff --git a/tests/workflows/test_bundled_speckit_workflow.py b/tests/workflows/test_bundled_speckit_workflow.py
index 77351ff017..50b53fa5dd 100644
--- a/tests/workflows/test_bundled_speckit_workflow.py
+++ b/tests/workflows/test_bundled_speckit_workflow.py
@@ -8,9 +8,19 @@
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
-BUNDLED = (
- Path(__file__).resolve().parents[2] / "workflows" / "speckit" / "workflow.yml"
-)
+REPO_ROOT = Path(__file__).resolve().parents[2]
+BUNDLED = REPO_ROOT / "workflows" / "speckit" / "workflow.yml"
+REFERENCE_DOC = REPO_ROOT / "docs" / "reference" / "workflows.md"
+DOC_INTRO = "Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit:"
+
+
+def _documented_workflow() -> object:
+ """Return the workflow YAML the reference guide claims is the shipped one."""
+ text = REFERENCE_DOC.read_text(encoding="utf-8")
+ intro = text.index(DOC_INTRO)
+ start = text.index("```yaml", intro) + len("```yaml")
+ end = text.index("```", start)
+ return yaml.safe_load(text[start:end])
def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None:
@@ -30,3 +40,19 @@ def test_bundled_speckit_workflow_has_no_unused_scope_input() -> None:
if args is None:
continue
assert "inputs.scope" not in str(args)
+
+
+def test_reference_doc_matches_the_shipped_workflow() -> None:
+ """The reference guide reproduces this workflow, so it must not drift from it.
+
+ ``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 -- a stale ``version`` and
+ ``speckit_version``, a short ``integrations.any`` list, and an
+ ``integration`` default of ``copilot`` where the shipped default is
+ ``auto`` -- which is exactly the sort of thing nothing else would catch.
+
+ The comparison is on parsed YAML, not text, so the guide stays free to
+ format lists however reads best; only the content has to agree.
+ """
+ assert _documented_workflow() == yaml.safe_load(BUNDLED.read_text(encoding="utf-8"))
From cca91e4ce85e176b2f02338bb67d6d6ee2171cb1 Mon Sep 17 00:00:00 2001
From: Noor ul ain
Date: Thu, 3 Sep 2026 21:21:52 +0500
Subject: [PATCH 03/15] fix(presets): reject falsy non-mapping catalog config
shapes (#4320)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
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
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
src/specify_cli/presets/__init__.py | 17 +++++++++++++++--
tests/test_presets.py | 12 ++++++++++++
2 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py
index 6b80b4fe1f..abc63299c2 100644
--- a/src/specify_cli/presets/__init__.py
+++ b/src/specify_cli/presets/__init__.py
@@ -4583,19 +4583,32 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog
raise PresetValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
+ # Do NOT coerce with ``or {}`` here: that also turns a FALSY
+ # non-mapping top level (``[]``, ``false``, ``0``, ``''``) into ``{}``
+ # and silently swallows it, while a TRUTHY non-mapping (``5``, a bare
+ # list) correctly raises below. Only an empty document/explicit
+ # ``null`` means "no document".
if data is None:
return None
if not isinstance(data, dict):
raise PresetValidationError(
f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}"
)
- catalogs_data = data.get("catalogs", [])
- if not catalogs_data:
+ # Same asymmetry one nesting level down: the shape check has to run
+ # BEFORE the emptiness check, or a FALSY non-list ``catalogs`` value
+ # (``{}``, ``''``, ``0``, ``false``) is silently swallowed as "no
+ # catalogs" while a TRUTHY non-list (``catalogs: "not-a-list"``)
+ # correctly raises. An absent key or an explicit ``catalogs: null``
+ # both keep their existing "nothing configured here" behavior.
+ catalogs_data = data.get("catalogs")
+ if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise PresetValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
+ if not catalogs_data:
+ return None
entries: List[PresetCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
diff --git a/tests/test_presets.py b/tests/test_presets.py
index 12f81fb9ec..57a70b4192 100644
--- a/tests/test_presets.py
+++ b/tests/test_presets.py
@@ -4057,6 +4057,18 @@ def test_load_catalog_config_not_a_list(self, project_dir):
with pytest.raises(PresetValidationError, match="must be a list"):
catalog._load_catalog_config(config_path)
+ @pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"])
+ def test_load_catalog_config_rejects_falsy_non_list_catalogs(self, project_dir, body):
+ """A FALSY non-list ``catalogs:`` value must raise, like a truthy one
+ (``catalogs: "not-a-list"``) already does. The shape check sat behind
+ the emptiness check, so these were silently swallowed as "no catalogs"."""
+ config_path = project_dir / ".specify" / "preset-catalogs.yml"
+ config_path.write_text(body, encoding="utf-8")
+
+ catalog = PresetCatalog(project_dir)
+ with pytest.raises(PresetValidationError, match="must be a list"):
+ catalog._load_catalog_config(config_path)
+
def test_load_catalog_config_invalid_entry(self, project_dir):
"""Test that non-dict entry raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
From df6b3187022ce986759bd854467e8a4bb56bb0f4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 3 Sep 2026 12:13:29 -0500
Subject: [PATCH 04/15] Update Linear Integration extension to v0.8.0 (#4428)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
docs/community/extensions.md | 2 +-
extensions/catalog.community.json | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/community/extensions.md b/docs/community/extensions.md
index b8ef03cd7a..69119631e0 100644
--- a/docs/community/extensions.md
+++ b/docs/community/extensions.md
@@ -86,7 +86,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Mirror | Spec Kit ↔ Jira bridge for team-managed and company-managed projects: configurable workflows & hierarchies (Scrum/SAFe), multi-project, idempotent and fail-closed. macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-jira-mirror](https://github.com/Fyloss/spec-kit-jira-mirror) |
| Keel Discovery | Evidence-backed discovery upstream of /speckit.specify, plus round-trip drift auditing after implementation | `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 Integration | Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work. | `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) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json
index a78e30aa45..282127c7de 100644
--- a/extensions/catalog.community.json
+++ b/extensions/catalog.community.json
@@ -2680,10 +2680,10 @@
"linear": {
"name": "Linear Integration",
"id": "linear",
- "description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
+ "description": "Automatically mirror your spec-kit specs into Linear — one issue per spec, a sub-issue per task phase, kept in sync as you work.",
"author": "Ash Brener",
- "version": "0.7.0",
- "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.7.0.zip",
+ "version": "0.8.0",
+ "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.8.0.zip",
"repository": "https://github.com/ashbrener/spec-kit-linear-sync",
"homepage": "https://github.com/ashbrener/spec-kit-linear-sync",
"documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md",
@@ -2710,7 +2710,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
- "updated_at": "2026-06-22T00:00:00Z"
+ "updated_at": "2026-09-03T00:00:00Z"
},
"linear-weave": {
"name": "Linear Weave",
From 4a7341a93d944d6efe153b71da4a1adb9c2b578c Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Fri, 4 Sep 2026 06:51:20 -0500
Subject: [PATCH 05/15] Add the August 2026 newsletter (#4442)
---
newsletters/2026-August.md | 130 +++++++++++++++++++++++++++++++++++++
1 file changed, 130 insertions(+)
create mode 100644 newsletters/2026-August.md
diff --git a/newsletters/2026-August.md b/newsletters/2026-August.md
new file mode 100644
index 0000000000..ba6ae17484
--- /dev/null
+++ b/newsletters/2026-August.md
@@ -0,0 +1,130 @@
+# Spec Kit - August 2026 Newsletter
+
+This edition covers Spec Kit activity in August 2026 — the month the project reached **1.0.0**. Ten releases shipped (v0.15.2 through v1.0.2), running out the 0.16 patch line before crossing the milestone: on **August 21, one year after its first commit, Spec Kit released v1.0.0**, followed the same day by v1.0.1 and, on August 31, by v1.0.2. The headline is less a feature than a framing — 1.0.0 marks the point where the project's **five primitives** (integrations, extensions, presets, workflows, and workflow steps), the catalog/governance layer beneath them, and the closed `specify → … → converge` core loop cohere into one system, rather than any single new capability. Around it, three currents ran through the month: the **Copilot skills default finally flipped** (`specify init --integration copilot` now installs skills), a **`feature-assess` agentic workflow** taught the project to triage incoming feature requests by running *itself*, and the **security-and-robustness campaign** from July continued as routine — bounded reads, TOCTOU elimination, URL-port validation, event-hook path confinement, and a broad non-UTF-8 resilience sweep. Externally, coverage pivoted to the milestone: a marquee "how Spec Kit became five primitives" 1.0.0 retrospective, a wave of multi-framework field guides, and — notably — the **companion tooling** that formed around Spec Kit in July began **entering the official catalog**. A summary is in the table below, followed by details.
+
+| **Spec Kit Core (Aug 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
+| --- | --- | --- |
+| Ten releases shipped (v0.15.2–v1.0.2), reaching the **v1.0.0** milestone on August 21 — one year after the project's first commit — with v1.0.1 the same day and v1.0.2 on August 31. 1.0.0 frames coherence across the **five primitives** rather than a feature drop. Headlines: the **Copilot skills default flip** (skills, not markdown commands, at `init`), a **`feature-assess` agentic workflow** that installs and runs Spec Kit to triage feature requests, manifest **`provides.templates`/`provides.scripts`** and command-time constitution templates, and a continued **security-hardening** wave. The built-in integrations catalog reached **38** with the new Command Code agent. The repo grew from ~124,655 to **~132,000 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 144 to **162 entries**; presets reached **34**, community workflows held at **2**, bundles doubled to **2**. Coverage centered on the 1.0.0 milestone — a deep "one pipeline to five primitives" retrospective — plus multi-framework field guides and intensifying "is it too heavy / who verifies the spec?" critiques. **~270 contributors** now listed. | July's **companion tools began entering the catalog** — SpecJudge (model right-sizing) and the SpecAssay suite (a multi-artifact extension + preset + bundle) were cataloged, evidence the third-party layer is consolidating into the official ecosystem. The 1.0.0 "coherence, not permanence" framing — the README still files goals under "Experimental" — reframes the competitive question from "which tool" to "which platform." |
+
+***
+
+> **From One Pipeline to Five Primitives.** If July was consolidation, August was punctuation. The month's substance was cumulative — ten releases of hardening, packaging, composition, and integration work — but its meaning was the number on the box. v1.0.0 landed a year to the day after the first commit, and the maintainer was unusually direct that it is *not* a feature release and *not* a stability promise: the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed. What 1.0.0 marks is coherence. A tool that began February as a linear `specify → plan → tasks → implement` pipeline now stands on five composable primitives — integrations, extensions, presets, workflows, and workflow steps — with a priority-ordered catalog system beneath all of them and a closed core loop that runs `converge` to ask "is this actually done?" The rest of August pushed the same direction: the Copilot integration flipped to skills by default, a `feature-assess` workflow put the project to work triaging its own backlog, and the security campaign hardened every new surface the primitives opened. Meanwhile the companion tools that sprang up around Spec Kit in July started arriving *inside* the catalog. 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.15.2–v0.16.5** (August 3–19) ran the month's patch cadence before the milestone. Feature work concentrated on **installation, onboarding, and composition**: `specify init` grew an **`--extension` flag** for opting into extensions at init time (#3914), extensions **scaffold their config templates** on add/enable (#2000), and a **managed `.specify/.gitignore`** is scaffolded at init (#4000). Two integration-selection changes landed together — the default init integration became **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) and, the month's quiet headline, the **Copilot integration now defaults to skills** (#3976), completing the skills-default rollout flagged since July. The composition layer matured: extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), presets **resolve constitution templates at command time** (#3984), and `specify` **lists presets in resolution/precedence order** (#4104). Underneath, a deep robustness pass hardened non-UTF-8 and unreadable inputs across the registries, manifests, bundler, and workflow engine. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+**v1.0.0–v1.0.2** (August 21–31) crossed the milestone. **v1.0.0** (#4246) shipped on August 21 — one year after the project's first commit — with **v1.0.1** the same day and **v1.0.2** on August 31. The releases carried the fortnight's workflow, catalog, bundler, cross-platform, and integration fixes into a published 1.0.0 line, alongside a documentation set built for the moment: a **first-anniversary marker** (#4260), a **project history page** (#4262) that traces the arc from linear pipeline to five primitives, a **branding refresh** replacing the DocFX theme with the Spec Kit logo (#4264), **workflow quickstart guides** (#4258), and operational guidance for **existing-project adoption** (#4263). [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The 1.0.0 Milestone: Coherence, Not Permanence
+
+August's headline was **v1.0.0** (#4246), released on August 21 — one year to the day after Spec Kit's first commit — and immediately followed by v1.0.1. The framing matters more than the number: 1.0.0 is explicitly **not a feature release and not a stability promise**. The argument, laid out in the project's new [history page](https://github.com/github/spec-kit/blob/main/docs/history.md), is that a major version used to be insurance against the cost of a breaking change — and that agents have collapsed that cost, since you can point an agent at a diff and it updates the call sites. The version instead marks **coherence between the five primitives**, not permanence; the README still files the project's ambitions under "Experimental Goals," deliberately unrenamed.
+
+What cohered is the shape of the tool. Spec Kit began February 2026 as a linear pipeline — `/speckit.specify`, `/speckit.plan`, `/speckit.tasks`, `/speckit.implement` — with the coding agent chosen by a flag. By 1.0.0 it stands on **five composable primitives**: **integrations** (agents became registry-backed plugins that write hash-tracked files), **extensions** (commands, templates, scripts, and hooks via an `extension.yml` manifest), **presets** (prepend/append/wrap/replace composition over existing core content), **workflows** (the specify-to-implement sequence is now a replaceable YAML definition, not hard-coded control flow), and **workflow steps** (a small interface any community step type can implement). Beneath them sits a single priority-ordered **catalog** system — environment variable → project config → user config → built-in — that governs discovery and install policy uniformly for all five, and the built-in `community` catalog is discovery-only by design: the maintainers verify an entry is well-formed, not that its code is safe. Above the primitives, the core loop closed in June with `/speckit.converge`, which assesses shipped code against the spec, plan, and tasks and appends the missing work — the answer to the tool's most-cited critique, "who verifies the spec?" The 1.0.0 core sequence is now constitution → specify → clarify → plan → checklist → tasks → analyze → implement → converge, with clarify/checklist/analyze as optional gates. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+### The Copilot Skills Default Flip
+
+The month's quiet structural change was the completion of the **Copilot skills-default rollout**. `specify init --integration copilot` now installs **`speckit-*` skills by default** (#3976) rather than the legacy markdown-command layout, and the default init integration is **overridable via `SPECKIT_INTEGRATION_DEFAULT`** (#3952) so teams can pin a different default without touching flags. This is the flip that July's release notes warned was coming: agents that support skills install skills, the markdown-command layout becomes the legacy path, and both layouts continue to flow through the same manifest system. It lands the project's Copilot surface squarely on the native skills model that the wider integration layer has been converging toward all year. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### Spec Kit Triages Itself: the `feature-assess` Workflow
+
+August's most on-brand feature was the **`feature-assess` agentic workflow** (#4186), which **installs and runs Spec Kit to assess an incoming feature request end to end**. Rather than a human reading each new request cold, the workflow provisions `uv` and Python (#4193), stands up the Spec Kit CLI and the `assess` extension (#4195), and runs the July `assess` "Idea Assessment Pipeline" against the request — capture → evidence → refine → design → decision — before a maintainer touches it. The initial landing was quickly followed by the provisioning and daily-credit-budget fixes needed to make it run reliably in CI (#4193, #4195, #4222). It is the clearest instance yet of the project **dogfooding its own primitives**: the tool that helps teams decide "should we build this?" is now wired into Spec Kit's own triage, running the `assess` pipeline on a labeled feature-request issue and posting each stage back. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Composition Layer Matures
+
+Beyond the milestone, August's engineering pushed the **composition primitives** toward everyday practicality. Extension manifests now accept **`provides.templates` and `provides.scripts`** (#4012), letting an extension ship template and script overrides the same way it ships commands, with duplicate `provides` names rejected up front (#4016, #4191). Presets **resolve their constitution templates at command time** rather than eagerly (#3984), inherit `argument-hint` from the core template when wrapping so a wrap no longer silently drops it (#3996), and `specify` now **lists presets in resolution/precedence order** so overrides are visible at a glance (#4104). Namespaced preset commands are scaffolded self-contained (#4082), and the bundler now reads the authoritative `default_integration` field instead of only its legacy aliases (#3880). The through-line is the same as the 1.0.0 story: the core is increasingly a set of **named, overridable slots** that presets and extensions compose against, rather than a monolith to replace. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Security-and-Robustness Campaign Continues
+
+July's hardening wave carried into August as standing discipline. **Bounded I/O** extended to the extension-catalog/download path (#3775), the integration-catalog fetch (#3812, #3818), and bundle downloads (#3764), and the **stdin read was capped at 1 MiB** to close a DoS path (#3857). **Race elimination** removed TOCTOU windows in file-unlink (#3819), zip packaging (#3855), and `RunState.load` (#3839). **URL and host hardening** taught the auth layer to treat exact host patterns literally (#4108) and, later in the month, to validate URL **ports** as well as hostnames across preset catalogs and credential matching, while **event-hook script paths are now confined to the project tree** (#4133) and the community submission workflow's output allowlists were tightened (#4103).
+
+Running alongside was a broad **non-UTF-8 and "fail-loudly" resilience sweep**: preset and extension registries, manifests, legacy commands, events, hook `config.toml`, resolver layers, and catalog responses all now **degrade gracefully instead of throwing** on malformed, unreadable, or wrong-encoding input (#3896, #3955, #3959, #3900, #3998, #3960, #3962, #3834, #3897, #3957, #3963, #3943, #3980, #3902, #4011, #3958). Workflow validation grew stricter in step — non-string step types, falsy non-mapping overlays and integration descriptors, empty condition blocks, and unvalidated dispatch defaults are now rejected with clear errors (#4111, #3884, #4187, #4182, #4181) — and a user-visible fix stopped `specify init` from **hanging on arrow-key pickers** in non-interactive agent harnesses (#4178). The hardening is prevention, arriving as the primitives open new surface. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### Agent Integrations
+
+The agent portfolio kept growing. The built-in integrations catalog (`integrations/catalog.json`) grew from **37 to 38**, adding one new agent — a **Command Code** integration (#4019). Alongside it, integration *implementation* work landed for existing catalog entries: the **Junie** integration module was implemented with dot-to-hyphen command formatting (#4073), the **Mistral Vibe** (`vibe`) integration was brought to Claude parity (#4075), and the **Qoder CLI integration migrated to a skills-based layout** for Qoder IDE 1.24+ (#4205). Existing integrations were further refined: Claude and Alquimia argument-hint injection became **fold-aware** for long, folded descriptions (#4045, #4063), goose commands now dispatch via `goose run` (#3781, closing the 300-day #2416), and Kimi preserves non-UTF-8 user skills (#3895). The 1.0.0 documentation lists **38 integrations**, and the pattern from prior months holds — the surviving integrations keep getting more native to each agent, not merely more numerous. [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+### The Extension, Preset & Bundle Ecosystem
+
+The community extension catalog grew from 144 to **162 entries** during August — eighteen net additions. Community presets grew from 29 to **34**, community workflows held at **2**, and community **bundles doubled from 1 to 2**.
+
+The month's most telling signal was **July's companion tooling entering the official catalog**. **SpecJudge** — the CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a right-sized model, profiled in July's newsletter — was cataloged as an extension (#4079). And **SpecAssay** arrived as a **multi-artifact suite**: an extension (SpecAssay Check, #4113), a preset (#4123), and a **bundle** (#4125) — the second cataloged community bundle. The third-party layer that formed *around* Spec Kit in July is consolidating *into* the ecosystem.
+
+Notable new extensions by category:
+
+- **Verification, review & governance**: Architecture Governance, SpecAssay Check, Taco Review (human review packaging), adrkit (ADR authoring)
+- **Requirements & intake**: SpecKit Grill Me (a more thorough clarification skill), Pre-Spec Cards, Charter (updated)
+- **Knowledge, memory & inventory**: DUBSAR Memory, Spec Inventory, spec-kit-atlas, Keel Discovery
+- **External trackers & bridges**: Jira Mirror, AgentDocx, AgentPay x402 (spend controls), Azure Cosmos DB code-gen
+- **Model routing & sizing**: SpecJudge, Model Routing Governance (preset), Closed Vocabulary Check (preset)
+
+The catalog also showed heavy maintenance: **Archive** (to v1.3.0), **Reconcile** (v1.2.1), **Security Review** (v2.0.0), **Architecture Guard** (v2.3.6), **MAQA**, **Superspec**, and the **Spec Kit Figma** bridge all iterated, and a large **governance-preset** family — Security, Architecture, iSAQB, A11Y, Cross-Platform, Agent-Parity, and the Intake and Autonomous-Run suites — pushed coordinated version bumps. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
+
+### Documentation & Docs Site
+
+August's documentation was built around the milestone. The **first-anniversary marker** (#4260), a **project history page** (#4262) tracing the pipeline-to-primitives arc, and a **branding refresh** to the Spec Kit logo (#4264) framed the 1.0.0 release. Practical guidance expanded: **workflow quickstart guides** (#4258) and **Python init-script** documentation (#4331) for workflows and an **existing-project adoption** guide (#4263). The **extension catalog trust model** was clarified across docs, help, and messaging (#4177), reinforcing the discovery-only nature of the community catalog, and installing `specify-cli` from a **custom package index** was documented (#4032). [\[github.com\]](https://github.com/github/spec-kit/releases)
+
+## Community & Content
+
+### Press and Industry Coverage
+
+August's coverage centered on the 1.0.0 milestone, with the mix continuing July's shift toward comparison pieces, field guides, and pointed "is it too heavy?" critiques. No first-party Microsoft or GitHub (non-maintainer) post appeared in August; the nearest remained June's Microsoft Developer Blog piece.
+
+**Stanislav Deviatov** (Medium, August 24) published the month's marquee article, titled *"Spec Kit Reaches 1.0: From One Pipeline to Five Primitives"* — a deep, well-sourced retrospective (Solution Architect at EPAM) tracing how Spec Kit evolved from a linear pipeline into five primitives plus catalogs and the `/speckit.converge` loop. It endorses the direction while qualifying the "1.0.0 = stable" claim, the real migration cost beyond mechanical call-site edits, and the unenforced trust boundary. It is the clearest external articulation of what the milestone means. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+**Roan Brasil Monteiro** (Medium, August 20) published a 21-minute *field guide to BMAD, Spec Kit, OpenSpec, and Kiro*, framed around "too much process burns money, too little burns more," positioning Spec Kit as the thorough, heavier option among four SDD frameworks. [\[medium.com\]](https://medium.com/@roanmonteiro/bmad-spec-kit-openspec-kiro-a-field-guide-to-getting-real-work-out-of-ai-coding-agents-b17833c24b3f)
+
+**百度百家号** (AI钉子铺, August 23) ran a half-year growth-rate review of five SDD frameworks that credits Spec Kit's GitHub/Microsoft brand pull and 14+ agent support but amplifies Martin Fowler's "8+ markdown files per spec" critique, arguing its growth is partly "brand premium." **Andrew** (DEV Community, August 28), reviewing OpenSpec, engaged Spec Kit substantively as the main comparison — citing 131,957 stars, phase gates, the Python requirement, and the larger extension catalog. [\[baijiahao.baidu.com\]](https://baijiahao.baidu.com/s?id=1874316315966110702)
+
+### Developer Articles and Field Reports
+
+August's articles skewed toward honest, use-it-in-anger critique, with several first-hand field reports and a strong multilingual current in Japanese, Korean, Chinese, Spanish, and Thai.
+
+Notable articles:
+
+- **Lusivision** (DEV Community, August 28) — *"Spec-Driven Development: The New AI Coding Workflow,"* an SDD overview built around Spec Kit's four gated phases as the central vehicle, honest about where the overhead pays back. [\[dev.to\]](https://dev.to/lusivision/spec-driven-development-the-new-ai-coding-workflow-24lh)
+- **chae_eun_ini** (velog, August 26, Korean) — a candid field report where 13 features grew `specs/` to 12,931 lines (73% of the 17,590-line `src/`); concludes the real problem was that finished spec docs left "nothing to decide, only to approve," and documents **moving off Spec Kit to a self-built human-first harness**. [\[velog.io\]](https://velog.io/@chae_eun_ini/Spec-Kit%EC%97%90%EC%84%9C-%EC%9E%90%EC%B2%B4-%EC%A0%9C%EC%9E%91-%ED%95%98%EB%84%A4%EC%8A%A4%EB%A1%9C-%EA%B7%B8-%EC%82%AC%EC%9D%B4-%EA%B3%BC%EB%8F%84%EA%B8%B0%EC%9D%98-%EA%B8%B0%EB%A1%9D)
+- **ta_kawano** (note.com, August 10, Japanese) — a consolidated eight-part continuation of the "要求AI" series that rigorously measures Spec Kit's cost/tokens/time when adding a requirement (~$31, ~34 min; 301→331 tests), concluding SDD covers "spec→code" but leaves **requirements elicitation outside its scope**. [\[note.com\]](https://note.com/takawano/n/nd01c18c93580)
+- **New2026** (Medium, August 19) — *"AI Coding Frameworks Explained,"* a five-layer "stack" mental model positioning Spec Kit as one control layer among ~8 frameworks ("they are not competing; they constrain different parts"). [\[medium.com\]](https://new2026.medium.com/ai-coding-frameworks-explained-superpowers-gsd-gstack-ralph-spec-kit-and-the-agentic-3dd92e559636)
+- **Katsumata** (Zenn, August 24, Japanese) — a designer, inspired by Spec Kit, builds a spec-driven *design* system (YAML component specs → React/CSS/tests/Storybook via CI gates), candid about where spec→production quality breaks down. [\[zenn.dev\]](https://zenn.dev/katsumata/articles/014affaeb272d00aeae6)
+- **guillermodelpino.com** (Guillermo del Pino, August 15, Spanish) — a positive analytical review walking the `constitution → … → implement → converge` flow, singling out `converge`'s "is this really done?" step and arguing the discipline is valuable even for non-programmers. [\[guillermodelpino.com\]](https://guillermodelpino.com/repos/spec-kit-github-desarrollo-guiado-por-especificacion)
+
+Additional coverage appeared on DEV Community (TekMag, Jeffrey Bakker), Naver/velog/Tistory (Korean), Qiita/Zenn (Japanese), CSDN and 百家号 (Chinese), and Vibe Coding Thailand (Thai) — including several head-to-head OpenSpec-vs-Spec-Kit comparisons and recurring documentation-proliferation critiques. [\[dev.to\]](https://dev.to/tekmag/githubs-spec-kit-the-open-source-toolkit-bringing-spec-driven-development-to-ai-coding-agents-ede)
+
+### Community Growth by the Numbers
+
+| Metric | Start of August | End of August | Change |
+| --- | --- | --- | --- |
+| GitHub stars | 124,655 | ~132,000 | +~7,300 (+6%) |
+| Forks | 11,125 | ~11,900 | +~775 |
+| Contributors | ~258 | ~270 | +~12 |
+| Releases (total) | 205 | 215 | +10 (v0.15.2–v1.0.2) |
+| Community extensions | 144 | 162 | +18 |
+| Community presets | 29 | 34 | +5 |
+| Community workflows | 2 | 2 | steady |
+| Community bundles | 1 | 2 | +1 |
+| Agent integrations (catalog) | 37 | 38 | +1 (Command Code) |
+| Discussions (total) | ~474 | ~482 | +~8 |
+
+## SDD Ecosystem & Industry Trends
+
+### From Companion Tools to Catalog Entries
+
+July's clearest ecosystem signal was a *pattern* — independent developers building tools on top of Spec Kit's artifacts. August's signal was that pattern **consolidating**: those companion tools began arriving in the official catalog. **SpecJudge** (model right-sizing) was cataloged as an extension, and **SpecAssay** landed as a full multi-artifact suite — extension, preset, and the ecosystem's second bundle. The loudest theme across the now-162 cataloged extensions remains verification and quality (gate, review, validate, drift, evidence, sync), and 1.0.0's catalog design — discovery-only community listings, priority-ordered promotion into an organization's own vetted catalog — turns that demand into a governable pipeline rather than an unmanaged sprawl. The community proposes, the catalog measures what grows, and the core promotes the winners; `/speckit.converge` (drift → core loop) was the template, and August's companion-tool intake is the pattern repeating. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
+
+### Competitive Landscape
+
+The "which SDD tool?" genre stayed dominant, but 1.0.0 shifted its framing. Where earlier months ran tool-vs-tool feature bake-offs, August's most substantive pieces — Deviatov's retrospective, Monteiro's field guide, the 百家号 growth review — increasingly argue the frameworks are **converging on the same primitives**, which moves the question from "which tool wins" to "**which platform and governance model.**" On that axis, Spec Kit's five-primitive surface, its uniform catalog/trust model, its agent-neutrality, and the companion layer now consolidating into the catalog are the differentiators. The recurring counter-critique held steady and, if anything, sharpened around the milestone: documentation proliferation, cognitive load, and "who verifies the spec, and who reads all this?" remain the consistent trade-off — with the most pointed field report of the month (chae_eun_ini) documenting a team leaving Spec Kit for a lighter self-built harness. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+
+## Roadmap
+
+Areas under discussion or in progress for future development:
+
+- **After 1.0.0, the trust boundary is the headline work** — the community catalog is discovery-only, and the maintainer calls making the guidance-vs-enforcement boundary *legible* the most valuable unfinished work. Expect continued investment in organization-owned catalogs, promotion pipelines, and provenance so "what is installable" can quietly become "what we approved." [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
+- **Deeper composition primitives** — August's `provides.templates`/`provides.scripts`, command-time template resolution, and precedence-ordered presets point toward finer-grained, named composition points; early post-milestone signals include reusable **workflow slots** and letting a **preset declare a required extension**, tightening the algebra by which one layer reshapes another. [\[github.com\]](https://github.com/github/spec-kit/releases)
+- **Agentic self-service** — `feature-assess` joins an existing set of Copilot-engine agentic workflows the project runs on itself: the `bug-assess → bug-test → bug-fix` triage pipeline and the `add-community-extension`/`-preset`/`-bundle` catalog-submission automations. Each is label-triggered and installs/runs Spec Kit or its tooling to do real maintenance work; expect these pipelines to deepen as the project dogfoods its own primitives. [\[github.com\]](https://github.com/github/spec-kit/releases)
+- **The Copilot skills surface** — with the skills default now flipped and `SPECKIT_INTEGRATION_DEFAULT` overridable, the markdown-command layout becomes the legacy path. The first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) plugin continues to explore a **visual, Copilot-driven surface** — a Spec Kit Wizard canvas with live boot progress — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
+- **Security and robustness as routine** — the bounded-read / TOCTOU / URL-port / non-UTF-8 / fail-loudly campaign is now standing discipline rather than a wave. Sustaining the no-unbounded-read and graceful-degradation invariants as the surface (bundles, workflows, catalogs, events, integrations) keeps growing 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 August's balanced reviews (chae_eun_ini, ta_kawano, the 百家号 review). The `assess` upstream gate, lean presets, `/speckit.converge`, and the consolidating companion-tooling layer all provide answers; surfacing them so new users feel 1.0.0 as *coherence* rather than *weight* is the persistent opportunity. [\[medium.com\]](https://stn1slv.medium.com/spec-kit-reaches-1-0-from-one-pipeline-to-five-primitives-f7bb6359e501)
From 7b387476153fcee0e7049cd392c85cd9de2452f6 Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Sun, 30 Aug 2026 15:55:20 +0300
Subject: [PATCH 06/15] feat(cli): add post-init config command
Expose persisted settings and extension lifecycle under `specify config`, and publish fork-owned documentation through the guarded Pages workflow.
---
.github/PULL_REQUEST_TEMPLATE.md | 2 +-
.github/workflows/docs.yml | 10 +-
.gitignore | 1 +
CITATION.cff | 2 +-
INSTALL.md | 2 +-
README.md | 15 +-
README.zh-CN.md | 12 +-
docs/local-development.md | 98 +++++++--
docs/reference/configuration.md | 65 ++++++
docs/reference/overview.md | 9 +
.../2026-08-30-post-init-config-command.md | 179 ++++++++++++++++
docs/toc.yml | 2 +
docs/upgrade.md | 2 +-
src/specify_cli/__init__.py | 5 +
src/specify_cli/commands/config.py | 197 ++++++++++++++++++
tests/test_config_cli.py | 176 ++++++++++++++++
16 files changed, 747 insertions(+), 30 deletions(-)
create mode 100644 docs/reference/configuration.md
create mode 100644 docs/superpowers/plans/2026-08-30-post-init-config-command.md
create mode 100644 src/specify_cli/commands/config.py
create mode 100644 tests/test_config_cli.py
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index c9fce2cd8d..64df171ee7 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -7,7 +7,7 @@
- [ ] Tested locally with `uv run specify --help`
-- [ ] Ran existing tests with `uv sync && uv run pytest`
+- [ ] Ran existing tests with `uv sync --extra test && uv run pytest`
- [ ] Tested with a sample project (if applicable)
## AI Disclosure
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index c5c0092be7..d4d1eb21f4 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -7,6 +7,12 @@ on:
branches: ["main"]
paths:
- 'docs/**'
+ - 'media/**'
+ - 'README.md'
+ - 'CONTRIBUTING.md'
+ - 'CODE_OF_CONDUCT.md'
+ - 'SECURITY.md'
+ - 'SUPPORT.md'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -26,7 +32,7 @@ concurrency:
jobs:
# Build job
build:
- if: github.repository == 'github/spec-kit'
+ if: github.repository == 'tikalk/agentic-sdlc-spec-kit'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -57,7 +63,7 @@ jobs:
# Deploy job
deploy:
- if: github.repository == 'github/spec-kit'
+ if: github.repository == 'tikalk/agentic-sdlc-spec-kit'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.gitignore b/.gitignore
index fa4dab24b0..2ad02ea020 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,7 @@ env/
*.log
.env
.env.local
+.envrc
*.lock
tmp/
bin/
diff --git a/CITATION.cff b/CITATION.cff
index aff7b4f8a0..d4945b8f79 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -18,7 +18,7 @@ authors:
family-names: Riem
alias: mnriem
repository-code: "https://github.com/github/spec-kit"
-url: "https://github.github.io/spec-kit/"
+url: "https://tikalk.github.io/agentic-sdlc-spec-kit/"
license: MIT
version: "0.10.2"
date-released: "2026-06-11"
diff --git a/INSTALL.md b/INSTALL.md
index 0f3826ae8e..18ef765b75 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -480,7 +480,7 @@ uv tool install agentic-sdlc-specify-cli --force --from git+https://github.com/t
echo $GITHUB_TOKEN # or $GITLAB_TOKEN
```
4. Check token has access to the repository
-5. See [authentication documentation](https://github.github.io/spec-kit/reference/authentication.html) for more details
+5. See [authentication documentation](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/authentication.html) for more details
### Issue: "No AI agent detected"
diff --git a/README.md b/README.md
index 7bd2d30319..844e39421f 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ This fork represents the evolution from a development process to a complete orga
-
+
@@ -396,6 +396,9 @@ specify init --team-ai-directives https://github.com/your-org/team-ai-
specify init --team-ai-directives ~/workspace/team-ai-directives
```
+To change or remove the source after initialization, see
+[`specify config`](./docs/reference/configuration.md).
+
Accepted sources are a local directory, a GitHub/GitLab archive URL, or a direct `.zip`/`.tar.gz` URL. Plain `.git` clone URLs are not supported.
**Private Repositories**: If your team-ai-directives repository is private, configure authentication in `~/.specify/auth.json`:
@@ -469,7 +472,7 @@ to install.
## 🤖 Supported AI Coding Agent Integrations
-Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide.
+Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/integrations.html) guide.
Run `specify integration list` to see all available integrations in your installed version.
@@ -536,7 +539,7 @@ Mission-driven SDLC automation with supervision modes and safety guardrails:
## 🔧 Specify CLI Reference
-For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html).
+For full command details, options, and examples, see the [CLI Reference](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/overview.html).
## 🧩 Making Spec Kit Your Own: Extensions & Presets
@@ -569,7 +572,7 @@ specify extension add
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
-See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
+See the [Extensions reference](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
### Presets — Customize Existing Workflows
@@ -585,7 +588,7 @@ specify preset add
For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering.
-See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking.
+See the [Presets reference](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking.
## 📦 Bundles: Role-Based Setups
@@ -1067,7 +1070,7 @@ The report is saved to `SPECIFY_FEATURE_DIRECTORY/verify.md` and includes an ove
If any pillar fails, convergence tasks are appended for another implement pass. Run the fixes and re-converge.
-- **[Quick Start Guide](https://github.github.io/spec-kit/quickstart.html)** - Step-by-step implementation walkthrough
+- **[Quick Start Guide](https://tikalk.github.io/agentic-sdlc-spec-kit/quickstart.html)** - Step-by-step implementation walkthrough
---
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b90809eee7..3750c3ea5e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -12,7 +12,7 @@
-
+
@@ -155,7 +155,7 @@ specify self upgrade --tag vX.Y.Z[suffix]
## 🤖 支持的 AI 编码助手集成
-Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,也包括基于 IDE 的助手。完整列表以及相关说明和使用细节,请参阅[支持的 AI 编码助手集成](https://github.github.io/spec-kit/reference/integrations.html)指南。
+Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,也包括基于 IDE 的助手。完整列表以及相关说明和使用细节,请参阅[支持的 AI 编码助手集成](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/integrations.html)指南。
运行 `specify integration list` 可查看当前安装版本中所有可用的集成。
@@ -189,7 +189,7 @@ Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,
## 🔧 Specify CLI 参考
-完整的命令详情、选项与示例,请参阅 [CLI 参考文档](https://github.github.io/spec-kit/reference/overview.html)。
+完整的命令详情、选项与示例,请参阅 [CLI 参考文档](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/overview.html)。
## 🧩 打造你自己的 Spec Kit:扩展与预设
@@ -222,7 +222,7 @@ specify extension add
举例来说,扩展可以添加 Jira 集成、实现后代码审查、V 模型测试追溯性,或项目健康诊断等功能。
-完整命令指南请参阅[扩展参考文档](https://github.github.io/spec-kit/reference/extensions.html)。浏览[社区扩展](https://github.github.io/spec-kit/community/extensions.html)了解现有资源。
+完整命令指南请参阅[扩展参考文档](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/extensions.html)。浏览[社区扩展](https://github.github.io/spec-kit/community/extensions.html)了解现有资源。
### 预设 —— 定制现有工作流
@@ -238,7 +238,7 @@ specify preset add
举例来说,预设可以重构规范模板以要求监管追溯性,将工作流适配为你所用的方法论(如敏捷、看板、瀑布、用户任务驱动或领域驱动设计),在方案中添加强制安全审查关卡,强制要求测试优先的任务排序,或将整个工作流本地化为其他语言。[海盗语演示](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)充分展示了定制的深度。多个预设可按优先级叠加使用。
-完整命令指南以及解析顺序和优先级叠加说明,请参阅[预设参考文档](https://github.github.io/spec-kit/reference/presets.html)。
+完整命令指南以及解析顺序和优先级叠加说明,请参阅[预设参考文档](https://tikalk.github.io/agentic-sdlc-spec-kit/reference/presets.html)。
## 📦 捆绑包:面向角色的一键配置
@@ -344,7 +344,7 @@ specify bundle build --path ./my-bundle # 生成带版本的 .zip 产物
## 📖 深入了解
- **[完整的规范驱动开发方法论](./spec-driven.md)** —— 深入了解整个流程
-- **[快速上手指南](https://github.github.io/spec-kit/quickstart.html)** —— 分步实现演练
+- **[快速上手指南](https://tikalk.github.io/agentic-sdlc-spec-kit/quickstart.html)** —— 分步实现演练
---
diff --git a/docs/local-development.md b/docs/local-development.md
index 34070451fc..8bf60f963e 100644
--- a/docs/local-development.md
+++ b/docs/local-development.md
@@ -47,7 +47,81 @@ specify --help
Re-running after code edits requires no reinstall because of editable mode.
-## 4. Invoke with uvx Directly From Git (Current Branch)
+## 4. Verify Post-Initialization Configuration
+
+Use a disposable project so configuration changes do not alter a real project.
+From the repository root, save the repository path and create a temporary test
+project:
+
+```bash
+SPECIFY_SRC="$(pwd)"
+SPECIFY="$SPECIFY_SRC/.venv/bin/specify"
+TEST_ROOT="$(mktemp -d)"
+"$SPECIFY" init "$TEST_ROOT/project" \
+ --integration copilot --ignore-agent-tools --script sh
+cd "$TEST_ROOT/project"
+```
+
+`"$SPECIFY" ...` executes the editable `specify` console entry point from the
+current working tree. The previous section creates that environment. If you
+prefer uv to manage the environment, use `uv run --project "$SPECIFY_SRC"
+specify ...` instead.
+
+Run the read and mutation commands and verify each result:
+
+```bash
+"$SPECIFY" config list
+"$SPECIFY" config list --json
+"$SPECIFY" config get script
+
+"$SPECIFY" config set script py
+"$SPECIFY" config get script
+
+"$SPECIFY" config set feature-numbering timestamp
+"$SPECIFY" config get feature-numbering
+```
+
+The final two `get` commands must print `py` and `timestamp`. The corresponding
+values in `"$TEST_ROOT/project/.specify/init-options.json"` must match.
+
+Verify that integration ownership is enforced:
+
+```bash
+"$SPECIFY" config set integration claude
+```
+
+This command must fail and direct you to `specify integration use` without
+changing the saved integration.
+
+Verify extension delegation using the bundled `git` extension:
+
+```bash
+"$SPECIFY" config extension list
+"$SPECIFY" config extension add git
+"$SPECIFY" config extension list
+"$SPECIFY" config extension disable git
+"$SPECIFY" config extension enable git
+"$SPECIFY" config extension remove git
+```
+
+The extension must appear as installed, disabled, enabled, and then absent in
+the corresponding list output.
+
+If you have a valid team-directives source, verify its lifecycle too. Replace
+the placeholder with a local directory or supported archive URL:
+
+```bash
+TEAM_DIRECTIVES_SOURCE="/absolute/path/to/team-ai-directives"
+"$SPECIFY" config set team-ai-directives "$TEAM_DIRECTIVES_SOURCE"
+"$SPECIFY" config get team-ai-directives
+"$SPECIFY" config unset team-ai-directives
+```
+
+`get` must report the resolved source, and `unset` must remove the saved source
+and governance extension while warning that copied team skills remain for
+manual review.
+
+## 5. Invoke with uvx Directly From Git (Current Branch)
`uvx` can run from a local path (or a Git ref) to simulate user flows:
@@ -63,7 +137,7 @@ git push origin your-feature-branch
uvx --from git+https://github.com/github/spec-kit.git@your-feature-branch specify init demo-branch-test --script ps
```
-### 4a. Absolute Path uvx (Run From Anywhere)
+### 5a. Absolute Path uvx (Run From Anywhere)
If you're in another directory, use an absolute path instead of `.`:
@@ -87,7 +161,7 @@ specify-dev() { uvx --from /mnt/c/GitHub/spec-kit specify "$@"; }
specify-dev --help
```
-## 5. Testing Script Permission Logic
+## 6. Testing Script Permission Logic
After running an `init`, check that shell scripts are executable on POSIX systems:
@@ -98,7 +172,7 @@ ls -l scripts | grep .sh
On Windows you will instead use the `.ps1` scripts (no chmod needed).
-## 6. Scaffold a Built-In Integration
+## 7. Scaffold a Built-In Integration
Use the integration scaffold command to create the initial Python package and
test skeleton for a new built-in integration:
@@ -118,7 +192,7 @@ The scaffold does not register the integration automatically. Review the
generated metadata, then add the import and `_register()` call in
`src/specify_cli/integrations/__init__.py`.
-## 7. Run Lint / Basic Checks
+## 8. Run Lint / Basic Checks
CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing:
@@ -132,7 +206,7 @@ You can also quickly sanity check importability:
python -c "import specify_cli; print('Import OK')"
```
-## 8. Build a Wheel Locally (Optional)
+## 9. Build a Wheel Locally (Optional)
Validate packaging before publishing:
@@ -143,7 +217,7 @@ ls dist/
Install the built artifact into a fresh throwaway environment if needed.
-## 9. Using a Temporary Workspace
+## 10. Using a Temporary Workspace
When testing `init --here` in a dirty directory, create a temp workspace:
@@ -154,7 +228,7 @@ python -m src.specify_cli init --here --integration claude --ignore-agent-tools
Or copy only the modified CLI portion if you want a lighter sandbox.
-## 10. Debug Network / TLS Issues
+## 11. Debug Network / TLS Issues
> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect.
> It was previously used to bypass TLS validation during local testing.
@@ -163,7 +237,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
>
> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`.
-## 11. Rapid Edit Loop Summary
+## 12. Rapid Edit Loop Summary
| Action | Command |
|--------|---------|
@@ -174,7 +248,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
| Build wheel | `uv build` |
-## 12. Cleaning Up
+## 13. Cleaning Up
Remove build artifacts / virtual env quickly:
@@ -182,7 +256,7 @@ Remove build artifacts / virtual env quickly:
rm -rf .venv dist build *.egg-info
```
-## 13. Common Issues
+## 14. Common Issues
| Symptom | Fix |
|---------|-----|
@@ -192,7 +266,7 @@ rm -rf .venv dist build *.egg-info
| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
-## 14. Next Steps
+## 15. Next Steps
- Update docs and run through Quick Start using your modified CLI
- Open a PR when satisfied
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
new file mode 100644
index 0000000000..9bab38a851
--- /dev/null
+++ b/docs/reference/configuration.md
@@ -0,0 +1,65 @@
+# Project Configuration
+
+Use `specify config` to inspect and safely change supported settings that were
+recorded when you initialized a project. Run these commands from the project
+root, or set `SPECIFY_INIT_DIR` to the project root.
+
+## Inspect Configuration
+
+```bash
+specify config list
+specify config list --json
+specify config get script
+```
+
+`list` shows the persisted initialization settings and a summary of installed
+extensions. `--json` prints both as machine-readable JSON.
+
+## Change Supported Initialization Settings
+
+```bash
+specify config set script py
+specify config set feature-numbering timestamp
+```
+
+Supported values are:
+
+| Setting | Values |
+| --- | --- |
+| `script` | `sh`, `ps`, `py` |
+| `feature-numbering` | `sequential`, `timestamp` |
+
+The active coding-agent integration and skills layout are not configurable
+through this command because changing them requires regenerating agent files.
+Use `specify integration use ` instead.
+
+## Change or Remove the Team Directives Source
+
+```bash
+specify config set team-ai-directives /absolute/path/to/team-ai-directives
+# A ZIP URL is also supported:
+specify config set team-ai-directives https://github.com/example/team-ai-directives/archive/refs/heads/main.zip
+specify config unset team-ai-directives
+```
+
+Setting a source validates it, ensures the bundled governance extension is
+installed without replacing an existing one, and installs any source-declared
+default skills that are not already present.
+Unsetting it removes the governance extension and the saved source setting.
+Copied team skills are intentionally left in the active agent's skills
+directory for manual review.
+
+## Manage Extensions
+
+`specify config extension` exposes the existing extension lifecycle under the
+configuration namespace. It has the same behavior as `specify extension`.
+
+```bash
+specify config extension list
+specify config extension add tdd
+specify config extension disable tdd
+specify config extension enable tdd
+specify config extension remove tdd
+```
+
+Use `specify config extension --help` to see the full extension command set.
diff --git a/docs/reference/overview.md b/docs/reference/overview.md
index 183ce84756..c49d44b918 100644
--- a/docs/reference/overview.md
+++ b/docs/reference/overview.md
@@ -14,6 +14,15 @@ Integrations connect Spec Kit to your AI coding agent. Each integration sets up
[Integrations reference →](integrations.md)
+## Project Configuration
+
+Project configuration lets you inspect and safely change selected settings
+recorded during initialization, including script type, feature numbering, and
+the team-directives source. Extension lifecycle commands are also available
+under `specify config extension`.
+
+[Project configuration reference →](configuration.md)
+
## Extensions
Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They are discovered through catalogs and can be installed, updated, enabled, disabled, or removed independently. Multiple extensions can coexist in a single project.
diff --git a/docs/superpowers/plans/2026-08-30-post-init-config-command.md b/docs/superpowers/plans/2026-08-30-post-init-config-command.md
new file mode 100644
index 0000000000..a96ce58cc2
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-30-post-init-config-command.md
@@ -0,0 +1,179 @@
+# Post-Initialization Configuration Command Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a `specify config` command that safely displays and changes selected initialization settings and exposes existing extension management below `config extension`.
+
+**Architecture:** Add a small `commands/config.py` Typer group registered by the root CLI. It reads and writes the existing `.specify/init-options.json` helpers, delegates `config extension` to the existing extension Typer group, and calls the existing team-directives synchronization facilities for source changes. It must not replace integration lifecycle commands.
+
+**Tech Stack:** Python 3.11+, Typer, Rich, pytest `CliRunner`.
+
+**Spec:** User-approved chat design: list/get and safe mutation of `script`, `feature-numbering`, and `team-ai-directives`; extension management exposed at `config extension`; integration ownership remains with `specify integration`.
+
+## Global Constraints
+
+- `specify integration use` remains the only way to change the active integration or skills layout.
+- Persist configuration through `save_init_options()` so formatting remains consistent.
+- Validate every public input before writing state.
+- Reuse the existing extension group instead of duplicating extension lifecycle behavior.
+- Changing or unsetting team directives must preserve user-created skills and warn about copied domain skills.
+
+---
+
+### Task 1: Add the configuration command with safe read/write behavior
+
+**Files:**
+
+- Create: `src/specify_cli/commands/config.py`
+- Modify: `src/specify_cli/__init__.py`
+- Test: `tests/test_config_cli.py`
+
+**Interfaces:**
+
+- Consumes: `load_init_options(project_root)`, `save_init_options(project_root, options)`, `_require_specify_project()`.
+- Produces: `specify config list`, `get`, `set`, and `unset` commands; `specify config extension ...` forwards to the existing extension group.
+
+- [x] **Step 1: Write failing CLI tests**
+
+```python
+def test_config_set_script_persists_valid_value(project, monkeypatch):
+ monkeypatch.chdir(project)
+ result = runner.invoke(app, ["config", "set", "script", "py"])
+ assert result.exit_code == 0
+ assert load_init_options(project)["script"] == "py"
+
+
+def test_config_extension_list_reuses_extension_commands(project, monkeypatch):
+ monkeypatch.chdir(project)
+ result = runner.invoke(app, ["config", "extension", "list"])
+ assert result.exit_code == 0
+```
+
+- [x] **Step 2: Run the focused test module and verify RED**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Expected: collection/import failure because `config` is not yet registered.
+
+- [x] **Step 3: Implement the minimal command group**
+
+```python
+config_app = make_typer(name="config", help="View and manage project configuration")
+config_app.add_typer(extension_app, name="extension")
+
+
+@config_app.command("set")
+def config_set(key: str, value: str) -> None:
+ # Validate the allowed key/value pair, then save init options.
+ ...
+```
+
+Implement `list` and `get` as read-only operations. Limit direct mutation to `script`, `feature-numbering`, and `team-ai-directives`. Reject all other keys with guidance to the owner command.
+
+- [x] **Step 4: Run the focused tests and verify GREEN**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Expected: PASS.
+
+### Task 2: Handle team-directives source lifecycle safely
+
+**Files:**
+
+- Modify: `src/specify_cli/commands/config.py`
+- Modify: `tests/test_config_cli.py`
+
+**Interfaces:**
+
+- Consumes: `sync_team_ai_directives(source, project_root, force=False)`, `_install_skills_from_path(...)`, `ExtensionManager.remove("team-ai-directives")`.
+- Produces: `config set team-ai-directives SOURCE` and `config unset team-ai-directives`.
+
+- [x] **Step 1: Write failing lifecycle tests**
+
+```python
+def test_config_set_team_directives_saves_resolved_source(project, monkeypatch):
+ monkeypatch.chdir(project)
+ monkeypatch.setattr(config, "sync_team_ai_directives", lambda *_args, **_kwargs: ("local", Path("/resolved/team")))
+ result = runner.invoke(app, ["config", "set", "team-ai-directives", "/input/team"])
+ assert result.exit_code == 0
+ assert load_init_options(project)["team_ai_directives"] == "/resolved/team"
+```
+
+- [x] **Step 2: Run the focused test module and verify RED**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Expected: FAIL because the lifecycle command is not implemented.
+
+- [x] **Step 3: Implement source set/unset behavior**
+
+Use the existing synchronization helper to validate and install the governance extension. Install default skills for the active integration when present. On unset, remove the governance extension, remove the saved source key, and print a warning that copied domain skills remain under the active agent’s skills directory for manual review.
+
+- [x] **Step 4: Run focused tests and verify GREEN**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Expected: PASS.
+
+### Task 3: Document the supported lifecycle
+
+**Files:**
+
+- Create: `docs/reference/configuration.md`
+- Modify: `docs/toc.yml`
+- Modify: `README.md`
+- Test: `tests/test_config_cli.py`
+
+**Interfaces:**
+
+- Consumes: the public command surface from Tasks 1 and 2.
+- Produces: discoverable reference documentation explaining init settings, team-directives source changes, and extension management delegation.
+
+- [x] **Step 1: Add a command-surface test for help output**
+
+```python
+def test_config_help_lists_configuration_commands():
+ result = runner.invoke(app, ["config", "--help"])
+ assert result.exit_code == 0
+ assert "team-ai-directives" in result.output
+```
+
+- [x] **Step 2: Write concise documentation**
+
+Document exact commands, supported mutable keys, and the explicit boundary that active integration changes use `specify integration use`.
+
+- [x] **Step 3: Run focused tests and Markdown lint**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Run: `npx --yes markdownlint-cli2 README.md docs/reference/configuration.md`
+
+Expected: both commands pass.
+
+### Task 4: Verify the integrated command
+
+**Files:**
+
+- Test: `tests/test_config_cli.py`
+
+- [x] **Step 1: Check required tooling before test execution**
+
+Run: `command -v uv && test -x .venv/bin/python`
+
+- [x] **Step 2: Run the focused test suite**
+
+Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
+
+Expected: PASS.
+
+- [x] **Step 3: Run the relevant existing extension and initialization tests**
+
+Run: `.venv/bin/python -m pytest tests/integrations/test_cli.py::TestInitTeamAiDirectives tests/test_extensions.py -q`
+
+Expected: PASS.
+
+- [x] **Step 4: Inspect the CLI manually**
+
+Run: `specify config --help`
+
+Expected: Help lists `list`, `get`, `set`, `unset`, and `extension`.
diff --git a/docs/toc.yml b/docs/toc.yml
index d2f1b2bd21..0fa975a233 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -37,6 +37,8 @@
href: reference/core.md
- name: Integrations
href: reference/integrations.md
+ - name: Project Configuration
+ href: reference/configuration.md
- name: Extensions
href: reference/extensions.md
- name: Presets
diff --git a/docs/upgrade.md b/docs/upgrade.md
index c3c8330591..e50248193d 100644
--- a/docs/upgrade.md
+++ b/docs/upgrade.md
@@ -547,4 +547,4 @@ After upgrading:
- **Test new slash commands:** Run `/speckit.constitution` or another command to verify everything works
- **Review release notes:** Check [GitHub Releases](https://github.com/github/spec-kit/releases) for new features and breaking changes
- **Update workflows:** If new commands were added, update your team's development workflows
-- **Check documentation:** Visit [github.io/spec-kit](https://github.github.io/spec-kit/) for updated guides
+- **Check documentation:** Visit [the documentation site](https://tikalk.github.io/agentic-sdlc-spec-kit/) for updated guides
diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index 8fdb6baeab..13ac10cf25 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -658,6 +658,11 @@ def version(
from .commands.event import register as _register_event_cmds # noqa: E402
_register_event_cmds(app)
+
+# ===== Configuration Commands =====
+from .commands.config import register as _register_config_cmds # noqa: E402
+_register_config_cmds(app)
+
# Re-export selected helpers to preserve the public import surface.
from .integrations._helpers import ( # noqa: E402
_clear_init_options_for_integration as _clear_init_options_for_integration,
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
new file mode 100644
index 0000000000..c599ccd740
--- /dev/null
+++ b/src/specify_cli/commands/config.py
@@ -0,0 +1,197 @@
+"""Project configuration commands for settings persisted by ``specify init``."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import typer
+from rich.table import Table
+
+from .._console import console
+from .._init_options import load_init_options, save_init_options
+from ..extensions import ExtensionManager
+from ..extensions._commands import extension_app
+
+try:
+ from .._init_fork import _install_skills_from_path, sync_team_ai_directives
+except ImportError:
+ _install_skills_from_path = None
+ sync_team_ai_directives = None
+
+try:
+ from .._init_fork import make_typer
+except ImportError:
+
+ def make_typer(*, name: str | None = None, help: str | None = None, **kwargs):
+ kwargs.setdefault("add_completion", False)
+ return typer.Typer(name=name, help=help, **kwargs)
+
+
+config_app = make_typer(
+ name="config",
+ help="View and manage project configuration",
+)
+config_app.add_typer(extension_app, name="extension")
+
+
+_INIT_OPTION_KEYS = {
+ "ai": "ai",
+ "ai-skills": "ai_skills",
+ "feature-numbering": "feature_numbering",
+ "here": "here",
+ "integration": "integration",
+ "script": "script",
+ "speckit-version": "speckit_version",
+ "team-ai-directives": "team_ai_directives",
+}
+_SCRIPT_TYPES = {"sh", "ps", "py"}
+_FEATURE_NUMBERING = {"sequential", "timestamp"}
+
+
+def _require_specify_project():
+ from .. import _require_specify_project as require_project
+
+ return require_project()
+
+
+def _canonical_key(key: str) -> str | None:
+ return _INIT_OPTION_KEYS.get(key.replace("_", "-").lower())
+
+
+def _display_value(value: Any) -> str:
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False)
+ return str(value)
+
+
+def _print_extensions(project_root) -> None:
+ installed = ExtensionManager(project_root).list_installed()
+ if not installed:
+ console.print("\nNo extensions installed.")
+ return
+
+ table = Table(title="Extensions")
+ table.add_column("ID")
+ table.add_column("Status")
+ table.add_column("Priority", justify="right")
+ table.add_column("Config")
+ for extension in installed:
+ extension_id = extension["id"]
+ table.add_row(
+ extension_id,
+ "enabled" if extension["enabled"] else "disabled",
+ str(extension["priority"]),
+ f".specify/extensions/{extension_id}/",
+ )
+ console.print()
+ console.print(table)
+
+
+@config_app.command("list")
+def config_list(
+ as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
+) -> None:
+ """List initialization settings and installed extensions."""
+ project_root = _require_specify_project()
+ options = load_init_options(project_root)
+ extensions = ExtensionManager(project_root).list_installed()
+
+ if as_json:
+ console.print_json(
+ json.dumps({"init": options, "extensions": extensions}, ensure_ascii=False)
+ )
+ return
+
+ table = Table(title="Initialization Settings")
+ table.add_column("Key")
+ table.add_column("Value")
+ for display_key, stored_key in _INIT_OPTION_KEYS.items():
+ if stored_key in options:
+ table.add_row(display_key, _display_value(options[stored_key]))
+ console.print(table)
+ _print_extensions(project_root)
+
+
+@config_app.command("get")
+def config_get(key: str = typer.Argument(help="Configuration key")) -> None:
+ """Show one persisted initialization setting."""
+ stored_key = _canonical_key(key)
+ if stored_key is None:
+ raise typer.BadParameter(f"Unknown configuration key: {key}")
+
+ options = load_init_options(_require_specify_project())
+ if stored_key not in options:
+ console.print(f"{key.replace('_', '-')} is not set")
+ raise typer.Exit(1)
+ console.print(_display_value(options[stored_key]))
+
+
+@config_app.command("set")
+def config_set(
+ key: str = typer.Argument(help="Configuration key"),
+ value: str = typer.Argument(help="New value"),
+) -> None:
+ """Change a supported initialization setting."""
+ normalized_key = key.replace("_", "-").lower()
+ project_root = _require_specify_project()
+ options = load_init_options(project_root)
+
+ if normalized_key == "script":
+ normalized_value = value.lower()
+ if normalized_value not in _SCRIPT_TYPES:
+ raise typer.BadParameter("script must be one of: sh, ps, py")
+ options["script"] = normalized_value
+ elif normalized_key == "feature-numbering":
+ normalized_value = value.lower()
+ if normalized_value not in _FEATURE_NUMBERING:
+ raise typer.BadParameter(
+ "feature-numbering must be one of: sequential, timestamp"
+ )
+ options["feature_numbering"] = normalized_value
+ elif normalized_key in {"ai", "integration", "ai-skills"}:
+ raise typer.BadParameter(
+ f"{normalized_key} is managed by specify integration use {value}"
+ )
+ elif normalized_key == "team-ai-directives":
+ if sync_team_ai_directives is None or _install_skills_from_path is None:
+ raise typer.BadParameter("team-ai-directives is only available in this fork")
+ selected_ai = options.get("ai")
+ if not isinstance(selected_ai, str) or not selected_ai:
+ raise typer.BadParameter(
+ "team-ai-directives requires an active integration; run specify integration use first"
+ )
+ _, directives_path = sync_team_ai_directives(value, project_root, force=False)
+ _install_skills_from_path(
+ team_directives_path=directives_path,
+ project_path=project_root,
+ selected_ai=selected_ai,
+ force=False,
+ )
+ options["team_ai_directives"] = str(directives_path)
+ else:
+ raise typer.BadParameter(f"Unknown configuration key: {key}")
+
+ save_init_options(project_root, options)
+ console.print(f"Updated {normalized_key}")
+
+
+@config_app.command("unset")
+def config_unset(key: str = typer.Argument(help="Configuration key")) -> None:
+ """Remove a supported initialization setting."""
+ normalized_key = key.replace("_", "-").lower()
+ if normalized_key != "team-ai-directives":
+ raise typer.BadParameter("Only team-ai-directives can be unset")
+
+ project_root = _require_specify_project()
+ options = load_init_options(project_root)
+ ExtensionManager(project_root).remove("team-ai-directives")
+ options.pop("team_ai_directives", None)
+ save_init_options(project_root, options)
+ console.print("Removed team-ai-directives configuration.")
+ console.print("Copied team skills remain for manual review in the active agent skills directory.")
+
+
+def register(app: typer.Typer) -> None:
+ """Attach the configuration command group to the root Typer app."""
+ app.add_typer(config_app, name="config")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
new file mode 100644
index 0000000000..91dcc7c5ff
--- /dev/null
+++ b/tests/test_config_cli.py
@@ -0,0 +1,176 @@
+"""Behavior tests for the post-initialization configuration CLI."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from typer.testing import CliRunner
+
+from specify_cli import app, load_init_options, save_init_options
+from specify_cli.commands import config
+
+
+runner = CliRunner()
+
+
+def _project(tmp_path):
+ project = tmp_path / "project"
+ (project / ".specify").mkdir(parents=True)
+ save_init_options(
+ project,
+ {
+ "ai": "codex",
+ "feature_numbering": "sequential",
+ "script": "sh",
+ "speckit_version": "0.0.0-test",
+ },
+ )
+ return project
+
+
+def test_config_list_shows_initialization_settings(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "list"])
+
+ assert result.exit_code == 0, result.output
+ assert "script" in result.output
+ assert "sh" in result.output
+ assert "feature-numbering" in result.output
+ assert "sequential" in result.output
+
+
+def test_config_list_shows_recorded_skills_layout(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ save_init_options(project, {**load_init_options(project), "ai_skills": True})
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "list"])
+
+ assert result.exit_code == 0, result.output
+ assert "ai-skills" in result.output
+ assert "True" in result.output
+
+
+def test_config_set_script_persists_valid_value(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "set", "script", "py"])
+
+ assert result.exit_code == 0, result.output
+ assert load_init_options(project)["script"] == "py"
+
+
+def test_config_set_feature_numbering_persists_valid_value(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(
+ app, ["config", "set", "feature-numbering", "timestamp"]
+ )
+
+ assert result.exit_code == 0, result.output
+ assert load_init_options(project)["feature_numbering"] == "timestamp"
+
+
+def test_config_rejects_integration_mutation_with_owner_guidance(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "set", "integration", "claude"])
+
+ assert result.exit_code != 0
+ assert "specify integration use claude" in result.output
+ assert load_init_options(project)["ai"] == "codex"
+
+
+def test_config_extension_list_reuses_extension_commands(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "extension", "list"])
+
+ assert result.exit_code == 0, result.output
+ assert "No extensions installed" in result.output
+
+
+def test_config_help_lists_configuration_commands():
+ result = runner.invoke(app, ["config", "--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "set" in result.output
+ assert "unset" in result.output
+ assert "extension" in result.output
+
+
+def test_config_set_team_directives_saves_resolved_source_and_skills(
+ tmp_path, monkeypatch
+):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+ calls = []
+
+ def sync(source, project_root, *, force):
+ calls.append(("sync", source, project_root, force))
+ return "local", Path("/resolved/team-directives")
+
+ def install_skills(**kwargs):
+ calls.append(("skills", kwargs))
+ return ["team-boot"]
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync, raising=False)
+ monkeypatch.setattr(config, "_install_skills_from_path", install_skills, raising=False)
+
+ result = runner.invoke(
+ app,
+ ["config", "set", "team-ai-directives", "/input/team-directives"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert load_init_options(project)["team_ai_directives"] == "/resolved/team-directives"
+ assert calls == [
+ ("sync", "/input/team-directives", project, False),
+ (
+ "skills",
+ {
+ "team_directives_path": Path("/resolved/team-directives"),
+ "project_path": project,
+ "selected_ai": "codex",
+ "force": False,
+ },
+ ),
+ ]
+
+
+def test_config_unset_team_directives_removes_extension_and_saved_source(
+ tmp_path, monkeypatch
+):
+ project = _project(tmp_path)
+ save_init_options(
+ project,
+ {
+ **load_init_options(project),
+ "team_ai_directives": "/resolved/team-directives",
+ },
+ )
+ monkeypatch.chdir(project)
+ removed = []
+
+ class FakeManager:
+ def __init__(self, project_root):
+ assert project_root == project
+
+ def remove(self, extension_id):
+ removed.append(extension_id)
+ return True
+
+ monkeypatch.setattr(config, "ExtensionManager", FakeManager)
+
+ result = runner.invoke(app, ["config", "unset", "team-ai-directives"])
+
+ assert result.exit_code == 0, result.output
+ assert removed == ["team-ai-directives"]
+ assert "team_ai_directives" not in load_init_options(project)
+ assert "copied team skills" in result.output.lower()
From 0f22369cf8b807f1301df4006f297a0a3c383720 Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Mon, 7 Sep 2026 21:25:25 +0300
Subject: [PATCH 07/15] fix: implement PR recommendations
---
CHANGELOG.md | 14 ++
docs/local-development.md | 17 +-
docs/reference/configuration.md | 47 +++-
.../2026-08-30-post-init-config-command.md | 179 ---------------
pyproject.toml | 2 +-
src/specify_cli/commands/config.py | 66 ++++--
.../test_integration_subcommand.py | 43 ++++
tests/test_config_cli.py | 209 +++++++++++++++++-
8 files changed, 361 insertions(+), 216 deletions(-)
delete mode 100644 docs/superpowers/plans/2026-08-30-post-init-config-command.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54b9062c0f..3830879ac2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
All notable changes to the Specify CLI and templates are documented here.
+## [1.0.4+adlc2] - 2026-09-07
+
+### Added
+
+- **Project configuration CLI**: Added `specify config` for inspecting persisted
+ initialization settings, safely changing feature numbering,
+ delegating extension lifecycle commands, and managing the team-directives
+ source after initialization. Added the corresponding reference and local
+ development documentation.
+ Script and skills-layout changes use `specify integration upgrade`.
+ Read-only settings receive specific guidance, team-directives failures report
+ partial installation and retry guidance, and unsetting an absent extension
+ distinguishes saved-source cleanup from an already-unset configuration.
+
# [1.0.4+adlc1] - 2026-09-03
### Changed
diff --git a/docs/local-development.md b/docs/local-development.md
index 8bf60f963e..2564bed208 100644
--- a/docs/local-development.md
+++ b/docs/local-development.md
@@ -74,7 +74,7 @@ Run the read and mutation commands and verify each result:
"$SPECIFY" config list --json
"$SPECIFY" config get script
-"$SPECIFY" config set script py
+"$SPECIFY" integration upgrade copilot --script py
"$SPECIFY" config get script
"$SPECIFY" config set feature-numbering timestamp
@@ -83,15 +83,22 @@ Run the read and mutation commands and verify each result:
The final two `get` commands must print `py` and `timestamp`. The corresponding
values in `"$TEST_ROOT/project/.specify/init-options.json"` must match.
+Inspect `.github/skills/` and compare helper invocations with the selected
+templates. Core templates supporting `py` use `scripts/python/`; bundled
+preset overrides without a `py` variant can still invoke shell helpers.
Verify that integration ownership is enforced:
```bash
"$SPECIFY" config set integration claude
+"$SPECIFY" config set script sh
+"$SPECIFY" config set ai-skills true
+"$SPECIFY" config set here true
```
-This command must fail and direct you to `specify integration use` without
-changing the saved integration.
+Each command must fail without changing saved settings. Integration selection
+must point to `specify integration use`, script and layout changes to
+`specify integration upgrade`, and `here` must be identified as read-only.
Verify extension delegation using the bundled `git` extension:
@@ -240,7 +247,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
## 12. Rapid Edit Loop Summary
| Action | Command |
-|--------|---------|
+| --- | --- |
| Run CLI directly | `python -m src.specify_cli --help` |
| Editable install | `uv pip install -e .` then `specify ...` |
| Local uvx run (repo root) | `uvx --from . specify ...` |
@@ -259,7 +266,7 @@ rm -rf .venv dist build *.egg-info
## 14. Common Issues
| Symptom | Fix |
-|---------|-----|
+| --- | --- |
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git commands unavailable | Install the git extension with `specify extension add git` |
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 9bab38a851..f17f880205 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -18,7 +18,6 @@ extensions. `--json` prints both as machine-readable JSON.
## Change Supported Initialization Settings
```bash
-specify config set script py
specify config set feature-numbering timestamp
```
@@ -26,12 +25,34 @@ Supported values are:
| Setting | Values |
| --- | --- |
-| `script` | `sh`, `ps`, `py` |
| `feature-numbering` | `sequential`, `timestamp` |
-The active coding-agent integration and skills layout are not configurable
-through this command because changing them requires regenerating agent files.
-Use `specify integration use ` instead.
+Script type, the active integration, and skills layout are owned by
+`specify integration`. Changing a script type requires regenerating the
+installed agent files:
+
+```bash
+specify integration upgrade --script py
+```
+
+Use the active integration key to update both its commands and the script
+setting shown by `config get script`. Supported script types are `sh`, `ps`,
+and `py`. Upgrade checks manifest hashes and refuses to overwrite modified
+files without `--force`; review those changes before choosing to overwrite them.
+
+The selected type applies where a template supplies that variant. Some bundled
+preset overrides, including the `agentic-sdlc` plan command, supply only `sh`
+and `ps` and fall back to a supported variant when `py` is selected. Inspect
+generated commands before assuming every helper uses Python.
+
+Use `specify integration use ` to select an installed integration.
+For layout changes, use `specify integration upgrade
+--integration-options="..."` with that integration's supported options. For
+example, Copilot supports `--integration-options="--commands"`. Layout options
+vary by integration; `ai-skills` is not a universal toggle.
+
+`here` and `speckit-version` are read-only initialization metadata. Known
+read-only settings and unknown keys produce distinct errors when set.
## Change or Remove the Team Directives Source
@@ -43,12 +64,24 @@ specify config unset team-ai-directives
```
Setting a source validates it, ensures the bundled governance extension is
-installed without replacing an existing one, and installs any source-declared
-default skills that are not already present.
+installed without replacing an existing one, merges its `.mcp.json` when
+present, and installs any source-declared default skills that are not already
+present.
Unsetting it removes the governance extension and the saved source setting.
Copied team skills are intentionally left in the active agent's skills
directory for manual review.
+Installation is not transactional. If synchronization or skill installation
+fails, the command exits with an error and leaves the previous saved source
+unchanged. The extension and some skills may already have been installed.
+Inspect and repair incomplete skill files, correct the reported cause, and
+retry the same `config set team-ai-directives` command. Existing skill files
+are skipped, even if a failed copy left them incomplete; retry alone does not
+repair those files. No automatic rollback is attempted.
+
+If the extension is absent, `unset` still clears a saved source and reports
+that cleanup. If neither exists, it reports that the setting is already unset.
+
## Manage Extensions
`specify config extension` exposes the existing extension lifecycle under the
diff --git a/docs/superpowers/plans/2026-08-30-post-init-config-command.md b/docs/superpowers/plans/2026-08-30-post-init-config-command.md
deleted file mode 100644
index a96ce58cc2..0000000000
--- a/docs/superpowers/plans/2026-08-30-post-init-config-command.md
+++ /dev/null
@@ -1,179 +0,0 @@
-# Post-Initialization Configuration Command Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a `specify config` command that safely displays and changes selected initialization settings and exposes existing extension management below `config extension`.
-
-**Architecture:** Add a small `commands/config.py` Typer group registered by the root CLI. It reads and writes the existing `.specify/init-options.json` helpers, delegates `config extension` to the existing extension Typer group, and calls the existing team-directives synchronization facilities for source changes. It must not replace integration lifecycle commands.
-
-**Tech Stack:** Python 3.11+, Typer, Rich, pytest `CliRunner`.
-
-**Spec:** User-approved chat design: list/get and safe mutation of `script`, `feature-numbering`, and `team-ai-directives`; extension management exposed at `config extension`; integration ownership remains with `specify integration`.
-
-## Global Constraints
-
-- `specify integration use` remains the only way to change the active integration or skills layout.
-- Persist configuration through `save_init_options()` so formatting remains consistent.
-- Validate every public input before writing state.
-- Reuse the existing extension group instead of duplicating extension lifecycle behavior.
-- Changing or unsetting team directives must preserve user-created skills and warn about copied domain skills.
-
----
-
-### Task 1: Add the configuration command with safe read/write behavior
-
-**Files:**
-
-- Create: `src/specify_cli/commands/config.py`
-- Modify: `src/specify_cli/__init__.py`
-- Test: `tests/test_config_cli.py`
-
-**Interfaces:**
-
-- Consumes: `load_init_options(project_root)`, `save_init_options(project_root, options)`, `_require_specify_project()`.
-- Produces: `specify config list`, `get`, `set`, and `unset` commands; `specify config extension ...` forwards to the existing extension group.
-
-- [x] **Step 1: Write failing CLI tests**
-
-```python
-def test_config_set_script_persists_valid_value(project, monkeypatch):
- monkeypatch.chdir(project)
- result = runner.invoke(app, ["config", "set", "script", "py"])
- assert result.exit_code == 0
- assert load_init_options(project)["script"] == "py"
-
-
-def test_config_extension_list_reuses_extension_commands(project, monkeypatch):
- monkeypatch.chdir(project)
- result = runner.invoke(app, ["config", "extension", "list"])
- assert result.exit_code == 0
-```
-
-- [x] **Step 2: Run the focused test module and verify RED**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Expected: collection/import failure because `config` is not yet registered.
-
-- [x] **Step 3: Implement the minimal command group**
-
-```python
-config_app = make_typer(name="config", help="View and manage project configuration")
-config_app.add_typer(extension_app, name="extension")
-
-
-@config_app.command("set")
-def config_set(key: str, value: str) -> None:
- # Validate the allowed key/value pair, then save init options.
- ...
-```
-
-Implement `list` and `get` as read-only operations. Limit direct mutation to `script`, `feature-numbering`, and `team-ai-directives`. Reject all other keys with guidance to the owner command.
-
-- [x] **Step 4: Run the focused tests and verify GREEN**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Expected: PASS.
-
-### Task 2: Handle team-directives source lifecycle safely
-
-**Files:**
-
-- Modify: `src/specify_cli/commands/config.py`
-- Modify: `tests/test_config_cli.py`
-
-**Interfaces:**
-
-- Consumes: `sync_team_ai_directives(source, project_root, force=False)`, `_install_skills_from_path(...)`, `ExtensionManager.remove("team-ai-directives")`.
-- Produces: `config set team-ai-directives SOURCE` and `config unset team-ai-directives`.
-
-- [x] **Step 1: Write failing lifecycle tests**
-
-```python
-def test_config_set_team_directives_saves_resolved_source(project, monkeypatch):
- monkeypatch.chdir(project)
- monkeypatch.setattr(config, "sync_team_ai_directives", lambda *_args, **_kwargs: ("local", Path("/resolved/team")))
- result = runner.invoke(app, ["config", "set", "team-ai-directives", "/input/team"])
- assert result.exit_code == 0
- assert load_init_options(project)["team_ai_directives"] == "/resolved/team"
-```
-
-- [x] **Step 2: Run the focused test module and verify RED**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Expected: FAIL because the lifecycle command is not implemented.
-
-- [x] **Step 3: Implement source set/unset behavior**
-
-Use the existing synchronization helper to validate and install the governance extension. Install default skills for the active integration when present. On unset, remove the governance extension, remove the saved source key, and print a warning that copied domain skills remain under the active agent’s skills directory for manual review.
-
-- [x] **Step 4: Run focused tests and verify GREEN**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Expected: PASS.
-
-### Task 3: Document the supported lifecycle
-
-**Files:**
-
-- Create: `docs/reference/configuration.md`
-- Modify: `docs/toc.yml`
-- Modify: `README.md`
-- Test: `tests/test_config_cli.py`
-
-**Interfaces:**
-
-- Consumes: the public command surface from Tasks 1 and 2.
-- Produces: discoverable reference documentation explaining init settings, team-directives source changes, and extension management delegation.
-
-- [x] **Step 1: Add a command-surface test for help output**
-
-```python
-def test_config_help_lists_configuration_commands():
- result = runner.invoke(app, ["config", "--help"])
- assert result.exit_code == 0
- assert "team-ai-directives" in result.output
-```
-
-- [x] **Step 2: Write concise documentation**
-
-Document exact commands, supported mutable keys, and the explicit boundary that active integration changes use `specify integration use`.
-
-- [x] **Step 3: Run focused tests and Markdown lint**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Run: `npx --yes markdownlint-cli2 README.md docs/reference/configuration.md`
-
-Expected: both commands pass.
-
-### Task 4: Verify the integrated command
-
-**Files:**
-
-- Test: `tests/test_config_cli.py`
-
-- [x] **Step 1: Check required tooling before test execution**
-
-Run: `command -v uv && test -x .venv/bin/python`
-
-- [x] **Step 2: Run the focused test suite**
-
-Run: `.venv/bin/python -m pytest tests/test_config_cli.py -q`
-
-Expected: PASS.
-
-- [x] **Step 3: Run the relevant existing extension and initialization tests**
-
-Run: `.venv/bin/python -m pytest tests/integrations/test_cli.py::TestInitTeamAiDirectives tests/test_extensions.py -q`
-
-Expected: PASS.
-
-- [x] **Step 4: Inspect the CLI manually**
-
-Run: `specify config --help`
-
-Expected: Help lists `list`, `get`, `set`, `unset`, and `extension`.
diff --git a/pyproject.toml b/pyproject.toml
index d0d00f3b3f..de890c0362 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "agentic-sdlc-specify-cli"
-version = "1.0.4+adlc1"
+version = "1.0.4+adlc2"
description = "Specify CLI (tikalk fork). Agentic SDLC toolkit for Spec-Driven Development with pre-installed extensions and AI integrations."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index c599ccd740..92774ec849 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -9,6 +9,7 @@
from rich.table import Table
from .._console import console
+from .._core_fork import install_mcp_config
from .._init_options import load_init_options, save_init_options
from ..extensions import ExtensionManager
from ..extensions._commands import extension_app
@@ -45,7 +46,6 @@ def make_typer(*, name: str | None = None, help: str | None = None, **kwargs):
"speckit-version": "speckit_version",
"team-ai-directives": "team_ai_directives",
}
-_SCRIPT_TYPES = {"sh", "ps", "py"}
_FEATURE_NUMBERING = {"sequential", "timestamp"}
@@ -138,10 +138,9 @@ def config_set(
options = load_init_options(project_root)
if normalized_key == "script":
- normalized_value = value.lower()
- if normalized_value not in _SCRIPT_TYPES:
- raise typer.BadParameter("script must be one of: sh, ps, py")
- options["script"] = normalized_value
+ raise typer.BadParameter(
+ "script is managed by specify integration upgrade --script "
+ )
elif normalized_key == "feature-numbering":
normalized_value = value.lower()
if normalized_value not in _FEATURE_NUMBERING:
@@ -149,10 +148,17 @@ def config_set(
"feature-numbering must be one of: sequential, timestamp"
)
options["feature_numbering"] = normalized_value
- elif normalized_key in {"ai", "integration", "ai-skills"}:
+ elif normalized_key in {"ai", "integration"}:
raise typer.BadParameter(
f"{normalized_key} is managed by specify integration use {value}"
)
+ elif normalized_key == "ai-skills":
+ raise typer.BadParameter(
+ "ai-skills is managed by specify integration upgrade --integration-options. "
+ "Options depend on the integration; see specify integration upgrade --help."
+ )
+ elif normalized_key in {"here", "speckit-version"}:
+ raise typer.BadParameter(f"{normalized_key} is read-only")
elif normalized_key == "team-ai-directives":
if sync_team_ai_directives is None or _install_skills_from_path is None:
raise typer.BadParameter("team-ai-directives is only available in this fork")
@@ -161,14 +167,28 @@ def config_set(
raise typer.BadParameter(
"team-ai-directives requires an active integration; run specify integration use first"
)
- _, directives_path = sync_team_ai_directives(value, project_root, force=False)
- _install_skills_from_path(
- team_directives_path=directives_path,
- project_path=project_root,
- selected_ai=selected_ai,
- force=False,
- )
- options["team_ai_directives"] = str(directives_path)
+ phase = "synchronization"
+ try:
+ _, directives_path = sync_team_ai_directives(value, project_root, force=False)
+ if (directives_path / ".mcp.json").exists():
+ phase = "MCP configuration"
+ install_mcp_config(directives_path, project_root)
+ phase = "skill installation"
+ _install_skills_from_path(
+ team_directives_path=directives_path,
+ project_path=project_root,
+ selected_ai=selected_ai,
+ force=False,
+ )
+ except Exception as exc:
+ console.print(f"Team AI directives {phase} failed: {exc}", markup=False)
+ console.print(
+ "Partial extension or skills files may remain. Inspect and repair incomplete "
+ "skill files first: existing skills are skipped on retry. Fix the cause and retry the same "
+ "config set team-ai-directives command. The saved source was not changed."
+ )
+ raise typer.Exit(1) from None
+ options["team_ai_directives"] = str(directives_path.resolve())
else:
raise typer.BadParameter(f"Unknown configuration key: {key}")
@@ -185,11 +205,19 @@ def config_unset(key: str = typer.Argument(help="Configuration key")) -> None:
project_root = _require_specify_project()
options = load_init_options(project_root)
- ExtensionManager(project_root).remove("team-ai-directives")
- options.pop("team_ai_directives", None)
- save_init_options(project_root, options)
- console.print("Removed team-ai-directives configuration.")
- console.print("Copied team skills remain for manual review in the active agent skills directory.")
+ removed = ExtensionManager(project_root).remove("team-ai-directives")
+ had_source = "team_ai_directives" in options
+ if had_source:
+ options.pop("team_ai_directives")
+ save_init_options(project_root, options)
+ if removed:
+ console.print("Removed team-ai-directives extension and configuration.")
+ elif had_source:
+ console.print("Cleared team-ai-directives saved source; the extension was not installed.")
+ else:
+ console.print("Nothing to unset: no team-ai-directives extension or saved source.")
+ if removed or had_source:
+ console.print("Copied team skills remain for manual review in the active agent skills directory.")
def register(app: typer.Typer) -> None:
diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py
index 020cb8dcbf..e2b7952c21 100644
--- a/tests/integrations/test_integration_subcommand.py
+++ b/tests/integrations/test_integration_subcommand.py
@@ -2337,6 +2337,49 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
class TestIntegrationUpgrade:
+ @pytest.mark.parametrize("modified", [False, True])
+ def test_script_upgrade_regenerates_commands_or_preserves_customizations(
+ self, copilot_project, modified
+ ):
+ project = copilot_project
+ # Test core templates: bundled preset overrides do not all support py.
+ for preset_id in ("agentic-sdlc", "agentic-change", "agentic-quick"):
+ if (project / ".specify" / "presets" / preset_id).exists():
+ removal = _run_in_project(project, ["preset", "remove", preset_id])
+ assert removal.exit_code == 0, removal.output
+ # Preset removal rewrites commands; establish a clean core manifest
+ # before introducing the customization whose protection is under test.
+ refresh = _run_in_project(
+ project, ["integration", "upgrade", "copilot", "--force"]
+ )
+ assert refresh.exit_code == 0, refresh.output
+ command = project / ".github" / "skills" / _skill_dir_name("plan") / "SKILL.md"
+ original = command.read_text(encoding="utf-8")
+ assert "scripts/bash/setup-plan.sh" in original
+ if modified:
+ command.write_text(original + "\nUser customization\n", encoding="utf-8")
+ before = command.read_bytes()
+ options_file = project / ".specify" / "init-options.json"
+ options_before = options_file.read_bytes()
+
+ result = _run_in_project(
+ project, ["integration", "upgrade", "copilot", "--script", "py"]
+ )
+
+ if modified:
+ assert result.exit_code != 0, result.output
+ assert "modified" in result.output
+ assert command.read_bytes() == before
+ assert options_file.read_bytes() == options_before
+ else:
+ assert result.exit_code == 0, result.output
+ updated = command.read_text(encoding="utf-8")
+ assert "scripts/python/setup_plan.py" in updated
+ assert "scripts/bash/setup-plan.sh" not in updated
+ setting = _run_in_project(project, ["config", "get", "script"])
+ assert setting.exit_code == 0, setting.output
+ assert setting.output.strip() == "py"
+
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index 91dcc7c5ff..fc59f2ea30 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -4,6 +4,7 @@
from pathlib import Path
+import pytest
from typer.testing import CliRunner
from specify_cli import app, load_init_options, save_init_options
@@ -53,14 +54,56 @@ def test_config_list_shows_recorded_skills_layout(tmp_path, monkeypatch):
assert "True" in result.output
-def test_config_set_script_persists_valid_value(tmp_path, monkeypatch):
+def test_config_set_script_rejects_metadata_only_change(tmp_path, monkeypatch):
project = _project(tmp_path)
monkeypatch.chdir(project)
result = runner.invoke(app, ["config", "set", "script", "py"])
- assert result.exit_code == 0, result.output
- assert load_init_options(project)["script"] == "py"
+ assert result.exit_code != 0
+ assert "specify integration upgrade" in result.output
+ assert "--script" in result.output
+ assert load_init_options(project)["script"] == "sh"
+
+
+@pytest.mark.parametrize("key", ["ai-skills", "AI_SKILLS"])
+def test_config_set_skills_guides_to_integration_options(tmp_path, monkeypatch, key):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+ before = load_init_options(project)
+
+ result = runner.invoke(app, ["config", "set", key, "true"])
+
+ assert result.exit_code != 0
+ assert "specify integration upgrade" in result.output
+ assert "--integration-options" in result.output
+ assert "--help" in result.output
+ assert "use true" not in result.output
+ assert load_init_options(project) == before
+
+
+@pytest.mark.parametrize("key", ["here", "HERE", "speckit-version", "SPECKIT_VERSION"])
+def test_config_set_identifies_read_only_keys(tmp_path, monkeypatch, key):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+ before = load_init_options(project)
+
+ result = runner.invoke(app, ["config", "set", key, "anything"])
+
+ assert result.exit_code != 0
+ assert "read-only" in result.output
+ assert "Unknown" not in result.output
+ assert load_init_options(project) == before
+
+
+def test_config_set_unknown_key_preserves_state(tmp_path, monkeypatch):
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+ before = load_init_options(project)
+ result = runner.invoke(app, ["config", "set", "not-a-setting", "anything"])
+ assert result.exit_code != 0
+ assert "Unknown configuration key" in result.output
+ assert load_init_options(project) == before
def test_config_set_feature_numbering_persists_valid_value(tmp_path, monkeypatch):
@@ -75,11 +118,12 @@ def test_config_set_feature_numbering_persists_valid_value(tmp_path, monkeypatch
assert load_init_options(project)["feature_numbering"] == "timestamp"
-def test_config_rejects_integration_mutation_with_owner_guidance(tmp_path, monkeypatch):
+@pytest.mark.parametrize("key", ["ai", "integration", "INTEGRATION"])
+def test_config_rejects_integration_mutation_with_owner_guidance(tmp_path, monkeypatch, key):
project = _project(tmp_path)
monkeypatch.chdir(project)
- result = runner.invoke(app, ["config", "set", "integration", "claude"])
+ result = runner.invoke(app, ["config", "set", key, "claude"])
assert result.exit_code != 0
assert "specify integration use claude" in result.output
@@ -144,6 +188,65 @@ def install_skills(**kwargs):
]
+def test_config_set_team_directives_installs_mcp_configuration(tmp_path, monkeypatch):
+ """A post-init directives source must apply its MCP configuration."""
+ project = _project(tmp_path)
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ (source / ".mcp.json").write_text('{"mcpServers": {"team": {}}}')
+ monkeypatch.chdir(project)
+
+ def sync(value, project_root, *, force):
+ return "local", source
+
+ def install_skills(**kwargs):
+ return []
+
+ def install_mcp(team_path, project_root):
+ assert team_path == source
+ (project_root / ".mcp.json").write_text('{"mcpServers": {"team": {}}}')
+ return True, [], [], []
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+ monkeypatch.setattr(config, "_install_skills_from_path", install_skills)
+ monkeypatch.setattr(config, "install_mcp_config", install_mcp, raising=False)
+
+ result = runner.invoke(
+ app,
+ ["config", "set", "team-ai-directives", str(source)],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert (project / ".mcp.json").read_text() == '{"mcpServers": {"team": {}}}'
+
+
+def test_config_set_team_directives_persists_an_absolute_source_path(
+ tmp_path, monkeypatch
+):
+ """A relative source must remain usable when later commands change CWD."""
+ project = _project(tmp_path)
+ monkeypatch.chdir(project)
+
+ def sync(value, project_root, *, force):
+ return "local", Path("knowledge-base")
+
+ def install_skills(**kwargs):
+ return []
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+ monkeypatch.setattr(config, "_install_skills_from_path", install_skills)
+
+ result = runner.invoke(
+ app,
+ ["config", "set", "team-ai-directives", "knowledge-base"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert load_init_options(project)["team_ai_directives"] == str(
+ (project / "knowledge-base").resolve()
+ )
+
+
def test_config_unset_team_directives_removes_extension_and_saved_source(
tmp_path, monkeypatch
):
@@ -174,3 +277,99 @@ def remove(self, extension_id):
assert removed == ["team-ai-directives"]
assert "team_ai_directives" not in load_init_options(project)
assert "copied team skills" in result.output.lower()
+
+
+@pytest.mark.parametrize("saved_source", [False, True])
+def test_config_unset_absent_extension_reports_actual_outcome(
+ tmp_path, monkeypatch, saved_source
+):
+ project = _project(tmp_path)
+ if saved_source:
+ save_init_options(project, {**load_init_options(project), "team_ai_directives": "/old"})
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, ["config", "unset", "team-ai-directives"])
+
+ assert result.exit_code == 0, result.output
+ assert "team_ai_directives" not in load_init_options(project)
+ assert "Removed" not in result.output
+ if saved_source:
+ assert "Cleared" in result.output
+ assert "not installed" in result.output
+ assert "copied team skills" in result.output.lower()
+ else:
+ assert "Nothing to unset" in result.output
+
+
+@pytest.mark.parametrize("phase", ["synchronization", "skill installation"])
+@pytest.mark.parametrize("existing_source", [False, True])
+def test_config_team_directives_failure_preserves_source_and_allows_retry(
+ tmp_path, monkeypatch, phase, existing_source
+):
+ from specify_cli import _init_fork
+
+ project = _project(tmp_path)
+ if existing_source:
+ save_init_options(project, {**load_init_options(project), "team_ai_directives": "/old"})
+ before = load_init_options(project)
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ (source / ".skills.json").write_text('{"default": ["first", "second"]}')
+ for name in ("first", "second"):
+ skill = source / "skills" / name / "SKILL.md"
+ skill.parent.mkdir(parents=True)
+ skill.write_text(f"{name} skill")
+ partial_extension = project / ".specify" / "extensions" / "team-ai-directives"
+ failing = True
+
+ def sync(value, project_root, *, force):
+ partial_extension.mkdir(parents=True, exist_ok=True)
+ if failing and phase == "synchronization":
+ raise ValueError("source unavailable")
+ return "local", source
+
+ original_copy = _init_fork.shutil.copy2
+
+ def copy_skill(src, dst, *args, **kwargs):
+ if failing and phase == "skill installation" and Path(src).parent.name == "second":
+ Path(dst).write_text("incomplete skill")
+ raise OSError("copy denied")
+ return original_copy(src, dst, *args, **kwargs)
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+ monkeypatch.setattr(_init_fork.shutil, "copy2", copy_skill)
+ monkeypatch.chdir(project)
+ command = ["config", "set", "team-ai-directives", str(source)]
+
+ result = runner.invoke(app, command)
+
+ assert result.exit_code == 1, result.output
+ output = " ".join(result.output.split())
+ assert phase in output
+ expected_cause = "source unavailable" if phase == "synchronization" else "copy denied"
+ assert expected_cause in output
+ assert "retry the same" in output.lower()
+ assert "may remain" in output
+ assert "Inspect and repair incomplete skill files" in output
+ assert "existing skills are skipped on retry" in output
+ assert "Updated" not in output
+ assert "Traceback" not in output
+ assert load_init_options(project) == before
+ assert partial_extension.is_dir()
+ first_skill = project / ".agents" / "skills" / "first" / "SKILL.md"
+ if phase == "skill installation":
+ assert first_skill.read_text() == "first skill"
+ first_skill.write_text("preserved partial skill")
+ incomplete_skill = project / ".agents" / "skills" / "second" / "SKILL.md"
+ assert incomplete_skill.read_text() == "incomplete skill"
+ # Follow the recovery guidance before retrying the failed operation.
+ incomplete_skill.write_text("second skill")
+
+ failing = False
+ retry = runner.invoke(app, command)
+
+ assert retry.exit_code == 0, retry.output
+ assert load_init_options(project)["team_ai_directives"] == str(source)
+ assert (project / ".agents" / "skills" / "second" / "SKILL.md").read_text() == "second skill"
+ if phase == "skill installation":
+ assert first_skill.read_text() == "preserved partial skill"
From 9812e905e1e1b8101204fc23ee4934ffc7a3e84c Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Tue, 8 Sep 2026 08:23:40 +0300
Subject: [PATCH 08/15] fix mcp failure handling
---
docs/reference/configuration.md | 7 ++++---
src/specify_cli/commands/config.py | 9 ++++++++-
tests/test_config_cli.py | 29 +++++++++++++++++++++++++++++
3 files changed, 41 insertions(+), 4 deletions(-)
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index f17f880205..d8ca4e9eb5 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -71,9 +71,10 @@ Unsetting it removes the governance extension and the saved source setting.
Copied team skills are intentionally left in the active agent's skills
directory for manual review.
-Installation is not transactional. If synchronization or skill installation
-fails, the command exits with an error and leaves the previous saved source
-unchanged. The extension and some skills may already have been installed.
+Installation is not transactional. If synchronization, MCP configuration, or
+skill installation fails, the command exits with an error and leaves the
+previous saved source unchanged. The extension and some skills may already
+have been installed.
Inspect and repair incomplete skill files, correct the reported cause, and
retry the same `config set team-ai-directives` command. Existing skill files
are skipped, even if a failed copy left them incomplete; retry alone does not
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index 92774ec849..82045524e1 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -172,7 +172,14 @@ def config_set(
_, directives_path = sync_team_ai_directives(value, project_root, force=False)
if (directives_path / ".mcp.json").exists():
phase = "MCP configuration"
- install_mcp_config(directives_path, project_root)
+ mcp_installed, mcp_messages, _, _ = install_mcp_config(
+ directives_path, project_root
+ )
+ if not mcp_installed:
+ raise RuntimeError(
+ "\n".join(mcp_messages)
+ or "Failed to install MCP configuration"
+ )
phase = "skill installation"
_install_skills_from_path(
team_directives_path=directives_path,
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index fc59f2ea30..00b53d0843 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -220,6 +220,35 @@ def install_mcp(team_path, project_root):
assert (project / ".mcp.json").read_text() == '{"mcpServers": {"team": {}}}'
+def test_config_set_team_directives_preserves_source_when_mcp_install_fails(
+ tmp_path, monkeypatch
+):
+ """A malformed MCP config must not be recorded as a successful setup."""
+ project = _project(tmp_path)
+ save_init_options(
+ project,
+ {**load_init_options(project), "team_ai_directives": "/previous/source"},
+ )
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ (source / ".mcp.json").write_text("{")
+ monkeypatch.chdir(project)
+
+ def sync(value, project_root, *, force):
+ return "local", source
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+
+ result = runner.invoke(
+ app,
+ ["config", "set", "team-ai-directives", str(source)],
+ )
+
+ assert result.exit_code == 1, result.output
+ assert "Invalid MCP config" in result.output
+ assert load_init_options(project)["team_ai_directives"] == "/previous/source"
+
+
def test_config_set_team_directives_persists_an_absolute_source_path(
tmp_path, monkeypatch
):
From 885f604335371fbd58ecffaeae9b63153e8556ad Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Tue, 8 Sep 2026 09:14:13 +0300
Subject: [PATCH 09/15] config output escaping and the overview guidance were
corrected
---
CITATION.cff | 2 +-
docs/reference/overview.md | 8 ++++----
src/specify_cli/commands/config.py | 5 +++--
tests/test_config_cli.py | 18 ++++++++++++++++++
4 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/CITATION.cff b/CITATION.cff
index d4945b8f79..a077b5e7e4 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -17,7 +17,7 @@ authors:
- given-names: Manfred
family-names: Riem
alias: mnriem
-repository-code: "https://github.com/github/spec-kit"
+repository-code: "https://github.com/tikalk/agentic-sdlc-spec-kit"
url: "https://tikalk.github.io/agentic-sdlc-spec-kit/"
license: MIT
version: "0.10.2"
diff --git a/docs/reference/overview.md b/docs/reference/overview.md
index c49d44b918..4141f6043b 100644
--- a/docs/reference/overview.md
+++ b/docs/reference/overview.md
@@ -16,10 +16,10 @@ Integrations connect Spec Kit to your AI coding agent. Each integration sets up
## Project Configuration
-Project configuration lets you inspect and safely change selected settings
-recorded during initialization, including script type, feature numbering, and
-the team-directives source. Extension lifecycle commands are also available
-under `specify config extension`.
+Project configuration lets you inspect persisted initialization settings and
+change feature numbering or the team-directives source. Script type changes are
+handled by `specify integration upgrade --script `.
+Extension lifecycle commands are also available under `specify config extension`.
[Project configuration reference →](configuration.md)
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index 82045524e1..27141e60cd 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -7,6 +7,7 @@
import typer
from rich.table import Table
+from rich.text import Text
from .._console import console
from .._core_fork import install_mcp_config
@@ -108,7 +109,7 @@ def config_list(
table.add_column("Value")
for display_key, stored_key in _INIT_OPTION_KEYS.items():
if stored_key in options:
- table.add_row(display_key, _display_value(options[stored_key]))
+ table.add_row(display_key, Text(_display_value(options[stored_key])))
console.print(table)
_print_extensions(project_root)
@@ -124,7 +125,7 @@ def config_get(key: str = typer.Argument(help="Configuration key")) -> None:
if stored_key not in options:
console.print(f"{key.replace('_', '-')} is not set")
raise typer.Exit(1)
- console.print(_display_value(options[stored_key]))
+ console.print(_display_value(options[stored_key]), markup=False)
@config_app.command("set")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index 00b53d0843..850db35f6a 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -54,6 +54,24 @@ def test_config_list_shows_recorded_skills_layout(tmp_path, monkeypatch):
assert "True" in result.output
+@pytest.mark.parametrize(
+ "command", [["config", "get", "team-ai-directives"], ["config", "list"]]
+)
+def test_config_displays_literal_brackets_in_saved_values(tmp_path, monkeypatch, command):
+ project = _project(tmp_path)
+ saved_path = "/tmp/team-[directives]"
+ save_init_options(
+ project,
+ {**load_init_options(project), "team_ai_directives": saved_path},
+ )
+ monkeypatch.chdir(project)
+
+ result = runner.invoke(app, command)
+
+ assert result.exit_code == 0, result.output
+ assert saved_path in result.output
+
+
def test_config_set_script_rejects_metadata_only_change(tmp_path, monkeypatch):
project = _project(tmp_path)
monkeypatch.chdir(project)
From f24b03d0a03805ed9a57a6495c2cceb7a91dfb8c Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Tue, 8 Sep 2026 20:44:48 +0300
Subject: [PATCH 10/15] fixed lifecycle bugs
---
docs/reference/configuration.md | 8 +--
src/specify_cli/_init_fork.py | 63 +++++++++++++++-------
src/specify_cli/commands/config.py | 87 +++++++++++++++++++++++++++---
tests/test_config_cli.py | 28 ++++++++++
tests/test_init_fork.py | 27 ++++++++++
5 files changed, 182 insertions(+), 31 deletions(-)
create mode 100644 tests/test_init_fork.py
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index d8ca4e9eb5..32123a0c97 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -68,13 +68,15 @@ installed without replacing an existing one, merges its `.mcp.json` when
present, and installs any source-declared default skills that are not already
present.
Unsetting it removes the governance extension and the saved source setting.
-Copied team skills are intentionally left in the active agent's skills
-directory for manual review.
+MCP entries that the source added are removed if they remain unchanged; entries
+that a user modified are preserved. Copied team skills are intentionally left
+in the active agent's skills directory for manual review.
Installation is not transactional. If synchronization, MCP configuration, or
skill installation fails, the command exits with an error and leaves the
previous saved source unchanged. The extension and some skills may already
-have been installed.
+have been installed. Downloaded archive sources keep their previous cached
+copy until the replacement archive extracts successfully.
Inspect and repair incomplete skill files, correct the reported cause, and
retry the same `config set team-ai-directives` command. Existing skill files
are skipped, even if a failed copy left them incomplete; retry alone does not
diff --git a/src/specify_cli/_init_fork.py b/src/specify_cli/_init_fork.py
index f7db81f03e..f33df11107 100644
--- a/src/specify_cli/_init_fork.py
+++ b/src/specify_cli/_init_fork.py
@@ -535,6 +535,48 @@ def _install_taskstoissues_config(project_root: Path) -> None:
pass
+def _replace_cached_team_directives_archive(zip_path: Path, download_dir: Path) -> Path:
+ """Extract an archive without discarding the previously cached knowledge base."""
+ import zipfile
+
+ extract_dir = download_dir / "team-ai-directives-kb-extracted"
+ staging_dir = download_dir / "team-ai-directives-kb-staging"
+ backup_dir = download_dir / "team-ai-directives-kb-previous"
+ if staging_dir.exists():
+ shutil.rmtree(staging_dir)
+ if backup_dir.exists():
+ shutil.rmtree(backup_dir)
+ staging_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ with zipfile.ZipFile(zip_path, "r") as archive:
+ archive.extractall(staging_dir)
+
+ knowledge_base = staging_dir
+ entries = [entry for entry in knowledge_base.iterdir() if entry.is_dir()]
+ if len(entries) == 1 and not (knowledge_base / "context_modules").exists():
+ subdir = entries[0]
+ if (subdir / "context_modules").exists() or (subdir / ".skills.json").exists():
+ knowledge_base = subdir
+
+ relative_path = knowledge_base.relative_to(staging_dir)
+ if extract_dir.exists():
+ extract_dir.replace(backup_dir)
+ try:
+ staging_dir.replace(extract_dir)
+ except Exception:
+ if backup_dir.exists():
+ backup_dir.replace(extract_dir)
+ raise
+ if backup_dir.exists():
+ shutil.rmtree(backup_dir)
+ return extract_dir / relative_path
+ except Exception:
+ if staging_dir.exists():
+ shutil.rmtree(staging_dir)
+ raise
+
+
def sync_team_ai_directives(
repo_url: str, project_root: Path, *, force: bool = False
) -> tuple[str, Path]:
@@ -656,26 +698,7 @@ def sync_team_ai_directives(
f"Downloaded file is not a valid ZIP archive: {repo_url}"
)
- import zipfile
-
- extract_dir = download_dir / "team-ai-directives-kb-extracted"
- if extract_dir.exists():
- shutil.rmtree(extract_dir)
- extract_dir.mkdir(parents=True, exist_ok=True)
-
- with zipfile.ZipFile(zip_path, 'r') as zf:
- zf.extractall(extract_dir)
-
- # Find the actual content directory
- kb_path = extract_dir
- entries = [e for e in kb_path.iterdir() if e.is_dir()]
- if len(entries) == 1 and not (kb_path / "context_modules").exists():
- subdir = entries[0]
- if (
- (subdir / "context_modules").exists()
- or (subdir / ".skills.json").exists()
- ):
- kb_path = subdir
+ kb_path = _replace_cached_team_directives_archive(zip_path, download_dir)
_update_agent_context(project_root)
return ("installed", kb_path)
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index 27141e60cd..15266b009d 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -48,6 +48,8 @@ def make_typer(*, name: str | None = None, help: str | None = None, **kwargs):
"team-ai-directives": "team_ai_directives",
}
_FEATURE_NUMBERING = {"sequential", "timestamp"}
+_TEAM_DIRECTIVES_MCP_KEY = "team_ai_directives_mcp"
+_MCP_ENTRY_SECTIONS = ("mcpServers", "tools")
def _require_specify_project():
@@ -66,6 +68,60 @@ def _display_value(value: Any) -> str:
return str(value)
+def _read_mcp_config(project_root) -> dict[str, Any]:
+ mcp_path = project_root / ".mcp.json"
+ if not mcp_path.exists():
+ return {}
+ content = json.loads(mcp_path.read_text())
+ if not isinstance(content, dict):
+ raise ValueError("Project .mcp.json must contain a JSON object")
+ return content
+
+
+def _mcp_entries_added(
+ before: dict[str, Any], after: dict[str, Any]
+) -> dict[str, dict[str, Any]]:
+ additions = {}
+ for section in _MCP_ENTRY_SECTIONS:
+ before_entries = before.get(section)
+ after_entries = after.get(section)
+ if not isinstance(before_entries, dict):
+ before_entries = {}
+ if not isinstance(after_entries, dict):
+ continue
+ added = {
+ name: value
+ for name, value in after_entries.items()
+ if name not in before_entries
+ }
+ if added:
+ additions[section] = added
+ return additions
+
+
+def _remove_owned_mcp_entries(project_root, owned_entries: Any) -> None:
+ if not isinstance(owned_entries, dict):
+ return
+ mcp_path = project_root / ".mcp.json"
+ if not mcp_path.exists():
+ return
+ config = _read_mcp_config(project_root)
+ changed = False
+ for section in _MCP_ENTRY_SECTIONS:
+ expected_entries = owned_entries.get(section)
+ current_entries = config.get(section)
+ if not isinstance(expected_entries, dict) or not isinstance(current_entries, dict):
+ continue
+ for name, expected_value in expected_entries.items():
+ if current_entries.get(name) == expected_value:
+ del current_entries[name]
+ changed = True
+ if not current_entries:
+ config.pop(section, None)
+ if changed:
+ mcp_path.write_text(json.dumps(config, indent=2))
+
+
def _print_extensions(project_root) -> None:
installed = ExtensionManager(project_root).list_installed()
if not installed:
@@ -171,8 +227,16 @@ def config_set(
phase = "synchronization"
try:
_, directives_path = sync_team_ai_directives(value, project_root, force=False)
+ phase = "skill installation"
+ _install_skills_from_path(
+ team_directives_path=directives_path,
+ project_path=project_root,
+ selected_ai=selected_ai,
+ force=False,
+ )
if (directives_path / ".mcp.json").exists():
phase = "MCP configuration"
+ before_mcp = _read_mcp_config(project_root)
mcp_installed, mcp_messages, _, _ = install_mcp_config(
directives_path, project_root
)
@@ -181,13 +245,14 @@ def config_set(
"\n".join(mcp_messages)
or "Failed to install MCP configuration"
)
- phase = "skill installation"
- _install_skills_from_path(
- team_directives_path=directives_path,
- project_path=project_root,
- selected_ai=selected_ai,
- force=False,
- )
+ additions = _mcp_entries_added(before_mcp, _read_mcp_config(project_root))
+ existing_entries = options.get(_TEAM_DIRECTIVES_MCP_KEY, {})
+ if not isinstance(existing_entries, dict):
+ existing_entries = {}
+ for section, entries in additions.items():
+ existing_entries.setdefault(section, {}).update(entries)
+ if existing_entries:
+ options[_TEAM_DIRECTIVES_MCP_KEY] = existing_entries
except Exception as exc:
console.print(f"Team AI directives {phase} failed: {exc}", markup=False)
console.print(
@@ -213,10 +278,16 @@ def config_unset(key: str = typer.Argument(help="Configuration key")) -> None:
project_root = _require_specify_project()
options = load_init_options(project_root)
+ try:
+ _remove_owned_mcp_entries(project_root, options.get(_TEAM_DIRECTIVES_MCP_KEY))
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
+ console.print(f"Unable to clean team MCP configuration: {exc}", markup=False)
+ raise typer.Exit(1) from None
removed = ExtensionManager(project_root).remove("team-ai-directives")
had_source = "team_ai_directives" in options
- if had_source:
+ if had_source or _TEAM_DIRECTIVES_MCP_KEY in options:
options.pop("team_ai_directives")
+ options.pop(_TEAM_DIRECTIVES_MCP_KEY, None)
save_init_options(project_root, options)
if removed:
console.print("Removed team-ai-directives extension and configuration.")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index 850db35f6a..c4848f50d6 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -238,6 +238,34 @@ def install_mcp(team_path, project_root):
assert (project / ".mcp.json").read_text() == '{"mcpServers": {"team": {}}}'
+def test_config_unset_team_directives_removes_owned_mcp_entries(tmp_path, monkeypatch):
+ """Unsetting directives must preserve user-owned MCP configuration."""
+ project = _project(tmp_path)
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ (source / ".mcp.json").write_text(
+ '{"mcpServers": {"team": {"command": "team-server"}}}'
+ )
+ (project / ".mcp.json").write_text(
+ '{"mcpServers": {"user": {"command": "user-server"}}}'
+ )
+ monkeypatch.chdir(project)
+
+ def sync(value, project_root, *, force):
+ return "local", source
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+ monkeypatch.setattr(config, "_install_skills_from_path", lambda **kwargs: [])
+
+ assert runner.invoke(
+ app, ["config", "set", "team-ai-directives", str(source)]
+ ).exit_code == 0
+ result = runner.invoke(app, ["config", "unset", "team-ai-directives"])
+
+ assert result.exit_code == 0, result.output
+ assert (project / ".mcp.json").read_text() == '{\n "mcpServers": {\n "user": {\n "command": "user-server"\n }\n }\n}'
+
+
def test_config_set_team_directives_preserves_source_when_mcp_install_fails(
tmp_path, monkeypatch
):
diff --git a/tests/test_init_fork.py b/tests/test_init_fork.py
new file mode 100644
index 0000000000..a09e8b5971
--- /dev/null
+++ b/tests/test_init_fork.py
@@ -0,0 +1,27 @@
+"""Regression tests for fork-specific initialization helpers."""
+
+from __future__ import annotations
+
+import zipfile
+
+import pytest
+
+from specify_cli import _init_fork
+
+
+def test_replace_cached_team_directives_archive_preserves_previous_cache_on_failure(
+ tmp_path,
+):
+ """A malformed replacement archive must not discard the saved knowledge base."""
+ downloads = tmp_path / "downloads"
+ downloads.mkdir()
+ previous = downloads / "team-ai-directives-kb-extracted"
+ previous.mkdir()
+ (previous / "CDR.md").write_text("previous directives")
+ archive = downloads / "team-ai-directives-kb.zip"
+ archive.write_bytes(b"PKnot-a-valid-zip")
+
+ with pytest.raises(zipfile.BadZipFile):
+ _init_fork._replace_cached_team_directives_archive(archive, downloads)
+
+ assert (previous / "CDR.md").read_text() == "previous directives"
From 224cffbd9f6f79dd7f17369b5fb4ddf398cbdcbd Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Tue, 8 Sep 2026 22:57:10 +0300
Subject: [PATCH 11/15] next round
---
src/specify_cli/_core_fork.py | 23 ++++++++
src/specify_cli/_init_fork.py | 52 ++++++++++++++---
src/specify_cli/commands/config.py | 74 ++++++++++++------------
src/specify_cli/commands/init.py | 6 ++
tests/test_config_cli.py | 90 ++++++++++++++++++++++++++++--
tests/test_init_fork.py | 63 +++++++++++++++++++++
6 files changed, 260 insertions(+), 48 deletions(-)
diff --git a/src/specify_cli/_core_fork.py b/src/specify_cli/_core_fork.py
index b8d186924a..aa0e1d2641 100644
--- a/src/specify_cli/_core_fork.py
+++ b/src/specify_cli/_core_fork.py
@@ -53,6 +53,7 @@
FORK_COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/tikalk/agentic-sdlc-spec-kit/main/extensions/catalog.community.json"
)
+MCP_ENTRY_SECTIONS = ("mcpServers", "tools")
def build_alias_map(project_root: Path) -> dict[str, str]:
@@ -402,6 +403,28 @@ def merge_mcp_configs_report_conflicts(existing: dict, incoming: dict) -> tuple[
return merged, conflicts
+def mcp_entries_added(
+ before: dict[str, Any], after: dict[str, Any]
+) -> dict[str, dict[str, Any]]:
+ """Return MCP entries introduced by one merge without claiming existing entries."""
+ additions = {}
+ for section in MCP_ENTRY_SECTIONS:
+ before_entries = before.get(section)
+ after_entries = after.get(section)
+ if not isinstance(before_entries, dict):
+ before_entries = {}
+ if not isinstance(after_entries, dict):
+ continue
+ added = {
+ name: value
+ for name, value in after_entries.items()
+ if name not in before_entries
+ }
+ if added:
+ additions[section] = added
+ return additions
+
+
def install_mcp_config(team_path: Path, project_root: Path) -> tuple[bool, list[str], list[str], list[str]]:
"""Install .mcp.json from team-ai-directives to project root.
diff --git a/src/specify_cli/_init_fork.py b/src/specify_cli/_init_fork.py
index f33df11107..2534c70ba9 100644
--- a/src/specify_cli/_init_fork.py
+++ b/src/specify_cli/_init_fork.py
@@ -43,6 +43,7 @@
from ._core_fork import (
compute_skill_output_name,
install_mcp_config,
+ mcp_entries_added,
)
from .extensions import ExtensionManager
@@ -232,6 +233,7 @@ def get_speckit_version() -> str:
# Directory name for team directives repository
TEAM_DIRECTIVES_DIRNAME = "team-ai-directives"
+TEAM_DIRECTIVES_MCP_KEY = "team_ai_directives_mcp"
# ============================================================================
@@ -568,8 +570,6 @@ def _replace_cached_team_directives_archive(zip_path: Path, download_dir: Path)
if backup_dir.exists():
backup_dir.replace(extract_dir)
raise
- if backup_dir.exists():
- shutil.rmtree(backup_dir)
return extract_dir / relative_path
except Exception:
if staging_dir.exists():
@@ -577,8 +577,30 @@ def _replace_cached_team_directives_archive(zip_path: Path, download_dir: Path)
raise
+def _restore_cached_team_directives_archive(download_dir: Path) -> None:
+ """Restore the prior archive cache after post-extraction setup fails."""
+ extract_dir = download_dir / "team-ai-directives-kb-extracted"
+ backup_dir = download_dir / "team-ai-directives-kb-previous"
+ if not backup_dir.exists():
+ return
+ if extract_dir.exists():
+ shutil.rmtree(extract_dir)
+ backup_dir.replace(extract_dir)
+
+
+def _discard_cached_team_directives_backup(download_dir: Path) -> None:
+ """Discard the prior archive cache after the replacement is fully configured."""
+ backup_dir = download_dir / "team-ai-directives-kb-previous"
+ if backup_dir.exists():
+ shutil.rmtree(backup_dir)
+
+
def sync_team_ai_directives(
- repo_url: str, project_root: Path, *, force: bool = False
+ repo_url: str,
+ project_root: Path,
+ *,
+ force: bool = False,
+ preserve_previous_cache: bool = False,
) -> tuple[str, Path]:
"""Install bundled team-ai-directives extension and resolve knowledge base path.
@@ -698,10 +720,16 @@ def sync_team_ai_directives(
f"Downloaded file is not a valid ZIP archive: {repo_url}"
)
- kb_path = _replace_cached_team_directives_archive(zip_path, download_dir)
-
- _update_agent_context(project_root)
- return ("installed", kb_path)
+ try:
+ kb_path = _replace_cached_team_directives_archive(zip_path, download_dir)
+ _update_agent_context(project_root)
+ if not preserve_previous_cache:
+ _discard_cached_team_directives_backup(download_dir)
+ return ("installed", kb_path)
+ except Exception:
+ if not preserve_previous_cache:
+ _restore_cached_team_directives_archive(download_dir)
+ raise
finally:
if zip_path.exists():
zip_path.unlink()
@@ -767,6 +795,7 @@ def pre_init(
tracker.start("team-directives")
directives_path: Path | None = None
+ owned_mcp_entries: dict[str, dict[str, Any]] = {}
try:
# Install bundled extension and resolve knowledge base path
@@ -780,6 +809,10 @@ def pre_init(
if tracker:
tracker.start("team-mcp")
try:
+ mcp_path = project_path / ".mcp.json"
+ before_mcp = (
+ json.loads(mcp_path.read_text()) if mcp_path.exists() else {}
+ )
success, messages, resolved, unresolved = install_mcp_config(
directives_path, project_path
)
@@ -831,6 +864,9 @@ def pre_init(
status_msg = ", ".join(status_parts) if status_parts else "installed"
if success:
+ after_mcp = json.loads(mcp_path.read_text()) if mcp_path.exists() else {}
+ if isinstance(before_mcp, dict) and isinstance(after_mcp, dict):
+ owned_mcp_entries = mcp_entries_added(before_mcp, after_mcp)
if tracker:
tracker.complete("team-mcp", status_msg)
@@ -928,6 +964,8 @@ def pre_init(
init_opts = load_init_options(project_path)
init_opts["team_ai_directives"] = str(directives_path)
+ if owned_mcp_entries:
+ init_opts[TEAM_DIRECTIVES_MCP_KEY] = owned_mcp_entries
save_init_options(project_path, init_opts)
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index 15266b009d..fc7af76de9 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -10,15 +10,22 @@
from rich.text import Text
from .._console import console
-from .._core_fork import install_mcp_config
+from .._core_fork import MCP_ENTRY_SECTIONS, install_mcp_config, mcp_entries_added
from .._init_options import load_init_options, save_init_options
from ..extensions import ExtensionManager
from ..extensions._commands import extension_app
try:
- from .._init_fork import _install_skills_from_path, sync_team_ai_directives
+ from .._init_fork import (
+ _discard_cached_team_directives_backup,
+ _install_skills_from_path,
+ _restore_cached_team_directives_archive,
+ sync_team_ai_directives,
+ )
except ImportError:
+ _discard_cached_team_directives_backup = None
_install_skills_from_path = None
+ _restore_cached_team_directives_archive = None
sync_team_ai_directives = None
try:
@@ -49,7 +56,6 @@ def make_typer(*, name: str | None = None, help: str | None = None, **kwargs):
}
_FEATURE_NUMBERING = {"sequential", "timestamp"}
_TEAM_DIRECTIVES_MCP_KEY = "team_ai_directives_mcp"
-_MCP_ENTRY_SECTIONS = ("mcpServers", "tools")
def _require_specify_project():
@@ -78,27 +84,6 @@ def _read_mcp_config(project_root) -> dict[str, Any]:
return content
-def _mcp_entries_added(
- before: dict[str, Any], after: dict[str, Any]
-) -> dict[str, dict[str, Any]]:
- additions = {}
- for section in _MCP_ENTRY_SECTIONS:
- before_entries = before.get(section)
- after_entries = after.get(section)
- if not isinstance(before_entries, dict):
- before_entries = {}
- if not isinstance(after_entries, dict):
- continue
- added = {
- name: value
- for name, value in after_entries.items()
- if name not in before_entries
- }
- if added:
- additions[section] = added
- return additions
-
-
def _remove_owned_mcp_entries(project_root, owned_entries: Any) -> None:
if not isinstance(owned_entries, dict):
return
@@ -107,7 +92,7 @@ def _remove_owned_mcp_entries(project_root, owned_entries: Any) -> None:
return
config = _read_mcp_config(project_root)
changed = False
- for section in _MCP_ENTRY_SECTIONS:
+ for section in MCP_ENTRY_SECTIONS:
expected_entries = owned_entries.get(section)
current_entries = config.get(section)
if not isinstance(expected_entries, dict) or not isinstance(current_entries, dict):
@@ -225,8 +210,15 @@ def config_set(
"team-ai-directives requires an active integration; run specify integration use first"
)
phase = "synchronization"
+ mcp_path = project_root / ".mcp.json"
+ previous_mcp = mcp_path.read_bytes() if mcp_path.exists() else None
+ previous_owned_entries = options.get(_TEAM_DIRECTIVES_MCP_KEY)
+ mcp_reconciled = False
+ download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
try:
- _, directives_path = sync_team_ai_directives(value, project_root, force=False)
+ _, directives_path = sync_team_ai_directives(
+ value, project_root, force=False, preserve_previous_cache=True
+ )
phase = "skill installation"
_install_skills_from_path(
team_directives_path=directives_path,
@@ -234,8 +226,11 @@ def config_set(
selected_ai=selected_ai,
force=False,
)
+ phase = "MCP configuration"
+ _remove_owned_mcp_entries(project_root, previous_owned_entries)
+ mcp_reconciled = True
+ additions = {}
if (directives_path / ".mcp.json").exists():
- phase = "MCP configuration"
before_mcp = _read_mcp_config(project_root)
mcp_installed, mcp_messages, _, _ = install_mcp_config(
directives_path, project_root
@@ -245,15 +240,19 @@ def config_set(
"\n".join(mcp_messages)
or "Failed to install MCP configuration"
)
- additions = _mcp_entries_added(before_mcp, _read_mcp_config(project_root))
- existing_entries = options.get(_TEAM_DIRECTIVES_MCP_KEY, {})
- if not isinstance(existing_entries, dict):
- existing_entries = {}
- for section, entries in additions.items():
- existing_entries.setdefault(section, {}).update(entries)
- if existing_entries:
- options[_TEAM_DIRECTIVES_MCP_KEY] = existing_entries
+ additions = mcp_entries_added(before_mcp, _read_mcp_config(project_root))
+ if additions:
+ options[_TEAM_DIRECTIVES_MCP_KEY] = additions
+ else:
+ options.pop(_TEAM_DIRECTIVES_MCP_KEY, None)
except Exception as exc:
+ if mcp_reconciled:
+ if previous_mcp is None:
+ mcp_path.unlink(missing_ok=True)
+ else:
+ mcp_path.write_bytes(previous_mcp)
+ if _restore_cached_team_directives_archive is not None:
+ _restore_cached_team_directives_archive(download_dir)
console.print(f"Team AI directives {phase} failed: {exc}", markup=False)
console.print(
"Partial extension or skills files may remain. Inspect and repair incomplete "
@@ -262,6 +261,11 @@ def config_set(
)
raise typer.Exit(1) from None
options["team_ai_directives"] = str(directives_path.resolve())
+ save_init_options(project_root, options)
+ if _discard_cached_team_directives_backup is not None:
+ _discard_cached_team_directives_backup(download_dir)
+ console.print(f"Updated {normalized_key}")
+ return
else:
raise typer.BadParameter(f"Unknown configuration key: {key}")
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py
index f58b20e045..59e17b40d0 100644
--- a/src/specify_cli/commands/init.py
+++ b/src/specify_cli/commands/init.py
@@ -857,12 +857,18 @@ def init(
"speckit_version": get_speckit_version(),
}
if _FORK:
+ from .._init_options import load_init_options
+
+ pre_init_options = load_init_options(project_path)
+ owned_mcp_entries = pre_init_options.get("team_ai_directives_mcp")
if team_ai_directives:
from pathlib import Path as _Path
_td_path = _Path(team_ai_directives).expanduser()
if _td_path.exists():
team_ai_directives = str(_td_path.resolve())
init_opts["team_ai_directives"] = team_ai_directives
+ if isinstance(owned_mcp_entries, dict):
+ init_opts["team_ai_directives_mcp"] = owned_mcp_entries
if resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
):
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index c4848f50d6..e96605a85f 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
from pathlib import Path
import pytest
@@ -174,7 +175,7 @@ def test_config_set_team_directives_saves_resolved_source_and_skills(
monkeypatch.chdir(project)
calls = []
- def sync(source, project_root, *, force):
+ def sync(source, project_root, *, force, preserve_previous_cache=False):
calls.append(("sync", source, project_root, force))
return "local", Path("/resolved/team-directives")
@@ -214,7 +215,7 @@ def test_config_set_team_directives_installs_mcp_configuration(tmp_path, monkeyp
(source / ".mcp.json").write_text('{"mcpServers": {"team": {}}}')
monkeypatch.chdir(project)
- def sync(value, project_root, *, force):
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
return "local", source
def install_skills(**kwargs):
@@ -251,7 +252,7 @@ def test_config_unset_team_directives_removes_owned_mcp_entries(tmp_path, monkey
)
monkeypatch.chdir(project)
- def sync(value, project_root, *, force):
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
return "local", source
monkeypatch.setattr(config, "sync_team_ai_directives", sync)
@@ -266,6 +267,44 @@ def sync(value, project_root, *, force):
assert (project / ".mcp.json").read_text() == '{\n "mcpServers": {\n "user": {\n "command": "user-server"\n }\n }\n}'
+def test_config_set_team_directives_replaces_owned_mcp_entries_when_new_source_has_none(
+ tmp_path, monkeypatch
+):
+ """Changing source must not leave the prior source's MCP server enabled."""
+ project = _project(tmp_path)
+ source = tmp_path / "knowledge-base-without-mcp"
+ source.mkdir()
+ save_init_options(
+ project,
+ {
+ **load_init_options(project),
+ "team_ai_directives": "/old/source",
+ "team_ai_directives_mcp": {
+ "mcpServers": {"team": {"command": "old-server"}}
+ },
+ },
+ )
+ (project / ".mcp.json").write_text(
+ '{"mcpServers": {"team": {"command": "old-server"}}}'
+ )
+ monkeypatch.chdir(project)
+
+ monkeypatch.setattr(
+ config,
+ "sync_team_ai_directives",
+ lambda value, project_root, *, force, preserve_previous_cache=False: ("local", source),
+ )
+ monkeypatch.setattr(config, "_install_skills_from_path", lambda **kwargs: [])
+
+ result = runner.invoke(
+ app, ["config", "set", "team-ai-directives", str(source)]
+ )
+
+ assert result.exit_code == 0, result.output
+ assert json.loads((project / ".mcp.json").read_text()) == {}
+ assert "team_ai_directives_mcp" not in load_init_options(project)
+
+
def test_config_set_team_directives_preserves_source_when_mcp_install_fails(
tmp_path, monkeypatch
):
@@ -280,7 +319,7 @@ def test_config_set_team_directives_preserves_source_when_mcp_install_fails(
(source / ".mcp.json").write_text("{")
monkeypatch.chdir(project)
- def sync(value, project_root, *, force):
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
return "local", source
monkeypatch.setattr(config, "sync_team_ai_directives", sync)
@@ -295,6 +334,45 @@ def sync(value, project_root, *, force):
assert load_init_options(project)["team_ai_directives"] == "/previous/source"
+def test_config_set_team_directives_restores_archive_cache_when_skills_fail(
+ tmp_path, monkeypatch
+):
+ """A failed replacement must not repoint the previously saved cached source."""
+ project = _project(tmp_path)
+ save_init_options(
+ project,
+ {**load_init_options(project), "team_ai_directives": "/previous/source"},
+ )
+ downloads = project / ".specify" / "extensions" / ".cache" / "downloads"
+ current = downloads / "team-ai-directives-kb-extracted"
+ backup = downloads / "team-ai-directives-kb-previous"
+ current.mkdir(parents=True)
+ (current / "CDR.md").write_text("previous directives")
+ replacement = tmp_path / "replacement"
+ replacement.mkdir()
+ monkeypatch.chdir(project)
+
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
+ current.replace(backup)
+ current.mkdir()
+ (current / "CDR.md").write_text("replacement directives")
+ return "installed", replacement
+
+ def install_skills(**kwargs):
+ raise OSError("copy denied")
+
+ monkeypatch.setattr(config, "sync_team_ai_directives", sync)
+ monkeypatch.setattr(config, "_install_skills_from_path", install_skills)
+
+ result = runner.invoke(
+ app, ["config", "set", "team-ai-directives", "https://example.com/new.zip"]
+ )
+
+ assert result.exit_code == 1, result.output
+ assert (current / "CDR.md").read_text() == "previous directives"
+ assert not backup.exists()
+
+
def test_config_set_team_directives_persists_an_absolute_source_path(
tmp_path, monkeypatch
):
@@ -302,7 +380,7 @@ def test_config_set_team_directives_persists_an_absolute_source_path(
project = _project(tmp_path)
monkeypatch.chdir(project)
- def sync(value, project_root, *, force):
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
return "local", Path("knowledge-base")
def install_skills(**kwargs):
@@ -397,7 +475,7 @@ def test_config_team_directives_failure_preserves_source_and_allows_retry(
partial_extension = project / ".specify" / "extensions" / "team-ai-directives"
failing = True
- def sync(value, project_root, *, force):
+ def sync(value, project_root, *, force, preserve_previous_cache=False):
partial_extension.mkdir(parents=True, exist_ok=True)
if failing and phase == "synchronization":
raise ValueError("source unavailable")
diff --git a/tests/test_init_fork.py b/tests/test_init_fork.py
index a09e8b5971..585ef64802 100644
--- a/tests/test_init_fork.py
+++ b/tests/test_init_fork.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+import json
import zipfile
import pytest
+from specify_cli._init_options import load_init_options
from specify_cli import _init_fork
@@ -25,3 +27,64 @@ def test_replace_cached_team_directives_archive_preserves_previous_cache_on_fail
_init_fork._replace_cached_team_directives_archive(archive, downloads)
assert (previous / "CDR.md").read_text() == "previous directives"
+
+
+def test_restore_cached_team_directives_archive_restores_previous_cache(tmp_path):
+ """Post-extraction setup failures must be able to restore the prior cache."""
+ downloads = tmp_path / "downloads"
+ downloads.mkdir()
+ current = downloads / "team-ai-directives-kb-extracted"
+ backup = downloads / "team-ai-directives-kb-previous"
+ current.mkdir()
+ backup.mkdir()
+ (current / "CDR.md").write_text("replacement directives")
+ (backup / "CDR.md").write_text("previous directives")
+
+ _init_fork._restore_cached_team_directives_archive(downloads)
+
+ assert (current / "CDR.md").read_text() == "previous directives"
+ assert not backup.exists()
+
+
+def test_pre_init_records_mcp_entries_for_later_cleanup(tmp_path, monkeypatch):
+ """MCP entries installed during init need the same ownership metadata as config set."""
+ project = tmp_path / "project"
+ (project / ".specify").mkdir(parents=True)
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ (source / ".mcp.json").write_text('{"mcpServers": {"team": {}}}')
+
+ class Tracker:
+ def add(self, *args):
+ pass
+
+ def start(self, *args):
+ pass
+
+ def complete(self, *args):
+ pass
+
+ def skip(self, *args):
+ pass
+
+ def error(self, *args):
+ pass
+
+ monkeypatch.setattr(
+ _init_fork,
+ "sync_team_ai_directives",
+ lambda value, project_root, *, force: ("local", source),
+ )
+ monkeypatch.setattr(_init_fork, "_install_skills_from_path", lambda **kwargs: [])
+
+ def install_mcp(team_path, project_root):
+ (project_root / ".mcp.json").write_text('{"mcpServers": {"team": {}}}')
+ return True, [], [], []
+
+ monkeypatch.setattr(_init_fork, "install_mcp_config", install_mcp)
+
+ _init_fork.pre_init(project, "codex", str(source), tracker=Tracker())
+
+ assert load_init_options(project)["team_ai_directives_mcp"] == {
+ "mcpServers": {"team": {}}
+ }
From f0b3afb4bb97514ad03e788fef9767f17ae91fd9 Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Tue, 8 Sep 2026 23:22:56 +0300
Subject: [PATCH 12/15] context lifecycle fix
---
docs/reference/configuration.md | 3 ++
src/specify_cli/_init_fork.py | 2 -
src/specify_cli/commands/config.py | 6 +++
tests/test_config_cli.py | 75 ++++++++++++++++++++++++++++++
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 32123a0c97..3475456d02 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -71,6 +71,9 @@ Unsetting it removes the governance extension and the saved source setting.
MCP entries that the source added are removed if they remain unchanged; entries
that a user modified are preserved. Copied team skills are intentionally left
in the active agent's skills directory for manual review.
+When the `agent-context` extension is installed, its managed context section
+refreshes after a source is set or unset so that team directives match the
+saved configuration.
Installation is not transactional. If synchronization, MCP configuration, or
skill installation fails, the command exits with an error and leaves the
diff --git a/src/specify_cli/_init_fork.py b/src/specify_cli/_init_fork.py
index 2534c70ba9..d60736de34 100644
--- a/src/specify_cli/_init_fork.py
+++ b/src/specify_cli/_init_fork.py
@@ -671,7 +671,6 @@ def sync_team_ai_directives(
f"Invalid team-ai-directives knowledge base: {potential_path}\n"
f"Missing expected content (context_modules/, .skills.json, or CDR.md)"
)
- _update_agent_context(project_root)
return ("local", potential_path)
if repo_url.endswith(".zip") or "/archive/" in repo_url:
@@ -722,7 +721,6 @@ def sync_team_ai_directives(
try:
kb_path = _replace_cached_team_directives_archive(zip_path, download_dir)
- _update_agent_context(project_root)
if not preserve_previous_cache:
_discard_cached_team_directives_backup(download_dir)
return ("installed", kb_path)
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index fc7af76de9..ecb3546a95 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -20,12 +20,14 @@
_discard_cached_team_directives_backup,
_install_skills_from_path,
_restore_cached_team_directives_archive,
+ _update_agent_context,
sync_team_ai_directives,
)
except ImportError:
_discard_cached_team_directives_backup = None
_install_skills_from_path = None
_restore_cached_team_directives_archive = None
+ _update_agent_context = None
sync_team_ai_directives = None
try:
@@ -262,6 +264,8 @@ def config_set(
raise typer.Exit(1) from None
options["team_ai_directives"] = str(directives_path.resolve())
save_init_options(project_root, options)
+ if _update_agent_context is not None:
+ _update_agent_context(project_root)
if _discard_cached_team_directives_backup is not None:
_discard_cached_team_directives_backup(download_dir)
console.print(f"Updated {normalized_key}")
@@ -300,6 +304,8 @@ def config_unset(key: str = typer.Argument(help="Configuration key")) -> None:
else:
console.print("Nothing to unset: no team-ai-directives extension or saved source.")
if removed or had_source:
+ if _update_agent_context is not None:
+ _update_agent_context(project_root)
console.print("Copied team skills remain for manual review in the active agent skills directory.")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index e96605a85f..19c34f7524 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -207,6 +207,42 @@ def install_skills(**kwargs):
]
+def test_config_set_team_directives_refreshes_context_after_saving_source(
+ tmp_path, monkeypatch
+):
+ """The context refresh must observe the newly persisted source."""
+ project = _project(tmp_path)
+ source = tmp_path / "knowledge-base"
+ source.mkdir()
+ monkeypatch.chdir(project)
+ observed_sources = []
+
+ monkeypatch.setattr(
+ config,
+ "sync_team_ai_directives",
+ lambda value, project_root, *, force, preserve_previous_cache=False: (
+ "local",
+ source,
+ ),
+ )
+ monkeypatch.setattr(config, "_install_skills_from_path", lambda **kwargs: [])
+ monkeypatch.setattr(
+ config,
+ "_update_agent_context",
+ lambda project_root: observed_sources.append(
+ load_init_options(project_root).get("team_ai_directives")
+ ),
+ raising=False,
+ )
+
+ result = runner.invoke(
+ app, ["config", "set", "team-ai-directives", str(source)]
+ )
+
+ assert result.exit_code == 0, result.output
+ assert observed_sources == [str(source.resolve())]
+
+
def test_config_set_team_directives_installs_mcp_configuration(tmp_path, monkeypatch):
"""A post-init directives source must apply its MCP configuration."""
project = _project(tmp_path)
@@ -432,6 +468,45 @@ def remove(self, extension_id):
assert "copied team skills" in result.output.lower()
+def test_config_unset_team_directives_refreshes_context_after_removing_source(
+ tmp_path, monkeypatch
+):
+ """The context refresh must not retain an unset directives source."""
+ project = _project(tmp_path)
+ save_init_options(
+ project,
+ {
+ **load_init_options(project),
+ "team_ai_directives": "/resolved/team-directives",
+ },
+ )
+ monkeypatch.chdir(project)
+ observed_sources = []
+
+ class FakeManager:
+ def __init__(self, project_root):
+ assert project_root == project
+
+ def remove(self, extension_id):
+ assert extension_id == "team-ai-directives"
+ return True
+
+ monkeypatch.setattr(config, "ExtensionManager", FakeManager)
+ monkeypatch.setattr(
+ config,
+ "_update_agent_context",
+ lambda project_root: observed_sources.append(
+ load_init_options(project_root).get("team_ai_directives")
+ ),
+ raising=False,
+ )
+
+ result = runner.invoke(app, ["config", "unset", "team-ai-directives"])
+
+ assert result.exit_code == 0, result.output
+ assert observed_sources == [None]
+
+
@pytest.mark.parametrize("saved_source", [False, True])
def test_config_unset_absent_extension_reports_actual_outcome(
tmp_path, monkeypatch, saved_source
From fb6a1527ab3872e7825655e976bbedcc04032f1e Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Fri, 11 Sep 2026 09:51:33 +0300
Subject: [PATCH 13/15] chore: clean up PR-related linting violations
---
src/specify_cli/commands/config.py | 5 +++--
tests/test_config_cli.py | 1 -
tests/test_init_fork.py | 3 +--
3 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py
index ecb3546a95..2597ed1727 100644
--- a/src/specify_cli/commands/config.py
+++ b/src/specify_cli/commands/config.py
@@ -82,7 +82,7 @@ def _read_mcp_config(project_root) -> dict[str, Any]:
return {}
content = json.loads(mcp_path.read_text())
if not isinstance(content, dict):
- raise ValueError("Project .mcp.json must contain a JSON object")
+ raise TypeError("Project .mcp.json must contain a JSON object")
return content
@@ -247,7 +247,8 @@ def config_set(
options[_TEAM_DIRECTIVES_MCP_KEY] = additions
else:
options.pop(_TEAM_DIRECTIVES_MCP_KEY, None)
- except Exception as exc:
+ # Keep the CLI boundary broad so every setup failure triggers rollback and a clean diagnostic.
+ except Exception as exc: # noqa: BLE001 — setup dependencies expose heterogeneous failure types.
if mcp_reconciled:
if previous_mcp is None:
mcp_path.unlink(missing_ok=True)
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index 19c34f7524..d658165059 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -11,7 +11,6 @@
from specify_cli import app, load_init_options, save_init_options
from specify_cli.commands import config
-
runner = CliRunner()
diff --git a/tests/test_init_fork.py b/tests/test_init_fork.py
index 585ef64802..5b624abab4 100644
--- a/tests/test_init_fork.py
+++ b/tests/test_init_fork.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-import json
import zipfile
import pytest
-from specify_cli._init_options import load_init_options
from specify_cli import _init_fork
+from specify_cli._init_options import load_init_options
def test_replace_cached_team_directives_archive_preserves_previous_cache_on_failure(
From 12708efe60bc0957939697f38b1df9ff17a4b116 Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Fri, 11 Sep 2026 10:37:09 +0300
Subject: [PATCH 14/15] add automated local verification
---
CONTRIBUTING.md | 4 +
docs/local-development.md | 82 +++--------
...erify-post-initialization-configuration.sh | 129 ++++++++++++++++++
...ost_initialization_configuration_script.py | 23 ++++
4 files changed, 173 insertions(+), 65 deletions(-)
create mode 100755 scripts/verify-post-initialization-configuration.sh
create mode 100644 tests/test_verify_post_initialization_configuration_script.py
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3d1f2f229c..c8bef7e730 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -148,6 +148,10 @@ job until a follow-up cleanup tightens the threshold.
### Manual testing
+This section covers testing slash-command behavior through a coding agent and
+reporting those results in a pull request. For post-initialization configuration,
+run the automated verifier in the [local development guide](docs/local-development.md#4-verify-post-initialization-configuration).
+
#### Testing setup
```bash
diff --git a/docs/local-development.md b/docs/local-development.md
index 2564bed208..792ef9a683 100644
--- a/docs/local-development.md
+++ b/docs/local-development.md
@@ -49,85 +49,37 @@ Re-running after code edits requires no reinstall because of editable mode.
## 4. Verify Post-Initialization Configuration
-Use a disposable project so configuration changes do not alter a real project.
-From the repository root, save the repository path and create a temporary test
-project:
+Use the automated verifier to exercise the post-initialization configuration
+workflow in a disposable Copilot project. After completing the editable install
+in the previous section, run:
```bash
-SPECIFY_SRC="$(pwd)"
-SPECIFY="$SPECIFY_SRC/.venv/bin/specify"
-TEST_ROOT="$(mktemp -d)"
-"$SPECIFY" init "$TEST_ROOT/project" \
- --integration copilot --ignore-agent-tools --script sh
-cd "$TEST_ROOT/project"
+scripts/verify-post-initialization-configuration.sh --specify "$(pwd)/.venv/bin/specify"
```
-`"$SPECIFY" ...` executes the editable `specify` console entry point from the
-current working tree. The previous section creates that environment. If you
-prefer uv to manage the environment, use `uv run --project "$SPECIFY_SRC"
-specify ...` instead.
+The script verifies configuration reads, script upgrades, mutable settings,
+persisted options, protected settings, and the bundled `git` extension
+lifecycle. It removes the temporary project when it exits. Set `SPECIFY` to an
+executable path instead of passing `--specify` if preferred.
-Run the read and mutation commands and verify each result:
-
-```bash
-"$SPECIFY" config list
-"$SPECIFY" config list --json
-"$SPECIFY" config get script
-
-"$SPECIFY" integration upgrade copilot --script py
-"$SPECIFY" config get script
-
-"$SPECIFY" config set feature-numbering timestamp
-"$SPECIFY" config get feature-numbering
-```
-
-The final two `get` commands must print `py` and `timestamp`. The corresponding
-values in `"$TEST_ROOT/project/.specify/init-options.json"` must match.
-Inspect `.github/skills/` and compare helper invocations with the selected
-templates. Core templates supporting `py` use `scripts/python/`; bundled
-preset overrides without a `py` variant can still invoke shell helpers.
-
-Verify that integration ownership is enforced:
-
-```bash
-"$SPECIFY" config set integration claude
-"$SPECIFY" config set script sh
-"$SPECIFY" config set ai-skills true
-"$SPECIFY" config set here true
-```
-
-Each command must fail without changing saved settings. Integration selection
-must point to `specify integration use`, script and layout changes to
-`specify integration upgrade`, and `here` must be identified as read-only.
-
-Verify extension delegation using the bundled `git` extension:
-
-```bash
-"$SPECIFY" config extension list
-"$SPECIFY" config extension add git
-"$SPECIFY" config extension list
-"$SPECIFY" config extension disable git
-"$SPECIFY" config extension enable git
-"$SPECIFY" config extension remove git
-```
-
-The extension must appear as installed, disabled, enabled, and then absent in
-the corresponding list output.
-
-If you have a valid team-directives source, verify its lifecycle too. Replace
-the placeholder with a local directory or supported archive URL:
+The team-directives lifecycle still requires a source you control, so verify it
+separately when applicable. From a disposable initialized project, replace the
+placeholder with a local directory or supported archive URL:
```bash
TEAM_DIRECTIVES_SOURCE="/absolute/path/to/team-ai-directives"
-"$SPECIFY" config set team-ai-directives "$TEAM_DIRECTIVES_SOURCE"
-"$SPECIFY" config get team-ai-directives
-"$SPECIFY" config unset team-ai-directives
+"$(pwd)/.venv/bin/specify" config set team-ai-directives "$TEAM_DIRECTIVES_SOURCE"
+"$(pwd)/.venv/bin/specify" config get team-ai-directives
+"$(pwd)/.venv/bin/specify" config unset team-ai-directives
```
`get` must report the resolved source, and `unset` must remove the saved source
and governance extension while warning that copied team skills remain for
manual review.
+For manual slash-command testing and its pull-request reporting template, see
+[Manual testing](../CONTRIBUTING.md#manual-testing).
+
## 5. Invoke with uvx Directly From Git (Current Branch)
`uvx` can run from a local path (or a Git ref) to simulate user flows:
diff --git a/scripts/verify-post-initialization-configuration.sh b/scripts/verify-post-initialization-configuration.sh
new file mode 100755
index 0000000000..24168bedd9
--- /dev/null
+++ b/scripts/verify-post-initialization-configuration.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+
+# Verify the post-initialization configuration workflow in a disposable project.
+set -euo pipefail
+
+SPECIFY_PATH="${SPECIFY:-}"
+TEMP_DIR=""
+
+fail() {
+ printf 'Error: %s\n' "$1" >&2
+ exit 1
+}
+
+cleanup() {
+ if [[ -n "$TEMP_DIR" ]]; then
+ rm -rf "$TEMP_DIR" || true
+ fi
+}
+
+usage() {
+ cat <<'EOF'
+Usage: verify-post-initialization-configuration.sh [--specify PATH]
+
+Verify post-initialization configuration using a disposable Copilot project.
+
+Options:
+ --specify PATH Path to the specify executable. Defaults to $SPECIFY or specify on PATH.
+ -h, --help Show this help message.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --specify)
+ [[ $# -ge 2 ]] || fail "--specify requires a path."
+ SPECIFY_PATH="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ fail "Unknown option: $1"
+ ;;
+ esac
+done
+
+if [[ -n "$SPECIFY_PATH" ]]; then
+ [[ -x "$SPECIFY_PATH" ]] || fail "Specify executable is not executable: $SPECIFY_PATH"
+ SPECIFY=("$SPECIFY_PATH")
+else
+ command -v specify >/dev/null 2>&1 || fail "Specify executable not found. Pass --specify PATH."
+ SPECIFY=(specify)
+fi
+
+command -v python3 >/dev/null 2>&1 || fail "python3 is required to inspect init-options.json."
+
+TEMP_DIR="$(mktemp -d)" || fail "Could not create a temporary directory."
+trap cleanup EXIT INT TERM
+PROJECT_DIR="$TEMP_DIR/project"
+
+run() {
+ "${SPECIFY[@]}" "$@"
+}
+
+expect_value() {
+ local expected="$1"
+ shift
+ local actual
+ actual="$(run "$@")"
+ [[ "$actual" == "$expected" ]] || fail "Expected '$expected' from 'specify $*', got '$actual'."
+}
+
+expect_failure() {
+ if run "$@" >/dev/null 2>&1; then
+ fail "Expected 'specify $*' to fail."
+ fi
+}
+
+run init "$PROJECT_DIR" --integration copilot --ignore-agent-tools --script sh
+cd "$PROJECT_DIR"
+
+run config list >/dev/null
+run config list --json >/dev/null
+expect_value sh config get script
+
+run integration upgrade copilot --script py
+expect_value py config get script
+
+run config set feature-numbering timestamp
+expect_value timestamp config get feature-numbering
+
+python3 - ".specify/init-options.json" <<'PY'
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as file:
+ options = json.load(file)
+
+expected = {"script": "py", "feature_numbering": "timestamp"}
+for key, value in expected.items():
+ if options.get(key) != value:
+ raise SystemExit(f"Expected {key}={value!r} in init-options.json, got {options.get(key)!r}.")
+PY
+
+expect_failure config set integration claude
+expect_failure config set script sh
+expect_failure config set ai-skills true
+expect_failure config set here true
+expect_value py config get script
+expect_value timestamp config get feature-numbering
+
+run config extension add git >/dev/null
+extension_list="$(run config extension list)"
+[[ "$extension_list" == *"Git Branching Workflow"* ]] || fail "Git extension was not listed after installation."
+run config extension disable git >/dev/null
+extension_list="$(run config extension list)"
+[[ "$extension_list" == *"Git Branching Workflow"* && "$extension_list" == *"Status: Disabled"* ]] || fail "Git extension was not listed as disabled."
+run config extension enable git >/dev/null
+extension_list="$(run config extension list)"
+[[ "$extension_list" == *"Git Branching Workflow"* && "$extension_list" == *"Status: Enabled"* ]] || fail "Git extension was not listed as enabled."
+run config extension remove git --force >/dev/null
+extension_list="$(run config extension list)"
+if [[ "$extension_list" == *"Git Branching Workflow"* ]]; then
+ fail "Git extension was still listed after removal."
+fi
+
+printf 'Post-initialization configuration verified.\n'
diff --git a/tests/test_verify_post_initialization_configuration_script.py b/tests/test_verify_post_initialization_configuration_script.py
new file mode 100644
index 0000000000..79bad78cc4
--- /dev/null
+++ b/tests/test_verify_post_initialization_configuration_script.py
@@ -0,0 +1,23 @@
+"""End-to-end coverage for the local post-initialization verifier."""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+SCRIPT = REPO_ROOT / "scripts" / "verify-post-initialization-configuration.sh"
+
+
+def test_verifier_checks_post_initialization_configuration() -> None:
+ """The helper validates the documented local configuration workflow."""
+ result = subprocess.run(
+ ["bash", str(SCRIPT), "--specify", str(REPO_ROOT / ".venv" / "bin" / "specify")],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "Post-initialization configuration verified." in result.stdout
From d75a1777abb7abd0f6731ebb704011a549cee6c8 Mon Sep 17 00:00:00 2001
From: therightstuff
Date: Fri, 11 Sep 2026 10:46:04 +0300
Subject: [PATCH 15/15] fix: align preset output and directive state
Retain MCP ownership only while its directives source is configured.
---
CHANGELOG.md | 13 +++++-
.../agentic-sdlc/commands/adlc.spec.plan.md | 2 +-
pyproject.toml | 2 +-
src/specify_cli/commands/init.py | 7 +--
tests/test_agent_config_consistency.py | 13 ++++++
tests/test_init_output_markup.py | 44 ++++++++++++++++++-
6 files changed, 74 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3830879ac2..2aeea77be4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to the Specify CLI and templates are documented here.
-## [1.0.4+adlc2] - 2026-09-07
+## [1.0.4+adlc3] - 2026-09-11
### Added
@@ -16,6 +16,17 @@ All notable changes to the Specify CLI and templates are documented here.
partial installation and retry guidance, and unsetting an absent extension
distinguishes saved-source cleanup from an already-unset configuration.
+### Changed
+
+- **Preset catalog validation**: A present `catalogs` value in
+ `.specify/preset-catalogs.yml` must now be a list. Empty, falsy non-list
+ values such as `{}`, `""`, `0`, and `false` now report a validation error
+ instead of silently behaving as an empty catalog configuration.
+- **Re-initialization team-directives state**: Re-running `specify init`
+ without `--team-ai-directives` now drops previously tracked MCP ownership
+ metadata alongside the directives source, preventing stale configuration
+ records.
+
# [1.0.4+adlc1] - 2026-09-03
### Changed
diff --git a/presets/agentic-sdlc/commands/adlc.spec.plan.md b/presets/agentic-sdlc/commands/adlc.spec.plan.md
index b547feee0b..a9f6da0f30 100644
--- a/presets/agentic-sdlc/commands/adlc.spec.plan.md
+++ b/presets/agentic-sdlc/commands/adlc.spec.plan.md
@@ -62,7 +62,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
-1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. 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 JSON for FEATURE_SPEC, IMPL_PLAN, FEATURE_DIR, BRANCH. 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 context**: Read FEATURE_SPEC and `{REPO_ROOT}/.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
diff --git a/pyproject.toml b/pyproject.toml
index de890c0362..37704f4d43 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "agentic-sdlc-specify-cli"
-version = "1.0.4+adlc2"
+version = "1.0.4+adlc3"
description = "Specify CLI (tikalk fork). Agentic SDLC toolkit for Spec-Driven Development with pre-installed extensions and AI integrations."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py
index 59e17b40d0..c207254055 100644
--- a/src/specify_cli/commands/init.py
+++ b/src/specify_cli/commands/init.py
@@ -860,15 +860,16 @@ def init(
from .._init_options import load_init_options
pre_init_options = load_init_options(project_path)
- owned_mcp_entries = pre_init_options.get("team_ai_directives_mcp")
if team_ai_directives:
from pathlib import Path as _Path
_td_path = _Path(team_ai_directives).expanduser()
if _td_path.exists():
team_ai_directives = str(_td_path.resolve())
init_opts["team_ai_directives"] = team_ai_directives
- if isinstance(owned_mcp_entries, dict):
- init_opts["team_ai_directives_mcp"] = owned_mcp_entries
+ # MCP ownership exists only while its directives source is configured.
+ owned_mcp_entries = pre_init_options.get("team_ai_directives_mcp")
+ if isinstance(owned_mcp_entries, dict):
+ init_opts["team_ai_directives_mcp"] = owned_mcp_entries
if resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
):
diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py
index 56578c3feb..aeaa70558e 100644
--- a/tests/test_agent_config_consistency.py
+++ b/tests/test_agent_config_consistency.py
@@ -453,6 +453,19 @@ def test_team_context_lifecycle_owned_by_skills(self):
assert "team-context" not in adlc_specify
assert "team-context" not in adlc_plan
+ def test_plan_preset_matches_setup_plan_output_keys(self):
+ """The preinstalled plan override must request the script's feature directory key."""
+ adlc_plan = (
+ REPO_ROOT
+ / "presets"
+ / "agentic-sdlc"
+ / "commands"
+ / "adlc.spec.plan.md"
+ ).read_text(encoding="utf-8")
+
+ assert "FEATURE_DIR" in adlc_plan
+ assert "SPECS_DIR" not in adlc_plan
+
# --- RovoDev consistency checks ---
def test_rovodev_in_agent_config(self):
diff --git a/tests/test_init_output_markup.py b/tests/test_init_output_markup.py
index 54576fb33f..2de34eb595 100644
--- a/tests/test_init_output_markup.py
+++ b/tests/test_init_output_markup.py
@@ -24,7 +24,7 @@
import pytest
from typer.testing import CliRunner
-from specify_cli import app
+from specify_cli import app, load_init_options, save_init_options
from specify_cli.commands.init import _shell_quote_arg
from tests.conftest import requires_bash
@@ -140,6 +140,48 @@ def test_ordinary_name_is_not_quoted(tmp_path: Path):
assert _cd_argument(result.stdout) == "my-project"
+def test_reinit_without_team_directives_discards_owned_mcp_metadata(tmp_path: Path):
+ """A re-init without the source must not retain its MCP ownership record."""
+ name = "project"
+ initial = _init(tmp_path, name)
+ assert initial.exit_code == 0, _strip(initial.stdout)
+
+ project = tmp_path / name
+ save_init_options(
+ project,
+ {
+ **load_init_options(project),
+ "team_ai_directives": "/old/directives",
+ "team_ai_directives_mcp": {"mcpServers": {"team": {}}},
+ },
+ )
+
+ previous = os.getcwd()
+ os.chdir(project)
+ try:
+ result = CliRunner().invoke(
+ app,
+ [
+ "init",
+ "--here",
+ "--force",
+ "--integration",
+ "generic",
+ "--integration-options",
+ "--commands-dir .agent/commands",
+ "--ignore-agent-tools",
+ "--offline",
+ ],
+ catch_exceptions=True,
+ )
+ finally:
+ os.chdir(previous)
+
+ assert result.exit_code == 0, _strip(result.stdout)
+ assert "team_ai_directives" not in load_init_options(project)
+ assert "team_ai_directives_mcp" not in load_init_options(project)
+
+
@requires_bash
@pytest.mark.parametrize("name", ["proj v2", "proj [v2]", "my-project"])
def test_printed_cd_command_actually_changes_directory(tmp_path: Path, name: str):