Skip to content
Open
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
213 changes: 212 additions & 1 deletion src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,117 @@
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,
resolver: "PresetResolver",
) -> 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):
return False
return (
isinstance(metadata, dict)
and metadata.get("sha256") == _content_sha256(content)
)

# Older projects have no provenance sidecar. Only the immutable bundled or
# source-checkout core template is safe to treat as generated.
core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
return core is not None and core.read_bytes() == content


def _constitution_provenance_matches_preset(
project_root: Path,
memory_constitution: Path,
pack_id: str,
pack_version: str,
) -> bool:
"""Return whether provenance identifies a preset as the materialized source."""
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
if not provenance.parent.exists():
return False
_ensure_safe_shared_destination(project_root, provenance)
if not provenance.exists():
return False
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("source") == f"{pack_id} v{pack_version}"
)


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 +1725,73 @@ 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, dest_dir)

return manifest

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

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

self.reconcile_constitution(
f"Failed to seed constitution from preset {manifest.id}",
create_if_missing=True,
)

def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings

warnings.warn(
f"{failure_context}: {exc}.",
stacklevel=2,
)

def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
if not memory_constitution.exists() and not create_if_missing:
return
resolver = PresetResolver(self.project_root)
if memory_constitution.exists() and not _constitution_is_generated(
self.project_root, memory_constitution, resolver
):
return
_materialize_constitution_template(self.project_root, memory_constitution)
Comment thread
BenBtg marked this conversation as resolved.

def install_from_zip(
self,
zip_path: Path,
Expand Down Expand Up @@ -1696,13 +1871,37 @@ 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 = any(
path.exists()
for path in (
pack_dir / "templates" / "constitution-template.md",
pack_dir / "constitution-template.md",
)
)
if metadata and isinstance(metadata.get("version"), str):
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
removed_constitution = removed_constitution or (
_constitution_provenance_matches_preset(
self.project_root,
memory_constitution,
pack_id,
metadata["version"],
)
)
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 thread
BenBtg marked this conversation as resolved.
Comment thread
BenBtg marked this conversation as resolved.
if tmpl.get("type") == "command":
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
Expand Down Expand Up @@ -1749,6 +1948,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
9 changes: 9 additions & 0 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ def preset_set_priority(

# Update priority
manager.registry.update(preset_id, {"priority": priority})
manager.reconcile_constitution(
f"Failed to reconcile constitution after changing priority for preset {preset_id}"
)

console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
Expand Down Expand Up @@ -517,6 +520,9 @@ def preset_enable(

# Enable the preset
manager.registry.update(preset_id, {"enabled": True})
manager.reconcile_constitution(
f"Failed to reconcile constitution after enabling preset {preset_id}"
)

console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
Expand Down Expand Up @@ -551,6 +557,9 @@ def preset_disable(

# Disable the preset
manager.registry.update(preset_id, {"enabled": False})
manager.reconcile_constitution(
f"Failed to reconcile constitution after disabling preset {preset_id}"
)

console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")
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
Loading
Loading