diff --git a/.agents/guidelines/documentation.md b/.agents/guidelines/documentation.md index 3607fd2..fc81478 100644 --- a/.agents/guidelines/documentation.md +++ b/.agents/guidelines/documentation.md @@ -63,8 +63,9 @@ When asked to populate documentation for an existing repository: 1. inspect source, tests, configuration, schemas, workflows, assets, and useful Git history; -2. populate FSD, GDD, architecture, user guidance, README, and changelog only - from relevant evidence; +2. populate only applicable documents from relevant evidence: FSD for + applications or GDD for games, architecture for libraries/applications/games, + user guidance for user-facing products, plus README and changelog; 3. distinguish intended behavior, verified as-built behavior, local inference, and unknowns where the difference matters; 4. cite repository evidence for meaningful inferences and request confirmation diff --git a/.repo-seed-state.json b/.repo-seed-state.json index e32a730..23d9826 100644 --- a/.repo-seed-state.json +++ b/.repo-seed-state.json @@ -5,9 +5,9 @@ ".agents/conventions/scripts.md": "b5f2340822cd7402d4eb6087428c0b62ffd71802824559c8492f778afcfda245", ".agents/conventions/shell.md": "165c0382882a8cce818be34368dc5b29a0297d7c2c66a40fd7e4729c397764b9", ".agents/guidelines/ci-cd.md": "e4d6bd8bc0f0696f27501ec87fa3c25d85b103e17bfe7a8993fc61ff63d4faa0", - ".agents/guidelines/documentation.md": "07c41e49fabc3ca3807246fe451611b96e6549ac991156e7fbd46271b1bc33f0", + ".agents/guidelines/documentation.md": "c73b9a220b282dabfefd5853bf0032ee485f02530a2ec3aa1e765b2762f78a62", ".agents/guidelines/git.md": "4773262f86d33c6e7c8cae87f652a2e4f979de79956bc723a5374dc6889eede5", - "AGENTS.md": "6585e8c7d15fbeadb98b3945fd79c575db62ccc3efab39535f2055bc536f0694", + "AGENTS.md": "9c33c47abef0e42a2be3e48fd95cddef8af5935ffdda15d7921c8f4e961c7025", "CLAUDE.md": "7050308e6cd5b0b953105fddc8fe5a4d259038dc8de1183414833bc90788554a", "docs/templates/.github/bug-report.template.md": "9e898d893d05a6962300f774120350559082f6b1d68be76e30c2696c1efdb53e", "docs/templates/.github/config.template.yml": "ade4f33049e309848b2f0a64d11c41be3204cd476ac112ee311cdd320cd39313", @@ -15,11 +15,11 @@ "docs/templates/architecture.template.md": "701a4193f0c5eb903af5172e2457af01e5148bddbeae8343b80fa1d02ed4fe90", "docs/templates/changelog.template.md": "731c8ef26b6104fd569f516f344197c0437aa609256aa10257eb3eaff6f6d427", "docs/templates/editorconfig.template": "74b219dcd26beaefc51a9892f5e74b136ac30ff0b04ef70039fbaa9903ead77a", - "docs/templates/readme.template.md": "d0be705d90d1c0cff0f27ed9e70f3848a3ad6498f38d918a2821fe4c2e98ba1c", - "docs/templates/tsd.template.md": "718bf526bfabaa33b34f6fc80fadca19ff5fae790a07d2a93239b2004607b7bf", - "scripts/sync-docs.py": "c6cf2eb16b3b8ebea81308572b0abfc92ab3589ecc98f0c4ff703b8b4068646e" + "docs/templates/readme.template.md": "dedfdbef06ccca95a257f1d16d761fb46f976a41a5682f2b6c42ddf3a549298a", + "docs/templates/tsd.template.md": "7d03ea43180ad7388a4ec19aa04126a5825f0cb15518d10e575bcaca11136f93", + "scripts/sync-docs.py": "f00b7a8807162df1af9d6eb3b7b1d8d1aca4c17aa51c4588607c00c160032653" }, - "pack_version": "4.0.0", + "pack_version": "4.0.1", "profile": "library", "schema_version": 1, "tombstones": {} diff --git a/AGENTS.md b/AGENTS.md index d376381..3ed0e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Repository-level instructions for coding agents. **Document role**: Managed coding-agent instructions **Sync destination**: `AGENTS.md` -**Version**: 4.0.0 +**Version**: 4.0.1 ## Start Here @@ -119,8 +119,10 @@ Do not customize these managed files in target repositories. Project-owned files include `.agents/project.md`, child `AGENTS.md` files, root `README.md` and `CHANGELOG.md`, `.editorconfig`, `.gitignore`, `docs/project/`, -and every unmapped path. Scaffolding creates missing project-owned files and -may upgrade Markdown only while repo-seed provenance proves it unchanged. +`.github/workflows/`, and every unmapped path. The sync manifest cannot manage, +scaffold, retire, or delete workflow files. Scaffolding creates missing +project-owned files and may upgrade Markdown only while repo-seed provenance +proves it unchanged. ## Completion diff --git a/docs/templates/readme.template.md b/docs/templates/readme.template.md index 23d3b1f..2d55d19 100644 --- a/docs/templates/readme.template.md +++ b/docs/templates/readme.template.md @@ -55,7 +55,7 @@ Add examples for common usage. ## Documentation -- [`CHANGELOG.md`](CHANGELOG.md) — version history +- `CHANGELOG.md` — version history Link the live project documents under `docs/project/` that exist in this repository, such as the architecture document, feature list, or user guide. diff --git a/docs/templates/tsd.template.md b/docs/templates/tsd.template.md index b370d73..85b630b 100644 --- a/docs/templates/tsd.template.md +++ b/docs/templates/tsd.template.md @@ -12,10 +12,10 @@ **Owner**: **Last Updated**: YYYY-MM-DD -Describe one substantial technical change. Reference the FSD or GDD for -behavior and `docs/project/architecture.md` for the current system. This design -records a transition; it is not the canonical description of the current -architecture. +Describe one substantial technical change. Reference the applicable requirement, +issue, acceptance criteria, FSD, or GDD for behavior and +`docs/project/architecture.md` for the current system. This design records a +transition; it is not the canonical description of the current architecture. Create a design for changes involving multiple components, public contracts, persistence or migration, security, concurrency, deployment, compatibility, diff --git a/scripts/sync-docs.py b/scripts/sync-docs.py index b67c887..e715ef4 100644 --- a/scripts/sync-docs.py +++ b/scripts/sync-docs.py @@ -12,13 +12,14 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath -SCRIPT_VERSION = "4.0.0" +SCRIPT_VERSION = "4.0.1" MANIFEST_FILE = "manifest.json" FILES_DIRECTORY = "files" TEMPLATE_METADATA_START = "repo-seed-template:start" TEMPLATE_METADATA_END = "repo-seed-template:end" VALID_TYPES = {"managed", "template"} VALID_SCAFFOLD_GROUPS = {"project", "github", "editorconfig"} +PROJECT_OWNED_TREES = (".github/workflows",) SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") SCAFFOLD_SOURCE_PATTERN = re.compile( @@ -51,6 +52,12 @@ class RetiredPathSet: paths: tuple[str, ...] +@dataclass(frozen=True) +class RetiredAsset: + path: str + content_hashes: tuple[str, ...] + + @dataclass(frozen=True) class ScaffoldUpgrade: from_versions: tuple[str, ...] @@ -65,6 +72,7 @@ class MigrationConfig: legacy_version: str legacy_conflicts: str protected_paths: tuple[str, ...] + retired_assets: tuple[RetiredAsset, ...] retired_path_sets: tuple[RetiredPathSet, ...] scaffold_upgrades: tuple[ScaffoldUpgrade, ...] @@ -128,6 +136,18 @@ def safe_child(root: Path, value: str, context: str) -> Path: return child +def is_project_owned_tree_path(value: str) -> bool: + return any( + value == tree or value.startswith(f"{tree}/") + for tree in PROJECT_OWNED_TREES + ) + + +def reject_project_owned_tree_path(value: str, context: str) -> None: + if is_project_owned_tree_path(value): + raise ValueError(f"{context} cannot use project-owned tree: {value}") + + def require_string(data: dict[str, object], key: str, context: str) -> str: value = data.get(key) if not isinstance(value, str) or not value: @@ -179,6 +199,26 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig for index, path in enumerate(protected_paths): relative_path(path, f"manifest.migration.protected_paths[{index}]") + raw_retired_assets = value.get("retired_assets") + if not isinstance(raw_retired_assets, list): + raise ValueError("manifest.migration.retired_assets must be an array") + retired_assets: list[RetiredAsset] = [] + retired_asset_paths: set[str] = set() + for index, raw_asset in enumerate(raw_retired_assets): + context = f"manifest.migration.retired_assets[{index}]" + if not isinstance(raw_asset, dict): + raise ValueError(f"{context} must be an object") + path = require_string(raw_asset, "path", context) + content_hashes = require_string_list(raw_asset, "content_hashes", context) + relative_path(path, f"{context}.path") + reject_project_owned_tree_path(path, f"{context}.path") + if path in retired_asset_paths: + raise ValueError(f"Duplicate retired asset: {path}") + if not all(SHA256_PATTERN.fullmatch(hash_value) for hash_value in content_hashes): + raise ValueError(f"{context}.content_hashes must contain SHA-256 values") + retired_asset_paths.add(path) + retired_assets.append(RetiredAsset(path=path, content_hashes=content_hashes)) + raw_sets = value.get("retired_path_sets") if not isinstance(raw_sets, list) or not raw_sets: raise ValueError("manifest.migration.retired_path_sets must be a non-empty array") @@ -193,6 +233,7 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig paths = require_string_list(raw_set, "paths", context) for path_index, path in enumerate(paths): relative_path(path, f"{context}.paths[{path_index}]") + reject_project_owned_tree_path(path, f"{context}.paths[{path_index}]") if path in retired_paths: raise ValueError(f"Duplicate retired path: {path}") retired_paths.add(path) @@ -201,11 +242,31 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig if retired_paths.intersection(protected_paths): raise ValueError("Protected paths cannot also be retired") current_paths = {asset.path for asset in assets} - if retired_paths.intersection(current_paths): + scaffold_targets = { + asset.scaffold_target + for asset in assets + if asset.scaffold_target is not None + } + if current_paths.intersection(protected_paths): + raise ValueError("Current managed paths cannot also be protected") + if retired_paths.intersection(current_paths) or retired_asset_paths.intersection(current_paths): raise ValueError("Retired paths cannot collide with current managed paths") + if ( + retired_asset_paths.intersection(protected_paths) + or retired_asset_paths.intersection(retired_paths) + or retired_asset_paths.intersection(scaffold_targets) + or retired_paths.intersection(scaffold_targets) + ): + raise ValueError( + "Retired paths cannot collide with protected paths or scaffold targets" + ) if legacy_manifest in retired_paths or legacy_conflicts in retired_paths: raise ValueError("Legacy manifest and conflict paths cannot also be retired") - if state_paths.intersection(current_paths) or state_paths.intersection(protected_paths): + if ( + state_paths.intersection(current_paths) + or state_paths.intersection(protected_paths) + or state_paths.intersection(retired_asset_paths) + ): raise ValueError("Legacy state paths cannot collide with current or protected paths") raw_upgrades = value.get("scaffold_upgrades") @@ -226,11 +287,16 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig version_key(version, f"{context}.from_versions[{version_index}]") relative_path(legacy_target, f"{context}.legacy_target") relative_path(template, f"{context}.template") + reject_project_owned_tree_path(legacy_target, f"{context}.legacy_target") if legacy_target in legacy_targets: raise ValueError(f"Duplicate scaffold legacy target: {legacy_target}") if template not in asset_by_path or asset_by_path[template].asset_type != "template": raise ValueError(f"{context}.template must reference a template asset") - if legacy_target in current_paths or legacy_target in protected_paths: + if ( + legacy_target in current_paths + or legacy_target in protected_paths + or legacy_target in retired_asset_paths + ): raise ValueError(f"{context}.legacy_target cannot be managed or protected") if not all(SHA256_PATTERN.fullmatch(hash_value) for hash_value in content_hashes): raise ValueError(f"{context}.content_hashes must contain SHA-256 values") @@ -249,6 +315,7 @@ def load_migration(value: object, assets: tuple[Asset, ...]) -> MigrationConfig legacy_version=legacy_version, legacy_conflicts=legacy_conflicts, protected_paths=protected_paths, + retired_assets=tuple(retired_assets), retired_path_sets=tuple(retired_sets), scaffold_upgrades=tuple(upgrades), ) @@ -328,6 +395,7 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif scaffold_target = raw_asset.get("scaffold_target") relative_path(path, f"{context}.path") + reject_project_owned_tree_path(path, f"{context}.path") if path in paths: raise ValueError(f"Duplicate asset path: {path}") if asset_type not in VALID_TYPES: @@ -350,6 +418,10 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif if not isinstance(scaffold_target, str) or not scaffold_target: raise ValueError(f"{context}.scaffold_target must be a non-empty string") relative_path(scaffold_target, f"{context}.scaffold_target") + reject_project_owned_tree_path( + scaffold_target, + f"{context}.scaffold_target", + ) if scaffold_target in scaffold_targets: raise ValueError(f"Duplicate scaffold target: {scaffold_target}") scaffold_targets.add(scaffold_target) @@ -388,6 +460,12 @@ def load_manifest(source_root: Path, validate_sources: bool = True) -> PackManif migration.legacy_version, migration.legacy_conflicts, *migration.protected_paths, + *(asset.path for asset in migration.retired_assets), + *( + path + for retired_set in migration.retired_path_sets + for path in retired_set.paths + ), }: raise ValueError("manifest.state_file cannot collide with migration paths") @@ -538,6 +616,20 @@ def read_managed_state(target_root: Path, manifest: PackManifest) -> ManagedStat if not isinstance(raw.get("profile"), str) or not raw["profile"]: raise ValueError("Managed state profile must be a non-empty string") + allowed_paths = {asset.path for asset in manifest.assets} + protected_paths: set[str] = set() + retired_hashes: dict[str, set[str]] = {} + if manifest.migration: + protected_paths.update(manifest.migration.protected_paths) + retired_hashes.update( + { + asset.path: set(asset.content_hashes) + for asset in manifest.migration.retired_assets + } + ) + allowed_paths.update(protected_paths) + allowed_paths.update(retired_hashes) + def hash_map(key: str) -> dict[str, str]: value = raw.get(key) if not isinstance(value, dict): @@ -551,6 +643,10 @@ def hash_map(key: str) -> dict[str, str]: raise ValueError("Managed state cannot own itself") if not SHA256_PATTERN.fullmatch(hash_value): raise ValueError(f"Managed state hash is invalid for: {path}") + if path not in allowed_paths: + raise ValueError(f"Managed state contains an unknown pack-owned path: {path}") + if path in retired_hashes and hash_value not in retired_hashes[path]: + raise ValueError(f"Managed state hash is not recognized for retired asset: {path}") result[path] = hash_value return result