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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/guidelines/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions .repo-seed-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
".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",
"docs/templates/.github/feature-request.template.md": "7110742210d16dbf64f01347579189cc653dc049ddcc1386ea4b8325646829ad",
"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": {}
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/templates/readme.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/templates/tsd.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 100 additions & 4 deletions scripts/sync-docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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, ...]
Expand All @@ -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, ...]

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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),
)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
Loading