diff --git a/pyproject.toml b/pyproject.toml index 13be17cd7a..c488cdd37d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,13 @@ packages = ["src/specify_cli"] # Bundle core assets so `specify init` works without network access (air-gapped / enterprise) # Page templates (exclude commands/ — bundled separately below to avoid duplication) "templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" +"templates/architecture-development-template.md" = "specify_cli/core_pack/templates/architecture-development-template.md" +"templates/architecture-logical-template.md" = "specify_cli/core_pack/templates/architecture-logical-template.md" +"templates/architecture-physical-template.md" = "specify_cli/core_pack/templates/architecture-physical-template.md" +"templates/architecture-process-template.md" = "specify_cli/core_pack/templates/architecture-process-template.md" +"templates/architecture-scenario-template.md" = "specify_cli/core_pack/templates/architecture-scenario-template.md" +"templates/architecture-template.md" = "specify_cli/core_pack/templates/architecture-template.md" +"templates/agent-governance-template.md" = "specify_cli/core_pack/templates/agent-governance-template.md" "templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" "templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" "templates/spec-template.md" = "specify_cli/core_pack/templates/spec-template.md" @@ -70,4 +77,3 @@ omit = ["*/tests/*", "*/__pycache__/*"] precision = 2 show_missing = true skip_covered = false - diff --git a/scripts/bash/setup-arch.sh b/scripts/bash/setup-arch.sh new file mode 100755 index 0000000000..f6d566d275 --- /dev/null +++ b/scripts/bash/setup-arch.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) +ARCH_DIR="$REPO_ROOT/.specify/memory" +ARCH_FILE="$ARCH_DIR/architecture.md" +SCENARIO_VIEW="$ARCH_DIR/architecture-scenario-view.md" +LOGICAL_VIEW="$ARCH_DIR/architecture-logical-view.md" +PROCESS_VIEW="$ARCH_DIR/architecture-process-view.md" +DEVELOPMENT_VIEW="$ARCH_DIR/architecture-development-view.md" +PHYSICAL_VIEW="$ARCH_DIR/architecture-physical-view.md" + +mkdir -p "$ARCH_DIR" + +copy_template_if_missing() { + local template_name="$1" + local destination="$2" + + if [[ -f "$destination" ]]; then + return 0 + fi + + local template + template=$(resolve_template "$template_name" "$REPO_ROOT") || true + if [[ -n "$template" ]] && [[ -f "$template" ]]; then + cp "$template" "$destination" + echo "Copied $template_name template to $destination" + else + echo "Warning: $template_name template not found" + touch "$destination" + fi +} + +copy_template_if_missing "architecture-template" "$ARCH_FILE" +copy_template_if_missing "architecture-scenario-template" "$SCENARIO_VIEW" +copy_template_if_missing "architecture-logical-template" "$LOGICAL_VIEW" +copy_template_if_missing "architecture-process-template" "$PROCESS_VIEW" +copy_template_if_missing "architecture-development-template" "$DEVELOPMENT_VIEW" +copy_template_if_missing "architecture-physical-template" "$PHYSICAL_VIEW" + +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg arch_file "$ARCH_FILE" \ + --arg arch_dir "$ARCH_DIR" \ + --arg scenario_view "$SCENARIO_VIEW" \ + --arg logical_view "$LOGICAL_VIEW" \ + --arg process_view "$PROCESS_VIEW" \ + --arg development_view "$DEVELOPMENT_VIEW" \ + --arg physical_view "$PHYSICAL_VIEW" \ + '{ARCH_FILE:$arch_file,ARCH_DIR:$arch_dir,SCENARIO_VIEW:$scenario_view,LOGICAL_VIEW:$logical_view,PROCESS_VIEW:$process_view,DEVELOPMENT_VIEW:$development_view,PHYSICAL_VIEW:$physical_view}' + else + printf '{"ARCH_FILE":"%s","ARCH_DIR":"%s","SCENARIO_VIEW":"%s","LOGICAL_VIEW":"%s","PROCESS_VIEW":"%s","DEVELOPMENT_VIEW":"%s","PHYSICAL_VIEW":"%s"}\n' \ + "$(json_escape "$ARCH_FILE")" \ + "$(json_escape "$ARCH_DIR")" \ + "$(json_escape "$SCENARIO_VIEW")" \ + "$(json_escape "$LOGICAL_VIEW")" \ + "$(json_escape "$PROCESS_VIEW")" \ + "$(json_escape "$DEVELOPMENT_VIEW")" \ + "$(json_escape "$PHYSICAL_VIEW")" + fi +else + echo "ARCH_FILE: $ARCH_FILE" + echo "ARCH_DIR: $ARCH_DIR" + echo "SCENARIO_VIEW: $SCENARIO_VIEW" + echo "LOGICAL_VIEW: $LOGICAL_VIEW" + echo "PROCESS_VIEW: $PROCESS_VIEW" + echo "DEVELOPMENT_VIEW: $DEVELOPMENT_VIEW" + echo "PHYSICAL_VIEW: $PHYSICAL_VIEW" +fi diff --git a/scripts/powershell/setup-arch.ps1 b/scripts/powershell/setup-arch.ps1 new file mode 100755 index 0000000000..b2f7427360 --- /dev/null +++ b/scripts/powershell/setup-arch.ps1 @@ -0,0 +1,86 @@ +#!/usr/bin/env pwsh +# Setup project-level 4+1 architecture artifacts + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: ./setup-arch.ps1 [-Json] [-Help]" + Write-Output " -Json Output results in JSON format" + Write-Output " -Help Show this help message" + exit 0 +} + +. "$PSScriptRoot/common.ps1" + +function Convert-ToPlainPath { + param([Parameter(Mandatory = $true)][string]$Path) + + if ($Path -like 'Microsoft.PowerShell.Core\FileSystem::*') { + return $Path.Substring('Microsoft.PowerShell.Core\FileSystem::'.Length) + } + return $Path +} + +$repoRoot = Convert-ToPlainPath (Get-RepoRoot) +$archDir = Join-Path $repoRoot ".specify/memory" +$archFile = Join-Path $archDir "architecture.md" +$scenarioView = Join-Path $archDir "architecture-scenario-view.md" +$logicalView = Join-Path $archDir "architecture-logical-view.md" +$processView = Join-Path $archDir "architecture-process-view.md" +$developmentView = Join-Path $archDir "architecture-development-view.md" +$physicalView = Join-Path $archDir "architecture-physical-view.md" + +New-Item -ItemType Directory -Path $archDir -Force | Out-Null + +function Copy-TemplateIfMissing { + param( + [Parameter(Mandatory = $true)][string]$TemplateName, + [Parameter(Mandatory = $true)][string]$Destination + ) + + if (Test-Path -LiteralPath $Destination -PathType Leaf) { + return + } + + $template = Resolve-Template -TemplateName $TemplateName -RepoRoot $repoRoot + if ($template -and (Test-Path -LiteralPath $template -PathType Leaf)) { + Copy-Item -LiteralPath $template -Destination $Destination -Force + Write-Output "Copied $TemplateName template to $Destination" + } else { + Write-Warning "$TemplateName template not found" + New-Item -ItemType File -Path $Destination -Force | Out-Null + } +} + +Copy-TemplateIfMissing -TemplateName "architecture-template" -Destination $archFile +Copy-TemplateIfMissing -TemplateName "architecture-scenario-template" -Destination $scenarioView +Copy-TemplateIfMissing -TemplateName "architecture-logical-template" -Destination $logicalView +Copy-TemplateIfMissing -TemplateName "architecture-process-template" -Destination $processView +Copy-TemplateIfMissing -TemplateName "architecture-development-template" -Destination $developmentView +Copy-TemplateIfMissing -TemplateName "architecture-physical-template" -Destination $physicalView + +if ($Json) { + [PSCustomObject]@{ + ARCH_FILE = $archFile + ARCH_DIR = $archDir + SCENARIO_VIEW = $scenarioView + LOGICAL_VIEW = $logicalView + PROCESS_VIEW = $processView + DEVELOPMENT_VIEW = $developmentView + PHYSICAL_VIEW = $physicalView + } | ConvertTo-Json -Compress +} else { + Write-Output "ARCH_FILE: $archFile" + Write-Output "ARCH_DIR: $archDir" + Write-Output "SCENARIO_VIEW: $scenarioView" + Write-Output "LOGICAL_VIEW: $logicalView" + Write-Output "PROCESS_VIEW: $processView" + Write-Output "DEVELOPMENT_VIEW: $developmentView" + Write-Output "PHYSICAL_VIEW: $physicalView" +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d3eb36391e..73dbdbd019 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -74,6 +74,10 @@ install_shared_infra as _install_shared_infra_impl, refresh_shared_templates as _refresh_shared_templates_impl, ) +from .agent_projection import ( + ensure_agent_governance_from_template as _ensure_agent_governance_from_template, + refresh_agent_projection as _refresh_agent_projection, +) # For cross-platform keyboard input import readchar @@ -928,6 +932,46 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") +def ensure_agent_governance_from_template(project_path: Path, tracker: StepTracker | None = None) -> None: + """Copy agent-governance template to memory if it doesn't exist.""" + try: + result = _ensure_agent_governance_from_template(project_path) + except Exception as e: + if tracker: + tracker.add("agent-governance", "Agent governance setup") + tracker.error("agent-governance", str(e)) + else: + console.print(f"[yellow]Warning: Could not initialize agent governance: {e}[/yellow]") + return + + if tracker: + tracker.add("agent-governance", "Agent governance setup") + if result is None: + tracker.error("agent-governance", "template not found") + else: + tracker.complete("agent-governance", "available") + + +def refresh_agent_projection(project_path: Path, tracker: StepTracker | None = None) -> None: + """Refresh generated agent governance projections.""" + try: + result = _refresh_agent_projection(project_path) + except Exception as e: + if tracker: + tracker.add("agent-projection", "Agent governance projection") + tracker.error("agent-projection", str(e)) + else: + console.print(f"[yellow]Warning: Could not refresh agent projection: {e}[/yellow]") + return + + if tracker: + tracker.add("agent-projection", "Agent governance projection") + if result.memory_path is None: + tracker.skip("agent-projection", "agent-governance template missing") + else: + tracker.complete("agent-projection", f"{len(result.projection_paths)} file(s) refreshed") + + INIT_OPTIONS_FILE = ".specify/init-options.json" @@ -973,6 +1017,8 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" SKILL_DESCRIPTIONS = { + "arch": "Generate project-level 4+1 architecture view artifacts and synthesis.", + "agent": "Create or update agent governance and refresh agent instruction projections.", "specify": "Create or update feature specifications from natural language descriptions.", "plan": "Generate technical implementation plans from feature specifications.", "tasks": "Break down implementation plans into actionable task lists.", @@ -1361,6 +1407,8 @@ def init( tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) + ensure_agent_governance_from_template(project_path, tracker=tracker) + refresh_agent_projection(project_path, tracker=tracker) if not no_git: tracker.start("git") @@ -1629,11 +1677,12 @@ def _display_cmd(name: str) -> str: steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") - steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") - steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") - steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") - steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") - steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") + steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('arch')}[/] - Shape 4+1 architecture views") + steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") + steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") + steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") + steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") + steps_lines.append(f" {step_num}.6 [cyan]{_display_cmd('implement')}[/] - Execute implementation") steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) console.print() @@ -1997,6 +2046,7 @@ def _write_integration_json( installed_integrations=installed_integrations, settings=integration_settings, ) + refresh_agent_projection(project_root) def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: @@ -2015,6 +2065,7 @@ def _remove_integration_json(project_root: Path) -> None: path = project_root / INTEGRATION_JSON if path.exists(): path.unlink() + refresh_agent_projection(project_root) _MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) diff --git a/src/specify_cli/agent_projection.py b/src/specify_cli/agent_projection.py new file mode 100644 index 0000000000..8f2fb1cb54 --- /dev/null +++ b/src/specify_cli/agent_projection.py @@ -0,0 +1,295 @@ +"""Agent governance memory and projection helpers.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .integration_state import ( + INTEGRATION_JSON, + default_integration_key, + installed_integration_keys, + normalize_integration_state, +) + + +AGENT_GOVERNANCE_MEMORY = ".specify/memory/agent-governance.md" +AGENT_GOVERNANCE_TEMPLATE = ".specify/templates/agent-governance-template.md" + +PROJECTION_MARKER_START = "" +PROJECTION_MARKER_END = "" + + +@dataclass(frozen=True) +class AgentProjectionResult: + """Files updated by an agent projection refresh.""" + + memory_path: Path | None + projection_paths: list[Path] + + +def ensure_agent_governance_from_template(project_root: Path) -> Path | None: + """Copy agent-governance template to memory if missing.""" + memory_path = project_root / AGENT_GOVERNANCE_MEMORY + if memory_path.exists(): + return memory_path + + template_path = project_root / AGENT_GOVERNANCE_TEMPLATE + if not template_path.exists(): + return None + + memory_path.parent.mkdir(parents=True, exist_ok=True) + memory_path.write_bytes(template_path.read_bytes()) + return memory_path + + +def refresh_agent_projection(project_root: Path) -> AgentProjectionResult: + """Refresh repo-level and agent-specific governance projections. + + The source of truth is ``.specify/memory/agent-governance.md`` plus the + repository's current integration, skill, MCP, and extension state. Existing + text outside the generated projection markers is preserved. + """ + memory_path = ensure_agent_governance_from_template(project_root) + if memory_path is None: + return AgentProjectionResult(None, []) + + state = _read_integration_state(project_root) + installed = installed_integration_keys(state) + default_key = default_integration_key(state) + projection_paths = _projection_targets(project_root, state) + projection = _render_projection(project_root, memory_path, state) + updated: list[Path] = [] + + for path in projection_paths: + content = _adapter_prelude(path, default_key, installed) + if path.exists(): + existing = path.read_text(encoding="utf-8-sig") + new_content = _upsert_marked_section(existing, projection) + if new_content == existing: + continue + else: + path.parent.mkdir(parents=True, exist_ok=True) + new_content = content + "\n" + projection + + path.write_text(_normalize_newlines(new_content), encoding="utf-8") + updated.append(path) + + return AgentProjectionResult(memory_path, updated) + + +def _read_integration_state(project_root: Path) -> dict[str, Any]: + path = project_root / INTEGRATION_JSON + if not path.exists(): + return normalize_integration_state({}) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return normalize_integration_state({}) + return normalize_integration_state(data if isinstance(data, dict) else {}) + + +def _projection_targets(project_root: Path, state: dict[str, Any]) -> list[Path]: + targets: list[Path] = [project_root / "AGENTS.md"] + + try: + from .integrations import get_integration + except Exception: + get_integration = None # type: ignore[assignment] + + for key in installed_integration_keys(state): + integration = get_integration(key) if get_integration else None + context_file = getattr(integration, "context_file", None) + if isinstance(context_file, str) and context_file.strip(): + targets.append(project_root / context_file) + + # Common adapter files. They are created when the corresponding + # integration is installed, and refreshed whenever they already exist so + # uninstall/switch operations do not leave stale generated projections. + for key, path in { + "claude": "CLAUDE.md", + "gemini": "GEMINI.md", + "copilot": ".github/copilot-instructions.md", + }.items(): + target = project_root / path + if key in installed_integration_keys(state) or target.exists(): + targets.append(target) + + deduped: list[Path] = [] + seen: set[str] = set() + for path in targets: + rel = path.resolve().as_posix() + if rel in seen: + continue + seen.add(rel) + deduped.append(path) + return deduped + + +def _render_projection( + project_root: Path, + memory_path: Path, + state: dict[str, Any], +) -> str: + installed = installed_integration_keys(state) + default_key = default_integration_key(state) + skills = _scan_skills(project_root) + mcp_configs = _scan_mcp_configs(project_root) + extensions = _scan_extensions(project_root) + + lines = [ + PROJECTION_MARKER_START, + "# Agent Governance Projection", + "", + "Generated from repository state. Do not edit this section directly; update", + f"`{AGENT_GOVERNANCE_MEMORY}`, integrations, skills, MCP config, or extensions instead.", + "", + "## Governing Source", + f"- Agent governance SSOT: `{AGENT_GOVERNANCE_MEMORY}`", + "- Project principles: `.specify/memory/constitution.md`", + "- Business semantics: `.specify/memory/uc.md`", + "- Architecture boundaries: `.specify/memory/architecture.md`", + "", + "## Active Integrations", + f"- Default integration: `{default_key or 'none'}`", + f"- Installed integrations: {', '.join(f'`{key}`' for key in installed) if installed else '`none`'}", + "", + "## Active Skills", + ] + + if skills: + for skill in skills: + lines.append(f"- `{skill}`") + else: + lines.append("- `none detected`") + + lines.extend(["", "## MCP Configuration"]) + if mcp_configs: + for config in mcp_configs: + lines.append(f"- `{config}`") + else: + lines.append("- `none detected`") + + lines.extend(["", "## Extensions"]) + if extensions: + for extension in extensions: + lines.append(f"- `{extension}`") + else: + lines.append("- `none detected`") + + lines.extend([ + "", + "## Required Operating Rules", + "- Follow current user instructions first.", + "- Treat `.specify/memory/agent-governance.md` as the source of truth for agent, skill, and MCP behavior.", + "- Treat `.specify/memory/constitution.md` as the source of truth for project principles and quality gates.", + "- Do not edit governance, CI, MCP config, secrets, permissions, or tool settings unless explicitly requested.", + "- Do not overwrite user edits or modify files outside the active task scope.", + "- Report changed files, commands run, validation results, and unresolved risks before handoff.", + "", + f"_Projection source file: `{memory_path.relative_to(project_root).as_posix()}`_", + PROJECTION_MARKER_END, + "", + ]) + return "\n".join(lines) + + +def _upsert_marked_section(content: str, projection: str) -> str: + start = content.find(PROJECTION_MARKER_START) + end = content.find(PROJECTION_MARKER_END, start if start != -1 else 0) + if start != -1 and end != -1 and end > start: + end += len(PROJECTION_MARKER_END) + if end < len(content) and content[end] == "\r": + end += 1 + if end < len(content) and content[end] == "\n": + end += 1 + return content[:start] + projection + content[end:] + + if content and not content.endswith("\n"): + content += "\n" + return content + ("\n" if content else "") + projection + + +def _adapter_prelude(path: Path, default_key: str | None, installed: list[str]) -> str: + name = path.name + if name == "AGENTS.md": + return "# Repo Agent Governance\n\nThis file is the repository-level agent governance projection." + if name == "CLAUDE.md": + return "# Claude Instructions\n\nRead `AGENTS.md` first; it is the repository-level agent governance projection." + if name == "GEMINI.md": + return "# Gemini Instructions\n\nRead `AGENTS.md` first; it is the repository-level agent governance projection." + if name == "copilot-instructions.md": + return "# GitHub Copilot Instructions\n\nRead `AGENTS.md` first; it is the repository-level agent governance projection." + installed_text = ", ".join(installed) if installed else "none" + return ( + "# Agent Instructions\n\n" + "Read `AGENTS.md` first; it is the repository-level agent governance projection.\n\n" + f"Default integration: `{default_key or 'none'}`. Installed integrations: `{installed_text}`." + ) + + +def _scan_skills(project_root: Path) -> list[str]: + skills: list[str] = [] + for skill_file in project_root.rglob("SKILL.md"): + if any(part in {".git", "__pycache__", ".venv", "node_modules"} for part in skill_file.parts): + continue + try: + rel = skill_file.relative_to(project_root).as_posix() + except ValueError: + rel = skill_file.as_posix() + skills.append(rel) + return sorted(skills) + + +def _scan_mcp_configs(project_root: Path) -> list[str]: + candidates: list[str] = [] + names = { + ".mcp.json", + "mcp.json", + "mcp.yml", + "mcp.yaml", + "mcp.config.json", + } + for path in project_root.rglob("*"): + if not path.is_file(): + continue + if any(part in {".git", "__pycache__", ".venv", "node_modules"} for part in path.parts): + continue + if path.name in names or "mcp" in path.name.lower(): + try: + candidates.append(path.relative_to(project_root).as_posix()) + except ValueError: + candidates.append(path.as_posix()) + return sorted(candidates) + + +def _scan_extensions(project_root: Path) -> list[str]: + registry = project_root / ".specify" / "extensions.yml" + if not registry.exists(): + return [] + try: + data = yaml.safe_load(registry.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeDecodeError): + return [".specify/extensions.yml"] + if not isinstance(data, dict): + return [".specify/extensions.yml"] + extensions = data.get("extensions") + if isinstance(extensions, dict): + return sorted(str(key) for key in extensions) + if isinstance(extensions, list): + names = [] + for item in extensions: + if isinstance(item, dict) and item.get("id"): + names.append(str(item["id"])) + elif isinstance(item, str): + names.append(item) + return sorted(names) or [".specify/extensions.yml"] + return [".specify/extensions.yml"] + + +def _normalize_newlines(content: str) -> str: + return content.replace("\r\n", "\n").replace("\r", "\n") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 944ee4a06d..c3f3617643 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -27,6 +27,7 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({ "analyze", + "arch", "checklist", "clarify", "constitution", diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 88aef85285..b45a4963da 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -23,6 +23,7 @@ # Mapping of command template stem → argument-hint text shown inline # when a user invokes the slash command in Claude Code. ARGUMENT_HINTS: dict[str, str] = { + "arch": "Optional architecture scenario or 4+1 design focus", "specify": "Describe the feature you want to specify", "plan": "Optional guidance for the planning phase", "tasks": "Optional task generation constraints", diff --git a/templates/agent-governance-template.md b/templates/agent-governance-template.md new file mode 100644 index 0000000000..71c6545676 --- /dev/null +++ b/templates/agent-governance-template.md @@ -0,0 +1,67 @@ +# Agent Governance + + + +## Authority Order + +1. Current user instruction +2. This agent governance file +3. `.specify/memory/constitution.md` +4. `.specify/memory/architecture.md` +5. `.specify/memory/uc.md` +6. Active feature artifacts under `specs//` +7. Skill-local `SKILL.md` +8. Tool/MCP defaults + +## Source Of Truth + +- Project principles: `.specify/memory/constitution.md` +- Business semantics: `.specify/memory/uc.md` +- Architecture boundaries: `.specify/memory/architecture.md` +- Feature work: `specs//` +- Agent operations: `.specify/memory/agent-governance.md` +- Skill contracts: each `SKILL.md` +- MCP permissions: MCP configuration and allowlists + +## Write Boundaries + +- Do not edit governance, CI, MCP config, secrets, permissions, or tool settings unless explicitly requested. +- Do not modify files outside the active task scope. +- Do not overwrite user edits. +- Do not rewrite generated files unless the owning workflow requires it. + +## Skill Contract + +Each skill must declare: + +- purpose +- trigger +- allowed read paths +- allowed write paths +- forbidden paths +- outputs +- validation command + +## MCP Policy + +- MCP tools are read-only by default. +- Mutating MCP calls require explicit user intent. +- External writes must report target, action, and result. +- Secrets and tokens must never be logged or written to repo files. + +## Validation + +Before handoff, report: + +- changed files +- commands run +- tests/validation result +- unresolved risks + diff --git a/templates/architecture-development-template.md b/templates/architecture-development-template.md new file mode 100644 index 0000000000..d9f8c6976c --- /dev/null +++ b/templates/architecture-development-template.md @@ -0,0 +1,39 @@ +# Development View + +**Input**: `.specify/memory/architecture-logical-view.md`, `.specify/memory/architecture-process-view.md` + +**Purpose**: Derive architecture-level components, package boundary intent, contract/artifact semantics, and dependency rules from logical and process views. + +## Architecture-Level Components + +| Component / Capability Package | Responsibility | Input / Output Boundary | Collaborators | Explicitly Must Not Own | Source View Evidence | +|--------------------------------|----------------|-------------------------|---------------|--------------------------|----------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Package Boundary Intent + +| Package / Boundary | Abstraction Level | Owned Concepts | May Depend On | Must Not Depend On | Evolution Rule | +|--------------------|-------------------|----------------|---------------|--------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Contracts and Artifacts + +| Contract / Artifact | Semantics | Producer | Consumer | Lifecycle | Architecture Consequence | +|---------------------|-----------|----------|----------|-----------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Dependency Rules + +| Rule | Allowed Direction | Forbidden Direction | Reason | Risk If Violated | +|------|-------------------|---------------------|--------|------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Development View Gaps + +| Gap | Affected Component / Boundary | Why It Matters | +|-----|-------------------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Prohibited Content + +Do not write source file paths, concrete package trees, classes, functions, implementation tasks, framework-specific wiring, or code generation notes here. diff --git a/templates/architecture-logical-template.md b/templates/architecture-logical-template.md new file mode 100644 index 0000000000..057874c3e1 --- /dev/null +++ b/templates/architecture-logical-template.md @@ -0,0 +1,39 @@ +# Logical View + +**Input**: `.specify/memory/architecture-scenario-view.md` + +**Purpose**: Derive capability boundaries, domain objects, states, relationships, and invariants from the scenario view. + +## Capability Boundaries + +| Capability / Boundary | Responsibility | Input | Output | Explicitly Does Not Own | Scenario Source | +|-----------------------|----------------|-------|--------|--------------------------|-----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Domain Objects and Relationships + +| Object | Meaning | Owning Capability | Key Relationships | Fact Source | Invariants | +|--------|---------|-------------------|-------------------|-------------|------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## State and Lifecycle + +| Object / Flow | State | Entered When | Exited When | Forbidden Transition | Responsible Boundary | +|---------------|-------|--------------|-------------|----------------------|----------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Logical Decisions + +| Decision | Scope | Owner / Boundary | Affected Objects or Flows | Consequence | +|----------|-------|------------------|---------------------------|-------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Logical Gaps + +| Gap | Affected Capability / Object | Why It Matters | +|-----|------------------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Prohibited Content + +Do not write classes, DTOs, database tables, fields, method names, endpoints, schemas, or implementation data structures here. diff --git a/templates/architecture-physical-template.md b/templates/architecture-physical-template.md new file mode 100644 index 0000000000..9271d3f76c --- /dev/null +++ b/templates/architecture-physical-template.md @@ -0,0 +1,39 @@ +# Physical View + +**Input**: `.specify/memory/architecture-process-view.md`, `.specify/memory/architecture-development-view.md` + +**Purpose**: Derive deployment, hosting, external system, fact-source, observability, and operational boundaries from process and development views. + +## Deployment and Hosting Boundaries + +| Runtime / Hosting Unit | Carries | Boundary | Depends On | Release / Migration Impact | +|------------------------|---------|----------|------------|----------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## External System Collaboration + +| External System | Purpose | Exchanged Content | Authoritative Fact | Failure Impact | Isolation / Substitute Boundary | +|-----------------|---------|-------------------|--------------------|----------------|---------------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Fact Sources and Observability + +| Fact / Event | Authoritative Source | Observable Location | Consumers | Traceability Requirement | +|--------------|----------------------|---------------------|-----------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Operations and Release Boundaries + +| Operational Concern | Responsible Boundary | Trigger | Affected Views | Architecture Consequence | +|---------------------|----------------------|---------|----------------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Physical View Gaps + +| Gap | Affected Deployment / External Boundary | Why It Matters | +|-----|-----------------------------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Prohibited Content + +Do not write Kubernetes YAML, cloud resource manifests, machine sizes, service SKUs, deployment scripts, runbooks, or concrete infrastructure configuration here. diff --git a/templates/architecture-process-template.md b/templates/architecture-process-template.md new file mode 100644 index 0000000000..143a085c21 --- /dev/null +++ b/templates/architecture-process-template.md @@ -0,0 +1,39 @@ +# Process View + +**Input**: `.specify/memory/architecture-scenario-view.md`, `.specify/memory/architecture-logical-view.md` + +**Purpose**: Derive runtime collaboration, handoffs, approvals, receipts, state advancement, and failure closure from scenario paths and logical boundaries. + +## Main Runtime Links + +| Runtime Link | Trigger | Source | Target | Transferred Content / Fact | Completion Condition | +|--------------|---------|--------|--------|----------------------------|----------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Handoffs and Approvals + +| Handoff / Approval | From | To | Meaning | Accepted Path | Rejected / Returned Path | +|--------------------|------|----|---------|---------------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Receipts and User Participation + +| Receipt / Participation Point | Sender | Receiver | Content | User Action | Architecture Consequence | +|-------------------------------|--------|----------|---------|-------------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Failure, Degradation, and Closure + +| Failure / Branch | Detection Boundary | Responsible Boundary | Degradation or Compensation | User-Visible Result | Closure Condition | +|------------------|--------------------|----------------------|-----------------------------|---------------------|-------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Process Gaps + +| Gap | Affected Runtime Link / Scenario | Why It Matters | +|-----|----------------------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Prohibited Content + +Do not write call stacks, queue names, retry counts, thread/process details, endpoint sequences, workflow engine configuration, or orchestration code here. diff --git a/templates/architecture-scenario-template.md b/templates/architecture-scenario-template.md new file mode 100644 index 0000000000..359c45244b --- /dev/null +++ b/templates/architecture-scenario-template.md @@ -0,0 +1,37 @@ +# Scenario View + +**Purpose**: Produce the UC semantics for the architecture workflow. This view is the source for the logical, process, development, and physical views. + +## Actors and Participants + +| Actor / Participant | Goal | Responsibility | Boundary | +|---------------------|------|----------------|----------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Use Cases + +| Use Case | Actor | Goal | Preconditions | Scope Boundary | +|----------|-------|------|---------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Scenario Paths + +| Scenario | Main Path | Successful Outcome | Alternative / Failure Branches | +|----------|-----------|--------------------|--------------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Acceptance Semantics + +| Acceptance Scenario | Observable Result | Must Hold | Not Covered | +|---------------------|-------------------|-----------|-------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Scenario Gaps + +| Gap | Affected Scenario | Why It Matters | +|-----|-------------------|----------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Prohibited Content + +Do not write architecture components, class designs, APIs, database tables, implementation tasks, test strategy, deployment scripts, or framework choices here. diff --git a/templates/architecture-template.md b/templates/architecture-template.md new file mode 100644 index 0000000000..9f2395bd94 --- /dev/null +++ b/templates/architecture-template.md @@ -0,0 +1,48 @@ +# Architecture Synthesis: [PROJECT] + +**Input Views**: +- Scenario: `.specify/memory/architecture-scenario-view.md` +- Logical: `.specify/memory/architecture-logical-view.md` +- Process: `.specify/memory/architecture-process-view.md` +- Development: `.specify/memory/architecture-development-view.md` +- Physical: `.specify/memory/architecture-physical-view.md` + +**Note**: This synthesis is filled in by the `__SPECKIT_COMMAND_ARCH__` command after the five 4+1 view files are updated. + +## View Index + +| View | File | Purpose | Current Status | +|------|------|---------|----------------| +| Scenario | `.specify/memory/architecture-scenario-view.md` | UC-producing actor, use case, path, branch, and acceptance semantics | NEEDS ARCH UPDATE | +| Logical | `.specify/memory/architecture-logical-view.md` | Capability boundaries, domain objects, states, and invariants | NEEDS ARCH UPDATE | +| Process | `.specify/memory/architecture-process-view.md` | Runtime links, handoffs, approvals, receipts, failure closure | NEEDS ARCH UPDATE | +| Development | `.specify/memory/architecture-development-view.md` | Architecture-level components, package boundaries, contracts, dependencies | NEEDS ARCH UPDATE | +| Physical | `.specify/memory/architecture-physical-view.md` | Deployment, external systems, fact sources, observability, operations | NEEDS ARCH UPDATE | + +## Architecture Axis + +[Summarize the central design forces that connect the five views: primary scenario flow, authority boundary, fact-source model, collaboration model, deployment constraint, or failure-closure model.] + +## Cross-View Mapping + +| Stable Concept | Scenario View | Logical View | Process View | Development View | Physical View | Architecture Consequence | +|----------------|---------------|--------------|--------------|------------------|---------------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Key Architecture Conclusions + +| Conclusion | Affected Views | Boundary/Owner | Consequence | +|------------|----------------|----------------|-------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Cross-Cutting Constraints + +| Constraint | Source | Affected Views | Scope | Architecture Consequence | +|------------|--------|----------------|-------|--------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | + +## Open Risks and Review Triggers + +| Risk or Trigger | Missing Evidence / Change Condition | Affected Views | Required Architecture Review | +|-----------------|-------------------------------------|----------------|------------------------------| +| NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | NEEDS ARCH UPDATE | diff --git a/templates/commands/agent.md b/templates/commands/agent.md new file mode 100644 index 0000000000..53db5b78b9 --- /dev/null +++ b/templates/commands/agent.md @@ -0,0 +1,63 @@ +--- +description: Create or update agent governance and refresh agent instruction projections. +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Outline + +You are updating `.specify/memory/agent-governance.md`, the source of truth for how AI agents, skills, MCP tools, and integration adapters operate in this repository. + +**Note**: If `.specify/memory/agent-governance.md` does not exist yet, copy `.specify/templates/agent-governance-template.md` first. + +Follow this execution flow: + +1. Load `.specify/memory/agent-governance.md`. +2. Load supporting context if present: + - `.specify/memory/constitution.md` for project principles and quality gates. + - `.specify/memory/architecture.md` for architecture boundaries. + - `.specify/memory/uc.md` for business semantics. + - `.specify/integration.json` for installed/default integrations. + - Any `SKILL.md` files for skill-local contracts. + - MCP configuration files such as `.mcp.json`, `mcp.json`, `mcp.yml`, or `mcp.yaml`. + - `.specify/extensions.yml` for enabled extensions. +3. Update agent governance: + - Keep the authority order explicit. + - Keep source-of-truth boundaries between constitution, architecture, UC, skills, MCP, and feature artifacts. + - Keep write boundaries testable and concrete. + - Require explicit user intent for mutating MCP calls and external writes. + - Preserve user-authored repo-specific rules unless they conflict with higher authority. +4. Refresh projections: + - `AGENTS.md` + - active integration context files such as `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, and other registered `context_file` paths. + - Preserve content outside `` and ``. +5. Produce a Sync Impact Report in `.specify/memory/agent-governance.md`: + - Active/default integration + - Installed integrations + - Skills scanned + - MCP config files scanned + - Projection files refreshed + - Follow-up TODOs + +## Validation + +- No projection file should duplicate long governance text outside the generated projection markers. +- `AGENTS.md` is the repo-level agent governance projection. +- Agent-specific files are adapters that point back to `AGENTS.md`. +- Do not modify `.specify/memory/constitution.md`, `.specify/memory/architecture.md`, `.specify/memory/uc.md`, feature specs, plans, tasks, source code, tests, CI, MCP config, or secrets unless the user explicitly requested that separate change. + +## Output + +Report: + +- Whether `.specify/memory/agent-governance.md` was created or updated. +- Projection files refreshed. +- Skills and MCP config files detected. +- Any unresolved governance risks. + diff --git a/templates/commands/arch.md b/templates/commands/arch.md new file mode 100644 index 0000000000..632ea76a30 --- /dev/null +++ b/templates/commands/arch.md @@ -0,0 +1,156 @@ +--- +description: Execute the 4+1 architecture workflow and generate architecture view artifacts. +scripts: + sh: scripts/bash/setup-arch.sh --json + ps: scripts/powershell/setup-arch.ps1 -Json +--- + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Goal + +Generate or update the project-level 4+1 architecture artifacts: + +- Main synthesis: `.specify/memory/architecture.md` +- Scenario view: `.specify/memory/architecture-scenario-view.md` +- Logical view: `.specify/memory/architecture-logical-view.md` +- Process view: `.specify/memory/architecture-process-view.md` +- Development view: `.specify/memory/architecture-development-view.md` +- Physical view: `.specify/memory/architecture-physical-view.md` + +The scenario view is the entry point. It produces the UC semantics for this architecture pass: actors, goals, use cases, scenario paths, branches, and acceptance meaning. The other four views are derived from the scenario view. + +## Operating Boundaries + +- Write only the six architecture artifacts listed above. +- Do not require `.specify/memory/uc.md`. If it exists, read it only as supporting reference, not as a hard prerequisite or sole source of truth. +- Do not modify `.specify/memory/uc.md`, `.specify/memory/constitution.md`, feature specs, plans, tasks, source code, tests, or root `docs/`. +- Stay at abstract architecture-design level. +- Do not write concrete classes, files, functions, endpoints, DTO fields, database tables, framework selections, library choices, UI component details, deployment manifests, task breakdowns, test strategy, validation anchors, code notes, deployment scripts, or runbooks. +- If evidence is insufficient, record a specific gap in the affected view instead of inventing business facts, components, interfaces, modules, deployment units, or numeric metrics. + +## Outline + +1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for `ARCH_FILE`, `ARCH_DIR`, `SCENARIO_VIEW`, `LOGICAL_VIEW`, `PROCESS_VIEW`, `DEVELOPMENT_VIEW`, and `PHYSICAL_VIEW`. + +2. **Load context**: + - Read all six architecture artifacts created by setup. + - Read `.specify/memory/uc.md` if present as optional scenario background. + - Read the five view templates under `.specify/templates/`. + +3. **Execute architecture workflow**: + - Phase 0: Fill `SCENARIO_VIEW`. + - Phase 1: Fill `LOGICAL_VIEW` from `SCENARIO_VIEW`. + - Phase 2: Fill `PROCESS_VIEW` from `SCENARIO_VIEW` and `LOGICAL_VIEW`. + - Phase 3: Fill `DEVELOPMENT_VIEW` from `LOGICAL_VIEW` and `PROCESS_VIEW`. + - Phase 4: Fill `PHYSICAL_VIEW` from `PROCESS_VIEW` and `DEVELOPMENT_VIEW`. + - Phase 5: Update `ARCH_FILE` as a synthesis and index over the five views. + +4. **Stop and report**: Report the six updated paths and any explicit unresolved architecture gaps. + +## Phases + +### Phase 0: Scenario View + +**Output**: `.specify/memory/architecture-scenario-view.md` + +Create or update the UC-producing scenario view: + +- Actors and external participants +- Use cases and goals +- Preconditions and scope boundaries +- Main scenario paths +- Alternative and failure branches +- Acceptance semantics +- Open scenario questions + +This phase is authoritative for scenario semantics inside the architecture workflow. Do not defer UC creation to a separate command. + +### Phase 1: Logical View + +**Input**: `.specify/memory/architecture-scenario-view.md` +**Output**: `.specify/memory/architecture-logical-view.md` + +Derive: + +- System capability boundaries +- Domain objects and relationships +- Object ownership and fact sources +- State lifecycle and invariants +- Governance or decision boundaries that are architectural, not organizational process notes + +Do not write class models, DTOs, database tables, field lists, method names, endpoint names, or implementation data structures. + +### Phase 2: Process View + +**Input**: `.specify/memory/architecture-scenario-view.md`, `.specify/memory/architecture-logical-view.md` +**Output**: `.specify/memory/architecture-process-view.md` + +Derive: + +- Main runtime links +- Handoffs and approvals +- Receipts and user participation points +- State advancement across scenario paths +- Failure, degradation, compensation, and closure + +Do not write call stacks, queue names, retry counts, thread/process details, endpoint sequences, or implementation orchestration code. + +### Phase 3: Development View + +**Input**: `.specify/memory/architecture-logical-view.md`, `.specify/memory/architecture-process-view.md` +**Output**: `.specify/memory/architecture-development-view.md` + +Derive: + +- Architecture-level components or capability packages +- Package boundary intent +- Contract and artifact semantics +- Dependency direction and forbidden crossings +- Component responsibility, collaborators, and input/output boundary + +Do not write source file paths, classes, functions, module-by-module implementation tasks, or framework-specific wiring. + +### Phase 4: Physical View + +**Input**: `.specify/memory/architecture-process-view.md`, `.specify/memory/architecture-development-view.md` +**Output**: `.specify/memory/architecture-physical-view.md` + +Derive: + +- Deployment and hosting boundaries +- External system collaboration +- Fact-source placement +- Observability and operational boundaries +- Release or runtime ownership constraints + +Do not write Kubernetes YAML, cloud resource manifests, machine sizes, concrete service SKUs, deployment scripts, or runbooks. + +### Phase 5: Architecture Synthesis + +**Input**: all five view files +**Output**: `architecture.md` + +Update the main synthesis file: + +- View index with links to all five view files +- Architecture axis and central design forces +- Cross-view mapping table +- Key boundaries and constraints +- Open risks and architecture review triggers + +Do not copy every detail from the view files. Summarize the architecture conclusions that connect multiple views. + +## Quality Bar + +- Scenario view must contain enough UC semantics for the other four views to derive from it. +- Every non-placeholder conclusion must be traceable to a scenario, object, runtime link, component boundary, deployment boundary, or stated constraint. +- Use stable names consistently across all five views and the synthesis file. +- Keep uncertainty specific: record what is unknown, which view it affects, and which architecture conclusion cannot yet be made. +- Remove generic statements such as "scalable", "secure", "observable", or "modular" unless they name owner, affected view, scope, and architecture consequence. diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 0b74a6f1a9..ecfeefeb4b 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -252,7 +252,7 @@ def test_init_options_includes_context_file(self, tmp_path): # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "analyze", "checklist", "clarify", "constitution", + "analyze", "arch", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", ] @@ -274,14 +274,20 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "setup-tasks.sh"]: + "setup-arch.sh", "setup-plan.sh", "setup-tasks.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "setup-tasks.ps1"]: + "setup-arch.ps1", "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") - for name in ["checklist-template.md", + for name in ["architecture-development-template.md", + "architecture-logical-template.md", + "architecture-physical-template.md", + "architecture-process-template.md", + "architecture-scenario-template.md", + "architecture-template.md", + "checklist-template.md", "constitution-template.md", "plan-template.md", "spec-template.md", "tasks-template.md"]: files.append(f".specify/templates/{name}") diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 89140de1c3..9b2484f3dc 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -100,7 +100,7 @@ def test_skill_directory_structure(self, tmp_path): skill_files = [f for f in created if "scripts" not in f.parts] expected_commands = { - "analyze", "checklist", "clarify", "constitution", + "analyze", "arch", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", } @@ -359,7 +359,7 @@ def test_options_include_skills_flag(self): # -- Complete file inventory ------------------------------------------ _SKILL_COMMANDS = [ - "analyze", "checklist", "clarify", "constitution", + "analyze", "arch", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", ] @@ -386,6 +386,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-arch.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", ] @@ -394,11 +395,18 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-arch.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ] # Templates files += [ + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 56862e534c..689878a3da 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -484,6 +484,7 @@ def test_init_options_includes_context_file(self, tmp_path): COMMAND_STEMS = [ "analyze", + "arch", "checklist", "clarify", "constitution", @@ -515,6 +516,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "setup-arch.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -524,12 +526,19 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "setup-arch.ps1", "setup-plan.ps1", "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ + "architecture-development-template.md", + "architecture-logical-template.md", + "architecture-physical-template.md", + "architecture-process-template.md", + "architecture-scenario-template.md", + "architecture-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 956c7a796f..3111b6b78a 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -363,6 +363,7 @@ def test_init_options_includes_context_file(self, tmp_path): COMMAND_STEMS = [ "analyze", + "arch", "checklist", "clarify", "constitution", @@ -394,6 +395,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "setup-arch.sh", "setup-plan.sh", "setup-tasks.sh", ]: @@ -403,12 +405,19 @@ def _expected_files(self, script_variant: str) -> list[str]: "check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "setup-arch.ps1", "setup-plan.ps1", "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ + "architecture-development-template.md", + "architecture-logical-template.md", + "architecture-physical-template.md", + "architecture-process-template.md", + "architecture-scenario-template.md", + "architecture-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index c6e9259b09..e7d47cfc68 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -125,9 +125,9 @@ def test_directory_structure(self, tmp_path): agents_dir = tmp_path / ".github" / "agents" assert agents_dir.is_dir() agent_files = sorted(agents_dir.glob("speckit.*.agent.md")) - assert len(agent_files) == 9 + assert len(agent_files) == 10 expected_commands = { - "analyze", "checklist", "clarify", "constitution", + "analyze", "arch", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", } actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files} @@ -179,6 +179,7 @@ def test_complete_file_inventory_sh(self, tmp_path): actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) expected = sorted([ ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.arch.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", ".github/agents/speckit.constitution.agent.md", @@ -188,6 +189,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.arch.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", ".github/prompts/speckit.constitution.prompt.md", @@ -205,8 +207,15 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-arch.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -239,6 +248,7 @@ def test_complete_file_inventory_ps(self, tmp_path): actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) expected = sorted([ ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.arch.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", ".github/agents/speckit.constitution.agent.md", @@ -248,6 +258,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.arch.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", ".github/prompts/speckit.constitution.prompt.md", @@ -265,8 +276,15 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-arch.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -286,7 +304,7 @@ class TestCopilotSkillsMode: """Tests for Copilot integration in --skills mode.""" _SKILL_COMMANDS = [ - "analyze", "checklist", "clarify", "constitution", + "analyze", "arch", "checklist", "clarify", "constitution", "implement", "plan", "specify", "tasks", "taskstoissues", ] @@ -615,9 +633,16 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-arch.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", # Templates + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -724,4 +749,4 @@ def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): # Must NOT show the dotted /speckit.plan form assert "/speckit.plan" not in result.output, ( f"Should not show /speckit.plan in skills mode:\n{result.output}" - ) \ No newline at end of file + ) diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 4f515a01d2..9ce5964e04 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -257,6 +257,7 @@ def test_complete_file_inventory_sh(self, tmp_path): expected = sorted([ "AGENTS.md", ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.arch.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", ".myagent/commands/speckit.constitution.md", @@ -273,8 +274,15 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-arch.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -313,6 +321,7 @@ def test_complete_file_inventory_ps(self, tmp_path): expected = sorted([ "AGENTS.md", ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.arch.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", ".myagent/commands/speckit.constitution.md", @@ -329,8 +338,15 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-arch.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", + ".specify/templates/architecture-development-template.md", + ".specify/templates/architecture-logical-template.md", + ".specify/templates/architecture-physical-template.md", + ".specify/templates/architecture-process-template.md", + ".specify/templates/architecture-scenario-template.md", + ".specify/templates/architecture-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_agent_projection.py b/tests/test_agent_projection.py new file mode 100644 index 0000000000..cac2e14ba9 --- /dev/null +++ b/tests/test_agent_projection.py @@ -0,0 +1,81 @@ +import json +import shutil +from pathlib import Path + +from specify_cli.agent_projection import ( + AGENT_GOVERNANCE_MEMORY, + PROJECTION_MARKER_START, + ensure_agent_governance_from_template, + refresh_agent_projection, +) + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _copy_template(project: Path, name: str) -> None: + dest = project / ".specify" / "templates" / name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(REPO_ROOT / "templates" / name, dest) + + +def test_ensure_agent_governance_from_template(tmp_path): + _copy_template(tmp_path, "agent-governance-template.md") + + result = ensure_agent_governance_from_template(tmp_path) + + assert result == tmp_path / AGENT_GOVERNANCE_MEMORY + content = result.read_text(encoding="utf-8") + assert "# Agent Governance" in content + assert "## Authority Order" in content + + +def test_refresh_agent_projection_creates_repo_and_agent_adapters(tmp_path): + _copy_template(tmp_path, "agent-governance-template.md") + (tmp_path / ".specify" / "integration.json").parent.mkdir(parents=True, exist_ok=True) + (tmp_path / ".specify" / "integration.json").write_text( + json.dumps( + { + "integration": "gemini", + "default_integration": "gemini", + "installed_integrations": ["gemini", "copilot"], + "integration_settings": {}, + } + ), + encoding="utf-8", + ) + (tmp_path / ".gemini" / "commands" / "speckit-test" / "SKILL.md").parent.mkdir( + parents=True, + exist_ok=True, + ) + (tmp_path / ".gemini" / "commands" / "speckit-test" / "SKILL.md").write_text( + "# Test Skill\n", + encoding="utf-8", + ) + (tmp_path / ".mcp.json").write_text("{}", encoding="utf-8") + + result = refresh_agent_projection(tmp_path) + + assert result.memory_path == tmp_path / AGENT_GOVERNANCE_MEMORY + assert (tmp_path / "AGENTS.md").exists() + assert (tmp_path / "GEMINI.md").exists() + assert (tmp_path / ".github" / "copilot-instructions.md").exists() + agents = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert PROJECTION_MARKER_START in agents + assert "Default integration: `gemini`" in agents + assert "`.gemini/commands/speckit-test/SKILL.md`" in agents + assert "`.mcp.json`" in agents + + +def test_refresh_agent_projection_preserves_user_content(tmp_path): + _copy_template(tmp_path, "agent-governance-template.md") + agents = tmp_path / "AGENTS.md" + agents.write_text("# Custom Rules\n\nKeep this.\n", encoding="utf-8") + + refresh_agent_projection(tmp_path) + + content = agents.read_text(encoding="utf-8") + assert "# Custom Rules" in content + assert "Keep this." in content + assert PROJECTION_MARKER_START in content + diff --git a/tests/test_arch_templates.py b/tests/test_arch_templates.py new file mode 100644 index 0000000000..a76dc10d33 --- /dev/null +++ b/tests/test_arch_templates.py @@ -0,0 +1,76 @@ +"""Quality guards for 4+1 architecture templates and command.""" + +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = PROJECT_ROOT / "templates" + + +def _read_template(name: str) -> str: + return (TEMPLATES / name).read_text(encoding="utf-8") + + +def test_arch_command_is_phase_based_and_does_not_require_uc_command(): + content = _read_template("commands/arch.md") + + assert "scripts:" in content + assert "setup-arch.sh --json" in content + assert "setup-arch.ps1 -Json" in content + for phase in [ + "Phase 0: Scenario View", + "Phase 1: Logical View", + "Phase 2: Process View", + "Phase 3: Development View", + "Phase 4: Physical View", + "Phase 5: Architecture Synthesis", + ]: + assert phase in content + assert "Do not require `.specify/memory/uc.md`" in content + assert "__SPECKIT_COMMAND_UC__" not in content + assert "Read `.specify/memory/constitution.md`" not in content + assert ".specify/memory/architecture/" not in content + + +def test_architecture_synthesis_references_five_view_files(): + content = _read_template("architecture-template.md") + + for filename in [ + "architecture-scenario-view.md", + "architecture-logical-view.md", + "architecture-process-view.md", + "architecture-development-view.md", + "architecture-physical-view.md", + ]: + assert f".specify/memory/{filename}" in content + assert "Cross-View Mapping" in content + assert "Key Architecture Conclusions" in content + assert ".specify/memory/architecture/" not in content + + +def test_init_next_steps_place_arch_before_constitution(): + init_source = (PROJECT_ROOT / "src" / "specify_cli" / "__init__.py").read_text(encoding="utf-8") + + arch_index = init_source.index("_display_cmd('arch')") + constitution_index = init_source.index("_display_cmd('constitution')") + + assert arch_index < constitution_index + + +def test_view_templates_define_inputs_and_reject_implementation_detail(): + scenario = _read_template("architecture-scenario-template.md") + logical = _read_template("architecture-logical-template.md") + process = _read_template("architecture-process-template.md") + development = _read_template("architecture-development-template.md") + physical = _read_template("architecture-physical-template.md") + + assert "Produce the UC semantics" in scenario + assert "Do not write architecture components" in scenario + assert "**Input**: `.specify/memory/architecture-scenario-view.md`" in logical + assert "Do not write classes, DTOs, database tables" in logical + assert "**Input**: `.specify/memory/architecture-scenario-view.md`, `.specify/memory/architecture-logical-view.md`" in process + assert "Do not write call stacks, queue names, retry counts" in process + assert "**Input**: `.specify/memory/architecture-logical-view.md`, `.specify/memory/architecture-process-view.md`" in development + assert "Do not write source file paths, concrete package trees" in development + assert "**Input**: `.specify/memory/architecture-process-view.md`, `.specify/memory/architecture-development-view.md`" in physical + assert "Do not write Kubernetes YAML, cloud resource manifests" in physical diff --git a/tests/test_setup_arch.py b/tests/test_setup_arch.py new file mode 100644 index 0000000000..3f9a5e3e2b --- /dev/null +++ b/tests/test_setup_arch.py @@ -0,0 +1,166 @@ +"""Tests for setup-arch project-level architecture artifact initialization.""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_ARCH_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-arch.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_ARCH_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-arch.ps1" +ARCH_TEMPLATES = [ + "architecture-template.md", + "architecture-scenario-template.md", + "architecture-logical-template.md", + "architecture-process-template.md", + "architecture-development-template.md", + "architecture-physical-template.md", +] + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_ARCH_SH, d / "setup-arch.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_ARCH_PS, d / "setup-arch.ps1") + + +def _install_templates(repo: Path) -> None: + d = repo / ".specify" / "templates" + d.mkdir(parents=True, exist_ok=True) + for name in ARCH_TEMPLATES: + shutil.copy(PROJECT_ROOT / "templates" / name, d / name) + + +def _clean_env() -> dict[str, str]: + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _powershell_script_arg(exe: str, script: Path) -> str: + if sys.platform != "win32" and str(exe).endswith("powershell.exe") and shutil.which("wslpath"): + result = subprocess.run( + ["wslpath", "-w", str(script)], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + return str(script) + + +@pytest.fixture +def arch_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + (repo / ".specify").mkdir() + _install_templates(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +def _json_from_output(output: str) -> dict[str, str]: + for line in reversed(output.strip().splitlines()): + line = line.strip() + if line.startswith("{") and line.endswith("}"): + return json.loads(line) + raise AssertionError(f"No JSON object found in output:\n{output}") + + +def _assert_arch_json(repo: Path, data: dict[str, str], *, exact_paths: bool = True) -> None: + expected = { + "ARCH_FILE": repo / ".specify" / "memory" / "architecture.md", + "ARCH_DIR": repo / ".specify" / "memory", + "SCENARIO_VIEW": repo / ".specify" / "memory" / "architecture-scenario-view.md", + "LOGICAL_VIEW": repo / ".specify" / "memory" / "architecture-logical-view.md", + "PROCESS_VIEW": repo / ".specify" / "memory" / "architecture-process-view.md", + "DEVELOPMENT_VIEW": repo / ".specify" / "memory" / "architecture-development-view.md", + "PHYSICAL_VIEW": repo / ".specify" / "memory" / "architecture-physical-view.md", + } + assert set(data) == set(expected) + for key, path in expected.items(): + if exact_paths: + assert Path(data[key]) == path + else: + normalized = data[key].replace("\\", "/") + assert normalized.endswith(path.relative_to(repo).as_posix()) + assert path.is_file() if key != "ARCH_DIR" else path.is_dir() + + +@requires_bash +def test_setup_arch_bash_creates_all_artifacts_and_json(arch_repo: Path) -> None: + script = arch_repo / ".specify" / "scripts" / "bash" / "setup-arch.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=arch_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + data = _json_from_output(result.stdout) + _assert_arch_json(arch_repo, data) + assert "Scenario View" in (arch_repo / ".specify" / "memory" / "architecture-scenario-view.md").read_text(encoding="utf-8") + + +@requires_bash +def test_setup_arch_bash_preserves_existing_files(arch_repo: Path) -> None: + existing = arch_repo / ".specify" / "memory" / "architecture-scenario-view.md" + existing.parent.mkdir(parents=True) + existing.write_text("# Custom Scenario\n", encoding="utf-8") + + script = arch_repo / ".specify" / "scripts" / "bash" / "setup-arch.sh" + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=arch_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert existing.read_text(encoding="utf-8") == "# Custom Scenario\n" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_arch_powershell_creates_all_artifacts_and_json(arch_repo: Path) -> None: + script = arch_repo / ".specify" / "scripts" / "powershell" / "setup-arch.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", _powershell_script_arg(exe, script), "-Json"], + cwd=arch_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + data = _json_from_output(result.stdout) + _assert_arch_json(arch_repo, data, exact_paths=False)