Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,39 @@ def _stdin_is_interactive() -> bool:
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
"""Copy constitution template to memory if it doesn't exist."""
"""Materialize the resolved constitution template to memory if missing.

Resolution walks the full priority stack (project overrides → installed
presets → extensions → core) via :class:`PresetResolver`, so a preset that
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
constitution) can seed the memory file. When nothing overrides it, the
resolver falls through to the core template.
"""
from ..presets import _materialize_constitution_template

memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)

Comment thread
BenBtg marked this conversation as resolved.
if memory_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.skip("constitution", "existing file preserved")
return

if not template_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return

try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
materialization = _materialize_constitution_template(
project_path, memory_constitution
)
if materialization is None:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
if materialization == "copied":
tracker.complete("constitution", "copied from template")
else:
tracker.complete("constitution", "composed from template")
else:
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
Expand Down Expand Up @@ -447,8 +456,6 @@ def init(
"shared-infra", f"scripts ({selected_script}) + templates"
)

ensure_constitution_from_template(project_path, tracker=tracker)

try:
bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf:
Expand Down Expand Up @@ -576,6 +583,11 @@ def init(
continuing="Continuing without the optional preset.",
)

# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.
ensure_constitution_from_template(project_path, tracker=tracker)

tracker.complete("final", "project ready")
except (typer.Exit, SystemExit):
raise
Expand Down
155 changes: 154 additions & 1 deletion src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,95 @@
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter, version_satisfies
from ..shared_infra import verify_archive_sha256
from ..shared_infra import (
_ensure_safe_shared_destination,
_ensure_safe_shared_directory,
_write_shared_bytes,
_write_shared_text,
verify_archive_sha256,
)


_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"


def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()


def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
layers: list[dict[str, Any]],
) -> bool:
"""Return whether the live constitution is an unchanged generated file."""
_ensure_safe_shared_destination(project_root, memory_constitution)
content = memory_constitution.read_bytes()
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_ensure_safe_shared_destination(project_root, provenance)

if provenance.exists():
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
metadata = {}
if (
isinstance(metadata, dict)
and metadata.get("sha256") == _content_sha256(content)
):
return True

# Older projects have no provenance sidecar. Only the exact known core
# template is safe to treat as generated; placeholder substrings are not.
for layer in layers:
if layer["source"].startswith("core") and layer["path"].read_bytes() == content:
return True
Comment on lines +72 to +76
return False


def _materialize_constitution_template(
project_root: Path,
memory_constitution: Path,
) -> str | None:
"""Materialize constitution-template content into memory/constitution.md.

Returns:
"copied" when the winning layer is ``replace`` and the source file is
copied verbatim; "composed" when a composing strategy is materialized
via ``resolve_content``; ``None`` when no constitution template resolves.
"""
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if not layers:
return None

top_layer = layers[0]
if top_layer["strategy"] == "replace":
content = top_layer["path"].read_bytes()
result = "copied"
else:
composed_content = resolver.resolve_content("constitution-template", "template")
if composed_content is None:
return None
content = composed_content.encode("utf-8")
result = "composed"

_ensure_safe_shared_directory(project_root, memory_constitution.parent)
_write_shared_bytes(project_root, memory_constitution, content)
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_write_shared_text(
project_root,
provenance,
json.dumps(
{
"sha256": _content_sha256(content),
"source": top_layer["source"],
},
indent=2,
)
+ "\n",
)
return result


def _substitute_core_template(
Expand Down Expand Up @@ -1615,8 +1703,55 @@ def install_from_directory(
stacklevel=2,
)

# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
Comment thread
BenBtg marked this conversation as resolved.
self._seed_constitution_from_preset(manifest)
Comment thread
BenBtg marked this conversation as resolved.

return manifest

def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None:
"""Seed memory/constitution.md from a preset constitution-template.

Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` and the live memory file is either missing or
is an unchanged generated file. Authored constitutions are never
overwritten.
"""
provides_constitution = any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
)
if not provides_constitution:
return

try:
self._reconcile_constitution()
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings

warnings.warn(
f"Failed to seed constitution from preset {manifest.id}: {exc}.",
stacklevel=2,
)

def _reconcile_constitution(self) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
resolver = PresetResolver(self.project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if memory_constitution.exists() and not _constitution_is_generated(
self.project_root, memory_constitution, layers
):
return
_materialize_constitution_template(self.project_root, memory_constitution)

def install_from_zip(
self,
zip_path: Path,
Expand Down Expand Up @@ -1696,13 +1831,19 @@ def remove(self, pack_id: str) -> bool:
# Also include aliases from the manifest as a safety net for registries
# populated by older versions that may not track aliases.
removed_cmd_names = set()
removed_constitution = False
for cmd_names in registered_commands.values():
removed_cmd_names.update(cmd_names)
manifest_path = pack_dir / "preset.yml"
if manifest_path.exists():
try:
manifest = PresetManifest(manifest_path)
for tmpl in manifest.templates:
if (
tmpl.get("type") == "template"
and tmpl.get("name") == "constitution-template"
):
removed_constitution = True
Comment on lines +1842 to +1846
if tmpl.get("type") == "command":
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
Expand Down Expand Up @@ -1749,6 +1890,18 @@ def remove(self, pack_id: str) -> bool:
stacklevel=2,
)

if removed_constitution:
try:
self._reconcile_constitution()
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings

warnings.warn(
f"Post-removal constitution reconciliation failed for {pack_id}: "
f"{exc}. The live constitution may be stale.",
stacklevel=2,
)

return True

def list_installed(self) -> List[Dict[str, Any]]:
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
"spec-template.md", "tasks-template.md"]:
files.append(f".specify/templates/{name}")

files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
]
# Script variant
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
]:
files.append(f".specify/templates/{name}")

files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_base_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
]:
files.append(f".specify/templates/{name}")

files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/test_integration_cline.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def _expected_files(self, script_variant: str) -> list[str]:
]:
files.append(f".specify/templates/{name}")

files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/test_integration_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def test_complete_file_inventory_sh(self, tmp_path):
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
Expand Down Expand Up @@ -313,6 +314,7 @@ def test_complete_file_inventory_ps(self, tmp_path):
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
Expand Down Expand Up @@ -724,6 +726,7 @@ def test_complete_file_inventory_skills_sh(self, tmp_path):
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",
Expand Down
2 changes: 2 additions & 0 deletions tests/integrations/test_integration_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ def test_complete_file_inventory_sh(self, tmp_path):
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
Expand Down Expand Up @@ -342,6 +343,7 @@ def test_complete_file_inventory_ps(self, tmp_path):
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
Expand Down
Loading
Loading