diff --git a/.github/instruction-surfaces.json b/.github/instruction-surfaces.json index 66c5b412d..8f33f84d0 100644 --- a/.github/instruction-surfaces.json +++ b/.github/instruction-surfaces.json @@ -875,6 +875,18 @@ "review_owner": "z-shell maintainers", "canonical_for": ["chroma-command-knowledge"] }, + { + "id": "decision-index", + "path": "decisions/README.md", + "kind": "runbook", + "authority": "advisory", + "consumers": ["codex", "claude-code", "copilot", "gemini-cli", "human"], + "tasks": ["architecture-decision", "onboarding"], + "file_patterns": ["**"], + "required": false, + "review_owner": "z-shell maintainers", + "canonical_for": [] + }, { "id": "zsh-standard-policy", "path": "lib/zsh-standard-policy.json", @@ -915,6 +927,18 @@ "review_owner": "z-shell maintainers", "canonical_for": ["public-policy-validation"] }, + { + "id": "decision-records-validator", + "path": "scripts/decision-records.py", + "kind": "enforcement", + "authority": "canonical-detail", + "consumers": ["ci", "human"], + "tasks": ["architecture-decision", "validation"], + "file_patterns": ["**"], + "required": true, + "review_owner": "z-shell maintainers", + "canonical_for": ["decision-record-validation"] + }, { "id": "agent-instruction-workflow", "path": ".github/workflows/agent-instructions.yml", diff --git a/.github/workflows/agent-instructions.yml b/.github/workflows/agent-instructions.yml index 90a6b535c..f1b096837 100644 --- a/.github/workflows/agent-instructions.yml +++ b/.github/workflows/agent-instructions.yml @@ -26,6 +26,8 @@ on: - "scripts/test_validate_agent_policy.py" - "scripts/validate-zsh-standard-policy.py" - "scripts/test_validate_zsh_standard_policy.py" + - "scripts/decision-records.py" + - "scripts/test_decision_records.py" push: branches: - main @@ -53,6 +55,8 @@ on: - "scripts/test_validate_agent_policy.py" - "scripts/validate-zsh-standard-policy.py" - "scripts/test_validate_zsh_standard_policy.py" + - "scripts/decision-records.py" + - "scripts/test_decision_records.py" permissions: contents: read @@ -80,6 +84,9 @@ jobs: - name: Run agent policy unit tests run: python3 -m unittest scripts/test_validate_agent_policy.py -v + - name: Run decision record unit tests + run: python3 -m unittest scripts/test_decision_records.py -v + - name: Run Zsh standard policy unit tests run: python3 -m unittest scripts/test_validate_zsh_standard_policy.py -v @@ -88,3 +95,6 @@ jobs: - name: Validate agent policy run: python3 scripts/validate-agent-policy.py + + - name: Validate decision records and index + run: python3 scripts/decision-records.py --check diff --git a/decisions/0011-zsh-lint-semantic-analyzer-architecture.md b/decisions/0011-zsh-lint-semantic-analyzer-architecture.md index 5e529ed02..9b763168a 100644 --- a/decisions/0011-zsh-lint-semantic-analyzer-architecture.md +++ b/decisions/0011-zsh-lint-semantic-analyzer-architecture.md @@ -1,12 +1,10 @@ # 11. zsh-lint Conditional Semantic Analysis Pipeline -Date: 2026-07-25 - -Deciders: ss-o - -## Status - -ACCEPTED +- **Status:** ACCEPTED +- **Date:** 2026-07-25 +- **Deciders:** ss-o +- **Supersedes:** None +- **Superseded by:** None ## Context diff --git a/decisions/0018-portable-worktree-management.md b/decisions/0018-portable-worktree-management.md index 7ab4f03e1..07d3cc7a8 100644 --- a/decisions/0018-portable-worktree-management.md +++ b/decisions/0018-portable-worktree-management.md @@ -1,6 +1,6 @@ # 18. Adopt Portable Worktree Management -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-27 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/0020-adopt-zsh-plugin-standard-2.md b/decisions/0020-adopt-zsh-plugin-standard-2.md index c9a3f854f..1ef9b6135 100644 --- a/decisions/0020-adopt-zsh-plugin-standard-2.md +++ b/decisions/0020-adopt-zsh-plugin-standard-2.md @@ -1,6 +1,6 @@ # 20. Adopt Zsh Plugin Standard 2 as a Clean Portable Contract -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-28 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/0021-derive-chroma-knowledge-at-runtime.md b/decisions/0021-derive-chroma-knowledge-at-runtime.md index 2052027ff..2319b80bd 100644 --- a/decisions/0021-derive-chroma-knowledge-at-runtime.md +++ b/decisions/0021-derive-chroma-knowledge-at-runtime.md @@ -1,6 +1,6 @@ # 21. Derive Chroma Command Knowledge at Runtime -- **Status:** PROPOSED +- **Status:** ACCEPTED - **Date:** 2026-08-29 - **Deciders:** ss-o - **Supersedes:** None diff --git a/decisions/README.md b/decisions/README.md new file mode 100644 index 000000000..6bc8bf0f1 --- /dev/null +++ b/decisions/README.md @@ -0,0 +1,34 @@ + + +# Architecture decision records + +Durable organization decisions. Draft new records with `runbooks/adr.md`; only +a maintainer moves a record from `PROPOSED` to `ACCEPTED`. + +| ADR | Title | Status | Date | Deciders | +| ------------------------------------------------------- | -------------------------------------------------------------- | -------- | ---------- | ----------------- | +| [0001](0001-meta-repo-and-agents-md.md) | Adopt a meta-repo pattern centered on `AGENTS.md` | ACCEPTED | 2026-05-29 | ss-o | +| [0002](0002-zi-as-canonical-plugin-manager.md) | `zi` is the canonical plugin manager for the z-shell ecosystem | ACCEPTED | 2026-05-29 | ss-o | +| [0003](0003-conventional-commits.md) | Adopt Conventional Commits across z-shell repositories | ACCEPTED | 2026-05-29 | ss-o | +| [0004](0004-dependabot-unification.md) | Standardize on Dependabot for Dependency Management | ACCEPTED | 2026-05-20 | ss-o, Gemini CLI | +| [0005](0005-workflow-naming-conventions.md) | No Emojis in Workflow and Job Name Fields | ACCEPTED | 2026-05-21 | ss-o, Claude Code | +| [0006](0006-wiki-content-root-boundaries.md) | Wiki Content-Root Boundaries | ACCEPTED | 2026-05-29 | ss-o, Claude Code | +| [0007](0007-release-publication-flow.md) | Release and Publication Flow | ACCEPTED | 2026-05-26 | ss-o, Claude Code | +| [0008](0008-branching-model.md) | Branching Model | ACCEPTED | 2026-07-25 | ss-o | +| [0009](0009-testing-ci-strategy.md) | Testing and CI Strategy | ACCEPTED | 2026-07-25 | ss-o | +| [0010](0010-security-incident-response.md) | Security Incident Response | PROPOSED | 2026-05-29 | TBD | +| [0011](0011-zsh-lint-semantic-analyzer-architecture.md) | zsh-lint Conditional Semantic Analysis Pipeline | ACCEPTED | 2026-07-25 | ss-o | +| [0012](0012-hybrid-dependency-management.md) | Split Dependency Updates Between Renovate and Dependabot | ACCEPTED | 2026-06-21 | ss-o | +| [0013](0013-repository-settings-baseline.md) | Repository Settings Baseline by Class | ACCEPTED | 2026-07-25 | ss-o | +| [0014](0014-portable-agent-instruction-architecture.md) | Adopt portable agent-instruction delivery | ACCEPTED | 2026-07-23 | ss-o | +| [0015](0015-zsh-scripting-standard.md) | Adopt an organization-wide Zsh scripting standard | ACCEPTED | 2026-08-27 | ss-o | +| [0016](0016-promotion-trigger-criteria.md) | Next-to-Main Promotion Trigger Criteria | ACCEPTED | 2026-08-16 | ss-o | +| [0017](0017-licensing-standard-by-provenance.md) | Licensing Standard by Provenance and Consumption | ACCEPTED | 2026-08-18 | ss-o | +| [0018](0018-portable-worktree-management.md) | Adopt Portable Worktree Management | ACCEPTED | 2026-08-27 | ss-o | +| [0019](0019-trunk-on-main-default.md) | Trunk-on-Main Default with a Zi Integration Exception | ACCEPTED | 2026-08-28 | ss-o | +| [0020](0020-adopt-zsh-plugin-standard-2.md) | Adopt Zsh Plugin Standard 2 as a Clean Portable Contract | ACCEPTED | 2026-08-28 | ss-o | +| [0021](0021-derive-chroma-knowledge-at-runtime.md) | Derive Chroma Command Knowledge at Runtime | ACCEPTED | 2026-08-29 | ss-o | diff --git a/scripts/decision-records.py b/scripts/decision-records.py new file mode 100644 index 000000000..3227bf7ce --- /dev/null +++ b/scripts/decision-records.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Validate ADR header blocks and render the decision index. + +Why this exists: + +`decisions/` had no index and no machine-checked header contract. A decision +record whose status went stale, or whose header used a different shape from its +neighbours, was discoverable only by reading every file. That is exactly the +class of drift the organization asks to catch with an executable check rather +than prose. + +This script owns two related outputs so they cannot disagree: + +1. the header contract for every `decisions/NNNN-*.md` record, and +2. the generated index at `decisions/README.md`. + +Usage: + + python3 scripts/decision-records.py # write the index + python3 scripts/decision-records.py --check # verify, change nothing +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +DECISIONS_DIRECTORY = "decisions" +INDEX_PATH = f"{DECISIONS_DIRECTORY}/README.md" +RECORD_PATTERN = re.compile(r"^(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$") +TITLE_PATTERN = re.compile(r"^# (\d+)\. (.+)$") +FIELD_PATTERN = re.compile(r"^- \*\*(?P[^:*]+):\*\*(?P.*)$") + +REQUIRED_FIELDS = ( + "Status", + "Date", + "Deciders", + "Supersedes", + "Superseded by", +) +ALLOWED_STATUSES = ( + "PROPOSED", + "ACCEPTED", + "REJECTED", + "SUPERSEDED", +) +UNRESOLVED_DECIDERS = ("TBD", "None", "") +DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + +INDEX_HEADER = """ + +# Architecture decision records + +Durable organization decisions. Draft new records with `runbooks/adr.md`; only +a maintainer moves a record from `PROPOSED` to `ACCEPTED`. + +""" + + +def error(path: str, rule: str, fix: str) -> str: + return f"{path}: {rule}; fix: {fix}" + + +class Record: + """One parsed decision record.""" + + def __init__(self, path: str, number: int, title: str, fields: dict[str, str]): + self.path = path + self.number = number + self.title = title + self.fields = fields + + @property + def filename(self) -> str: + return Path(self.path).name + + @property + def file_number(self) -> int: + """The number encoded in the filename, which orders the index.""" + match = RECORD_PATTERN.match(self.filename) + if match is None: # pragma: no cover - filenames are filtered on collection + raise ValueError(f"{self.path} is not a decision record filename") + return int(match.group(1)) + + @property + def status(self) -> str: + return self.fields.get("Status", "") + + @property + def date(self) -> str: + return self.fields.get("Date", "") + + @property + def deciders(self) -> str: + return self.fields.get("Deciders", "") + + +def _record_paths(root: Path) -> list[Path]: + directory = root / DECISIONS_DIRECTORY + if not directory.is_dir(): + return [] + return sorted( + path + for path in directory.iterdir() + if path.is_file() and RECORD_PATTERN.match(path.name) + ) + + +def _parse_header(text: str) -> tuple[int | None, str, dict[str, str], list[str]]: + """Return the record number, title, header fields, and structural problems.""" + lines = text.splitlines() + problems: list[str] = [] + + if not lines: + return None, "", {}, ["file is empty"] + + title_match = TITLE_PATTERN.match(lines[0]) + if title_match is None: + return None, "", {}, ["first line must be '# . '"] + number = int(title_match.group(1)) + title = title_match.group(2).strip() + + if len(lines) < 2 or lines[1].strip() != "": + problems.append("the title must be followed by one blank line") + + fields: dict[str, str] = {} + order: list[str] = [] + for line in lines[2:]: + if line.strip() == "": + break + field_match = FIELD_PATTERN.match(line) + if field_match is None: + if order: + # A continuation line belongs to the field opened above it. + fields[order[-1]] = f"{fields[order[-1]]} {line.strip()}".strip() + continue + problems.append("the header block must start at line 3") + break + field = field_match.group("field").strip() + if field in fields: + problems.append(f"duplicate header field {field!r}") + continue + fields[field] = field_match.group("value").strip() + order.append(field) + + if order and tuple(order) != REQUIRED_FIELDS: + expected = ", ".join(REQUIRED_FIELDS) + problems.append(f"header fields must be exactly, and in order: {expected}") + + return number, title, fields, problems + + +def _validate_record( + path: str, number: int | None, record: Record | None, problems: list[str] +) -> list[str]: + fix = ( + "match the header block used by the other records in decisions/ " + "and rerun python3 scripts/decision-records.py --check" + ) + errors = [error(path, problem, fix) for problem in problems] + if record is None: + return errors + + filename_number = record.file_number + if record.number != filename_number: + errors.append( + error( + path, + f"title number {record.number} does not match filename number " + f"{filename_number}", + f"renumber the title heading to '# {filename_number}. ...'", + ) + ) + + if record.status not in ALLOWED_STATUSES: + allowed = ", ".join(ALLOWED_STATUSES) + errors.append( + error( + path, + f"status {record.status!r} is not a recognized status", + f"set Status to one of: {allowed}", + ) + ) + + if record.status == "ACCEPTED" and record.deciders in UNRESOLVED_DECIDERS: + errors.append( + error( + path, + "an ACCEPTED record must name the accepting maintainer", + "record the accepting maintainer's handle in Deciders " + "(see runbooks/adr.md)", + ) + ) + + if not DATE_PATTERN.match(record.date): + errors.append( + error( + path, + f"date {record.date!r} is not an ISO 8601 calendar date", + "set Date to a YYYY-MM-DD value", + ) + ) + + return errors + + +def collect(root: Path) -> tuple[list[Record], list[str]]: + records: list[Record] = [] + errors: list[str] = [] + seen_numbers: dict[int, str] = {} + + for record_path in _record_paths(root): + display_path = f"{DECISIONS_DIRECTORY}/{record_path.name}" + try: + text = record_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + errors.append( + error( + display_path, + f"cannot read decision record: {exc}", + f"restore a readable UTF-8 file at {display_path}", + ) + ) + continue + + number, title, fields, problems = _parse_header(text) + record = ( + Record(display_path, number, title, fields) if number is not None else None + ) + errors.extend(_validate_record(display_path, number, record, problems)) + if record is None: + continue + + filename_number = record.file_number + if filename_number in seen_numbers: + errors.append( + error( + display_path, + f"decision number {filename_number} is already used by " + f"{seen_numbers[filename_number]!r}", + "give every decision record a unique number", + ) + ) + else: + seen_numbers[filename_number] = display_path + records.append(record) + + records.sort(key=lambda item: item.file_number) + return records, errors + + +def render_index(records: list[Record]) -> str: + """Render the index table. + + Columns are padded to the widest cell so the generated file is already in + the repository formatter's preferred shape. Without this, `trunk fmt` would + rewrite the table and `--check` would then report the freshly generated + index as out of date. + """ + headings = ("ADR", "Title", "Status", "Date", "Deciders") + rows: list[tuple[str, ...]] = [] + for record in records: + rows.append( + ( + f"[{record.file_number:04d}]({record.filename})", + record.title, + record.status, + record.date, + record.deciders or "TBD", + ) + ) + + widths = [ + max(len(heading), *(len(row[column]) for row in rows)) if rows else len(heading) + for column, heading in enumerate(headings) + ] + + def render_row(cells: tuple[str, ...]) -> str: + padded = (cell.ljust(widths[column]) for column, cell in enumerate(cells)) + return f"| {' | '.join(padded)} |" + + separator = f"| {' | '.join('-' * width for width in widths)} |" + lines = [render_row(headings), separator] + lines.extend(render_row(row) for row in rows) + return INDEX_HEADER + "\n".join(lines) + "\n" + + +def run(root: Path, check_only: bool) -> tuple[int, list[str]]: + root = root.resolve() + records, errors = collect(root) + if errors: + return 1, sorted(set(errors)) + + if not records: + return 0, [] + + expected = render_index(records) + index_path = root / INDEX_PATH + + if check_only: + try: + current = index_path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + current = None + if current != expected: + return 1, [ + error( + INDEX_PATH, + "the decision index is missing or out of date", + "run python3 scripts/decision-records.py and commit the result", + ) + ] + return 0, [] + + index_path.write_text(expected, encoding="utf-8") + return 0, [] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate decision records and render the decision index" + ) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the script's repository)", + ) + parser.add_argument( + "--check", + action="store_true", + help="verify records and the index without writing anything", + ) + arguments = parser.parse_args() + + status, errors = run(arguments.root, arguments.check) + for message in errors: + print(f"ERROR: {message}") + if status == 0: + print( + "decision record validation passed" + if arguments.check + else f"wrote {INDEX_PATH}" + ) + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_decision_records.py b/scripts/test_decision_records.py new file mode 100644 index 000000000..c9c3d4461 --- /dev/null +++ b/scripts/test_decision_records.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from types import ModuleType + +sys.dont_write_bytecode = True +SCRIPT_PATH = Path(__file__).with_name("decision-records.py") +PUBLIC_ROOT = SCRIPT_PATH.resolve().parent.parent + + +def load_validator() -> ModuleType: + spec = importlib.util.spec_from_file_location("decision_records", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {SCRIPT_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = load_validator() + +VALID_RECORD = """# 1. A Valid Decision + +- **Status:** ACCEPTED +- **Date:** 2026-05-29 +- **Deciders:** ss-o +- **Supersedes:** None +- **Superseded by:** None + +## Context + +Context body. +""" + + +class DecisionRecordTests(unittest.TestCase): + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self._temporary_directory.cleanup) + self.root = Path(self._temporary_directory.name) + (self.root / "decisions").mkdir() + + def write_record(self, filename: str, text: str) -> None: + (self.root / "decisions" / filename).write_text(text, encoding="utf-8") + + def errors(self) -> list[str]: + _records, errors = validator.collect(self.root) + return errors + + def test_valid_record_has_no_errors(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.assertEqual(self.errors(), []) + + def test_empty_directory_has_no_errors(self) -> None: + self.assertEqual(self.errors(), []) + + def test_rejects_legacy_section_header(self) -> None: + self.write_record( + "0001-legacy.md", + textwrap.dedent("""\ + # 1. Legacy Shape + + Date: 2026-05-29 + + ## Status + + ACCEPTED + """), + ) + errors = self.errors() + self.assertTrue(any("header block must start at line 3" in e for e in errors)) + + def test_rejects_accepted_record_without_deciders(self) -> None: + self.write_record("0001-no-decider.md", VALID_RECORD.replace("ss-o", "TBD")) + errors = self.errors() + self.assertTrue( + any("must name the accepting maintainer" in e for e in errors), errors + ) + + def test_allows_proposed_record_without_deciders(self) -> None: + text = VALID_RECORD.replace("ACCEPTED", "PROPOSED").replace("ss-o", "TBD") + self.write_record("0001-proposed.md", text) + self.assertEqual(self.errors(), []) + + def test_rejects_unknown_status(self) -> None: + self.write_record("0001-unknown.md", VALID_RECORD.replace("ACCEPTED", "MAYBE")) + errors = self.errors() + self.assertTrue(any("is not a recognized status" in e for e in errors), errors) + + def test_rejects_non_iso_date(self) -> None: + self.write_record( + "0001-bad-date.md", VALID_RECORD.replace("2026-05-29", "29 May 2026") + ) + errors = self.errors() + self.assertTrue(any("ISO 8601 calendar date" in e for e in errors), errors) + + def test_rejects_title_number_mismatch(self) -> None: + self.write_record("0002-mismatch.md", VALID_RECORD.replace("# 1.", "# 7.")) + errors = self.errors() + self.assertTrue(any("does not match filename number" in e for e in errors)) + + def test_rejects_out_of_order_header_fields(self) -> None: + text = textwrap.dedent("""\ + # 1. Reordered + + - **Date:** 2026-05-29 + - **Status:** ACCEPTED + - **Deciders:** ss-o + - **Supersedes:** None + - **Superseded by:** None + + ## Context + """) + self.write_record("0001-reordered.md", text) + errors = self.errors() + self.assertTrue(any("must be exactly, and in order" in e for e in errors)) + + def test_ignores_non_record_filenames(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.write_record("README.md", "# Not a record\n") + self.assertEqual(self.errors(), []) + + def test_check_reports_missing_index(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + status, errors = validator.run(self.root, check_only=True) + self.assertEqual(status, 1) + self.assertTrue(any("out of date" in e for e in errors), errors) + + def test_write_then_check_is_stable(self) -> None: + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + self.assertEqual(validator.run(self.root, check_only=False), (0, [])) + self.assertEqual(validator.run(self.root, check_only=True), (0, [])) + + def test_index_lists_records_in_numeric_order(self) -> None: + self.write_record("0002-second.md", VALID_RECORD.replace("# 1.", "# 2.")) + self.write_record("0010-tenth.md", VALID_RECORD.replace("# 1.", "# 10.")) + self.write_record("0001-a-valid-decision.md", VALID_RECORD) + validator.run(self.root, check_only=False) + index = (self.root / "decisions/README.md").read_text(encoding="utf-8") + self.assertLess(index.index("[0001]"), index.index("[0002]")) + self.assertLess(index.index("[0002]"), index.index("[0010]")) + + +class PublicRepositoryTests(unittest.TestCase): + """The real repository must satisfy its own decision-record contract.""" + + def test_public_records_and_index_are_valid(self) -> None: + status, errors = validator.run(PUBLIC_ROOT, check_only=True) + self.assertEqual(errors, []) + self.assertEqual(status, 0) + + def test_index_is_declared_in_the_manifest(self) -> None: + manifest = (PUBLIC_ROOT / ".github/instruction-surfaces.json").read_text() + self.assertIn("decisions/README.md", manifest) + self.assertIn("scripts/decision-records.py", manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_validate_agent_policy.py b/scripts/test_validate_agent_policy.py index a8a0994d5..f30ec448f 100644 --- a/scripts/test_validate_agent_policy.py +++ b/scripts/test_validate_agent_policy.py @@ -2000,12 +2000,13 @@ def test_public_repository_routes_portable_worktree_management(self) -> None: "`git worktree list --porcelain` as the authoritative inventory", (PUBLIC_ROOT / "AGENTS.md").read_text(), ) - self.assertIn( - "- **Status:** PROPOSED", - ( - PUBLIC_ROOT / "decisions/0018-portable-worktree-management.md" - ).read_text(), - ) + record = ( + PUBLIC_ROOT / "decisions/0018-portable-worktree-management.md" + ).read_text() + # The record is accepted; scripts/decision-records.py owns the general + # header contract, so this only pins the routed decision's own state. + self.assertIn("- **Status:** ACCEPTED", record) + self.assertIn("- **Deciders:** ss-o", record) def test_public_repository_prohibits_vendor_root_instruction_files(self) -> None: policy = (PUBLIC_ROOT / "AGENTS.md").read_text() diff --git a/scripts/validate-agent-policy.py b/scripts/validate-agent-policy.py index 61fba9ce2..cdc03e004 100644 --- a/scripts/validate-agent-policy.py +++ b/scripts/validate-agent-policy.py @@ -88,6 +88,12 @@ ".github/README.md": "runbook", ".github/copilot-instructions.md": "adapter", } +# Generated outputs that live inside a scanned inventory directory but are not +# records of that directory's kind. The inventory scan discovers them when they +# exist, so they are not unconditionally required here. +GENERATED_INVENTORY = { + "decisions/README.md": "runbook", +} INVENTORY_RULES = ( (".github/instructions", ".instructions.md", "scoped-guidance", True), (".github/agents", ".md", "agent", False), @@ -97,6 +103,7 @@ ENFORCEMENT_INVENTORY = { ".github/workflows/agent-instructions.yml": "enforcement", "scripts/validate-agent-policy.py": "enforcement", + "scripts/decision-records.py": "enforcement", } PUBLIC_SCAN_EXEMPTIONS = {"scripts/validate-agent-policy.py"} ALLOWED_MANIFEST_FIELDS = {"version", "repository", "canonical_policy", "surfaces"} @@ -260,8 +267,10 @@ def _inventory_path(relative_path: str) -> str: def _expected_inventory_kind(relative_path: str) -> str | None: inventory_path = _inventory_path(relative_path) - exact_kind = BASE_INVENTORY.get(inventory_path) or ENFORCEMENT_INVENTORY.get( - inventory_path + exact_kind = ( + BASE_INVENTORY.get(inventory_path) + or GENERATED_INVENTORY.get(inventory_path) + or ENFORCEMENT_INVENTORY.get(inventory_path) ) if exact_kind is not None: return exact_kind