diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index d7b3e1a2c..e90a98461 100644 --- a/.agent-loop/CURRENT_STATE.md +++ b/.agent-loop/CURRENT_STATE.md @@ -42,7 +42,8 @@ authority; these records do not grant or withhold it. | [WS-POL-001](initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md) | Foundation initiative complete | Follow-up behavior belongs to current ART, POL, REV, or CON initiatives | | [WS-QUAL-001](initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md) | Coverage closure complete; blocking mutation rollout retired | Preserve global 78 percent and protected-subsystem 90 percent floors; mutation needs a fresh changed-line-aware plan | | [WS-CI-001](initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md) | Semantic distributed backend lanes complete | Treat further CI optimization as a fresh measured bounded change | -| [WS-CI-002](initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md) | Active bounded CI repair | Make Agent Gates deterministic per PR head while protected-branch review remains the approval authority | +| [WS-CI-002](initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md) | `WS-CI-002-01` complete through PR #311; Agent Gates is deterministic per PR head | Preserve protected-branch review as the independent approval authority | +| [WS-CI-003](initiatives/WS-CI-003-atomic-chunk-state/STATUS.md) | `WS-CI-003-01` complete | Require every chunk PR to land its final contract and initiative state atomically | | [WS-DOCS-001](initiatives/WS-DOCS-001-current-v01-documentation/STATUS.md) | Current v0.1 entry documentation complete | Keep current pages synchronized with merged capability changes | | [WS-DOCS-002](initiatives/WS-DOCS-002-workstream-definition/STATUS.md) | Canonical Workstream definition complete | Preserve terminology across current documentation and generated artifacts | | [WS-XINT-001](initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/STATUS.md) | Planning reconciliation complete and closed | Owner initiatives implement the resulting boundaries | diff --git a/.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md b/.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md index a7150f14b..24745320e 100644 --- a/.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md +++ b/.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md @@ -1,7 +1,7 @@ # STATUS: WS-CI-002 — Deterministic Agent Gates -- Phase: ready for human review -- Completed chunk: `WS-CI-002-01` +- Phase: complete; merged through PR #311 +- Completed chunk: `WS-CI-002-01` merged through PR #311 - Goal: make the required `agent-gates` result deterministic for each PR head while leaving independent human approval to protected-branch review rules. - Trigger: PR #309 remained blocked by several pre-approval failures after its diff --git a/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/CHUNK_MAP.md b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/CHUNK_MAP.md new file mode 100644 index 000000000..ff05dbd7e --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/CHUNK_MAP.md @@ -0,0 +1,6 @@ +# Chunk Map: WS-CI-003 Atomic Chunk State + +| Chunk | Goal | Risk | State represented by this change | +|---|---|---:|---| +| `WS-CI-003-01` | Require atomic chunk completion state in the implementation PR | L1 | Complete | + diff --git a/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/STATUS.md b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/STATUS.md new file mode 100644 index 000000000..7adcadfed --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/STATUS.md @@ -0,0 +1,9 @@ +# Status: WS-CI-003 Atomic Chunk State + +- Initiative state: active +- Current chunk: `WS-CI-003-01` +- Outcome on merge: `WS-CI-003-01` is complete and Agent Gates requires every + chunk PR to carry its contract, chunk-map, initiative-status, and current-state + outcome atomically. +- Product behavior changed: no + diff --git a/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/chunks/WS-CI-003-01-atomic-chunk-state.md b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/chunks/WS-CI-003-01-atomic-chunk-state.md new file mode 100644 index 000000000..8aa867bd6 --- /dev/null +++ b/.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/chunks/WS-CI-003-01-atomic-chunk-state.md @@ -0,0 +1,73 @@ +# Chunk Contract: WS-CI-003-01 Atomic Chunk State + +## Goal + +Ensure a human merge atomically lands both the bounded change and its durable +chunk/initiative state, without a pre-merge memory PR or post-merge repair PR. + +## Why this chunk exists + +PR #318 had to reconcile state after earlier chunks merged, and its own +`WS-ARCH-001-HK1` row still landed as `In review`. The repository had no gate +requiring changed chunk contracts and their projections to describe the state +that would exist after merge. + +## Risk class + +L1 CI and contributor workflow. + +## Allowed files + +```text +.github/workflows/agent-gates.yml +.github/pull_request_template.md +AGENTS.md +CONTRIBUTING.md +scripts/check_chunk_state_sync.py +scripts/test_chunk_state_sync.py +scripts/test_lightweight_agent_gates.py +.agent-loop/CURRENT_STATE.md +.agent-loop/templates/PR_TRUST_BUNDLE.md +.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md +.agent-loop/initiatives/WS-CI-003-atomic-chunk-state/** +``` + +## Not allowed + +- Post-merge commits, automated merge PRs, direct pushes, or write tokens. +- Product, schema, authorization, dependency, test-selection, coverage, or + branch-protection changes. +- Inferring completion from historical review files or chat. +- More than one implementation chunk in one PR. + +## Acceptance criteria + +- [x] Implementation-surface changes require exactly one changed chunk contract. +- [x] Every changed chunk contract declares one final outcome on merge. +- [x] The same PR changes its initiative `CHUNK_MAP.md`, initiative `STATUS.md`, + and `.agent-loop/CURRENT_STATE.md`. +- [x] All three projections name the exact chunk and final outcome. +- [x] A completed chunk cannot remain `in review`, `pending review`, or + `ready for review` in its chunk-map row. +- [x] Planning, completion, cancellation, and supersession are supported. +- [x] GitHub review and human merge remain the only approval and merge steps. +- [x] No post-merge automation is introduced. + +## Merge state + +- Outcome on merge: `complete` + +## Verification + +```bash +python3 -m unittest -v scripts.test_chunk_state_sync scripts.test_lightweight_agent_gates +python3 scripts/check_chunk_state_sync.py --base-ref origin/main +python3 scripts/check_markdown_links.py +python3 scripts/check_stale_workstream_wording.py +git diff --check origin/main...HEAD +``` + +## Required review + +CI integrity and documentation review. Human review should confirm the rule is +atomic, deterministic, and does not introduce a second merge workflow. diff --git a/.agent-loop/templates/PR_TRUST_BUNDLE.md b/.agent-loop/templates/PR_TRUST_BUNDLE.md index 940cc82da..5436339aa 100644 --- a/.agent-loop/templates/PR_TRUST_BUNDLE.md +++ b/.agent-loop/templates/PR_TRUST_BUNDLE.md @@ -7,6 +7,10 @@ in sync with this template. `` — `` +For a chunk PR, confirm its contract contains `## Merge state` with one +`Outcome on merge`, and that the chunk map, initiative status, and current +engineering state already describe the result that will land on `main`. + ## Goal What this PR is meant to accomplish. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f48b727f3..a0526118f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,10 @@ in sync when the trust-bundle structure changes. `<chunk-id or small-change>` - `<title>` +For a chunk PR, confirm its contract contains `## Merge state` with one +`Outcome on merge`, and that the chunk map, initiative status, and current +engineering state already describe the result that will land on `main`. + ## Goal ## Intent And Planning Context diff --git a/.github/workflows/agent-gates.yml b/.github/workflows/agent-gates.yml index 24d4fb357..7c04d9ffd 100644 --- a/.github/workflows/agent-gates.yml +++ b/.github/workflows/agent-gates.yml @@ -41,5 +41,15 @@ jobs: - name: Guide extractor dependency validation run: python3 backend/scripts/check_guide_extractor_dependencies.py + - name: Atomic chunk state validation + env: + WORKSTREAM_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: >- + python3 scripts/check_chunk_state_sync.py + --base-ref "${WORKSTREAM_BASE_SHA}" + - name: Lightweight gate regression tests - run: python3 -m unittest -v scripts.test_lightweight_agent_gates + run: >- + python3 -m unittest -v + scripts.test_chunk_state_sync + scripts.test_lightweight_agent_gates diff --git a/AGENTS.md b/AGENTS.md index 45dd10cc6..b5ac15f9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,11 @@ definition or ownership boundary of Workstream. paths. - Every non-trivial task starts with the smallest applicable loop artifact: an initiative plan for large work, or a chunk contract for bounded work. - Do not implement a chunk until its allowed files, not-allowed changes, acceptance criteria, risk class, verification commands, and required reviewers are explicit. +- One implementation chunk equals one pull request. The chunk contract must + declare its outcome on merge, and the same pull request must update the + initiative chunk map, initiative status, and `.agent-loop/CURRENT_STATE.md` + to that final state. Do not use `in review`, `pending review`, or `ready for + review` as the state that will land on `main`. - Do not begin the next chunk automatically after finishing the current chunk. - Use internal sub-agent review proportionate to risk. Security, authorization, payment, architecture, workflow, and broad product changes require focused diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1dc3e627..9d2311786 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,10 +25,11 @@ host setup is supported only on the Linux/glibc/Python matrix documented there. Do not replace the approved Pillow artifacts to make an unsupported host install pass. -For a small change, record the intent and scope in the pull request. For larger -or higher-risk work, add a short initiative plan and chunk contract under -`.agent-loop/initiatives/`. Existing planning artifacts are useful context, not -runtime locks. +For a documentation-only small change, record the intent and scope in the pull +request. Every implementation change uses one bounded chunk contract under +`.agent-loop/initiatives/`; larger or higher-risk work also adds a short +initiative plan. Existing planning artifacts are useful context, not runtime +locks. ## Find The Current Contract @@ -77,6 +78,17 @@ active queue or approval gate. - Preserve security defaults and existing coverage floors. - Record important reviewer findings and how they were resolved. - Reconcile with current `main` and rerun affected checks. +- For a chunk PR, declare `Outcome on merge` in the chunk contract and update + its initiative `CHUNK_MAP.md`, initiative `STATUS.md`, and + `.agent-loop/CURRENT_STATE.md` to the state that will exist after human + merge. Code and durable state land together; there is no second pre-merge or + post-merge memory PR. + +Run the same atomic check locally before pushing: + +```bash +python3 scripts/check_chunk_state_sync.py --base-ref origin/main +``` Security, authorization, payments, workflow, architecture, and other high-risk changes require focused internal review before they are ready to merge. Small diff --git a/scripts/check_chunk_state_sync.py b/scripts/check_chunk_state_sync.py new file mode 100644 index 000000000..3c618d238 --- /dev/null +++ b/scripts/check_chunk_state_sync.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Require one chunk PR to land its durable state projections atomically.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CHUNK_PATH = re.compile(r"^\.agent-loop/initiatives/([^/]+)/chunks/([^/]+)\.md$") +OUTCOME = re.compile(r"^- Outcome on merge: `(planned|complete|cancelled|superseded)`\s*$") +MERGE_STATE_SECTION = re.compile( + r"^## Merge state\s*$\n(?P<body>.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL, +) +CHUNK_ID = re.compile(r"^([A-Z]+-[A-Z]+-[0-9]+-[A-Z0-9]+)(?:-|$)") +IMPLEMENTATION_PREFIXES = ( + ".ci/", + ".github/workflows/", + "backend/", + "frontend/src/", + "scripts/", +) +OUTCOME_WORDS = { + "planned": ("planned", "proposed"), + "complete": ("complete", "merged"), + "cancelled": ("cancelled",), + "superseded": ("superseded",), +} +REVIEW_ONLY_WORDS = ("in review", "pending review", "ready for review") + + +class ChunkStateError(RuntimeError): + """Raised when a PR would merge stale or incomplete chunk state.""" + + +def changed_paths(base_ref: str, head_ref: str = "HEAD") -> list[str]: + """Return paths changed by the prospective merge.""" + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}...{head_ref}"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def _read(relative_path: str) -> str: + try: + return (ROOT / relative_path).read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ChunkStateError(f"CHUNK_STATE_UNREADABLE: {relative_path}") from exc + + +def _chunk_row(chunk_map: str, chunk_id: str) -> str: + rows = [line for line in chunk_map.splitlines() if f"`{chunk_id}`" in line] + if len(rows) != 1: + raise ChunkStateError(f"CHUNK_STATE_MAP_ROW_INVALID: {chunk_id}") + return rows[0] + + +def _projection_lines(projection: str, chunk_id: str) -> list[str]: + identifier = re.compile(rf"(?<![A-Z0-9-]){re.escape(chunk_id)}(?![A-Z0-9-])") + return [line for line in projection.splitlines() if identifier.search(line)] + + +def _declared_outcome(contract: str, chunk_id: str) -> str: + sections = list(MERGE_STATE_SECTION.finditer(contract)) + declarations = [ + match + for line in contract.splitlines() + if (match := OUTCOME.fullmatch(line)) is not None + ] + if len(sections) != 1 or len(declarations) != 1: + raise ChunkStateError(f"CHUNK_STATE_OUTCOME_INVALID: {chunk_id}") + section_declarations = [ + match + for line in sections[0].group("body").splitlines() + if (match := OUTCOME.fullmatch(line)) is not None + ] + if len(section_declarations) != 1: + raise ChunkStateError(f"CHUNK_STATE_OUTCOME_INVALID: {chunk_id}") + return section_declarations[0].group(1) + + +def _has_outcome(line: str, outcome: str) -> bool: + """Return whether a line asserts, rather than merely contains, an outcome.""" + folded = line.casefold() + for word in OUTCOME_WORDS[outcome]: + token = re.compile(rf"(?<![a-z0-9_]){re.escape(word)}(?![a-z0-9_])") + for match in token.finditer(folded): + prefix = folded[max(0, match.start() - 32) : match.start()] + if re.search(r"\b(?:not(?:\s+yet)?|never)\s+$", prefix): + continue + return True + return False + + +def _requires_contract(paths: set[str]) -> bool: + return any(path.startswith(IMPLEMENTATION_PREFIXES) for path in paths) + + +def _validate_chunk(chunk_path: str, changed: set[str]) -> str: + """Validate one changed contract and return its declared merge outcome.""" + match = CHUNK_PATH.fullmatch(chunk_path) + assert match is not None + initiative_directory, chunk_filename = match.groups() + chunk_id_match = CHUNK_ID.match(chunk_filename) + if chunk_id_match is None: + raise ChunkStateError(f"CHUNK_STATE_ID_INVALID: {chunk_filename}") + chunk_id = chunk_id_match.group(1) + contract = _read(chunk_path) + outcome = _declared_outcome(contract, chunk_id) + + initiative_root = f".agent-loop/initiatives/{initiative_directory}" + chunk_map_path = f"{initiative_root}/CHUNK_MAP.md" + status_path = f"{initiative_root}/STATUS.md" + current_state_path = ".agent-loop/CURRENT_STATE.md" + required = {chunk_map_path, status_path, current_state_path} + missing = sorted(required - changed) + if missing: + raise ChunkStateError("CHUNK_STATE_PROJECTION_MISSING: " + ", ".join(missing)) + + chunk_map = _read(chunk_map_path) + status = _read(status_path) + current_state = _read(current_state_path) + row = _chunk_row(chunk_map, chunk_id) + if not _has_outcome(row, outcome): + raise ChunkStateError(f"CHUNK_STATE_MAP_OUTCOME_MISMATCH: {chunk_id}") + if outcome == "complete" and any(word in row.casefold() for word in REVIEW_ONLY_WORDS): + raise ChunkStateError(f"CHUNK_STATE_REVIEW_WORDING: {chunk_id}") + for projection_path, projection in ( + (status_path, status), + (current_state_path, current_state), + ): + lines = _projection_lines(projection, chunk_id) + if not lines: + raise ChunkStateError(f"CHUNK_STATE_ID_MISSING: {projection_path}: {chunk_id}") + if not any(_has_outcome(line, outcome) for line in lines): + raise ChunkStateError(f"CHUNK_STATE_OUTCOME_MISMATCH: {projection_path}: {chunk_id}") + return outcome + + +def validate(paths: list[str]) -> None: + """Validate atomic state for planning contracts or one implementation chunk.""" + changed = set(paths) + chunk_paths = sorted(path for path in changed if CHUNK_PATH.fullmatch(path)) + implementation = _requires_contract(changed) + if implementation and not chunk_paths: + raise ChunkStateError("CHUNK_STATE_CONTRACT_MISSING") + if implementation and len(chunk_paths) > 1: + raise ChunkStateError("CHUNK_STATE_MULTIPLE_CONTRACTS") + outcomes = [_validate_chunk(chunk_path, changed) for chunk_path in chunk_paths] + if len(outcomes) > 1 and any(outcome != "planned" for outcome in outcomes): + raise ChunkStateError("CHUNK_STATE_MULTIPLE_FINAL_OUTCOMES") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-ref", required=True) + parser.add_argument("--head-ref", default="HEAD") + args = parser.parse_args() + try: + validate(changed_paths(args.base_ref, args.head_ref)) + except (ChunkStateError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + print("Atomic chunk state check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_chunk_state_sync.py b/scripts/test_chunk_state_sync.py new file mode 100644 index 000000000..42e2db7c9 --- /dev/null +++ b/scripts/test_chunk_state_sync.py @@ -0,0 +1,165 @@ +"""Tests for atomic chunk state validation.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts import check_chunk_state_sync as gate + + +class ChunkStateSyncTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.initiative = ".agent-loop/initiatives/WS-EXAMPLE-001-example" + self.chunk = f"{self.initiative}/chunks/WS-EXAMPLE-001-01-example.md" + self.paths = [ + "backend/app/example.py", + self.chunk, + f"{self.initiative}/CHUNK_MAP.md", + f"{self.initiative}/STATUS.md", + ".agent-loop/CURRENT_STATE.md", + ] + self._write(self.chunk, "## Merge state\n\n- Outcome on merge: `complete`\n") + self._write( + f"{self.initiative}/CHUNK_MAP.md", + "| `WS-EXAMPLE-001-01` | Example | L1 | Complete |\n", + ) + self._write( + f"{self.initiative}/STATUS.md", + "WS-EXAMPLE-001-01 is complete.\n", + ) + self._write( + ".agent-loop/CURRENT_STATE.md", + "WS-EXAMPLE-001-01 is complete.\n", + ) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def _write(self, relative_path: str, text: str) -> None: + target = self.root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def _validate(self, paths: list[str] | None = None) -> None: + with patch.object(gate, "ROOT", self.root): + gate.validate(self.paths if paths is None else paths) + + def test_complete_chunk_and_all_projections_pass(self) -> None: + self._validate() + + def test_implementation_without_contract_fails(self) -> None: + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_CONTRACT_MISSING"): + self._validate(["backend/app/example.py"]) + + def test_multiple_chunk_contracts_fail(self) -> None: + other = f"{self.initiative}/chunks/WS-EXAMPLE-001-02-other.md" + self._write(other, "## Merge state\n\n- Outcome on merge: `complete`\n") + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_MULTIPLE_CONTRACTS"): + self._validate([*self.paths, other]) + + def test_planning_pr_may_define_multiple_planned_chunks(self) -> None: + second = f"{self.initiative}/chunks/WS-EXAMPLE-001-02-other.md" + self._write(self.chunk, "## Merge state\n\n- Outcome on merge: `planned`\n") + self._write(second, "## Merge state\n\n- Outcome on merge: `planned`\n") + self._write( + f"{self.initiative}/CHUNK_MAP.md", + "| `WS-EXAMPLE-001-01` | One | L1 | Planned |\n" + "| `WS-EXAMPLE-001-02` | Two | L1 | Planned |\n", + ) + self._write( + f"{self.initiative}/STATUS.md", + "WS-EXAMPLE-001-01 and WS-EXAMPLE-001-02 are planned.\n", + ) + self._write( + ".agent-loop/CURRENT_STATE.md", + "WS-EXAMPLE-001-01 and WS-EXAMPLE-001-02 are planned.\n", + ) + planning_paths = [path for path in self.paths if not path.startswith("backend/")] + self._validate([*planning_paths, second]) + + def test_missing_projection_fails(self) -> None: + paths = [path for path in self.paths if not path.endswith("STATUS.md")] + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_PROJECTION_MISSING"): + self._validate(paths) + + def test_missing_merge_outcome_fails(self) -> None: + self._write(self.chunk, "# Chunk Contract\n") + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_OUTCOME_INVALID"): + self._validate() + + def test_duplicate_merge_outcome_fails(self) -> None: + self._write( + self.chunk, + "## Merge state\n\n" + "- Outcome on merge: `complete`\n" + "- Outcome on merge: `cancelled`\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_OUTCOME_INVALID"): + self._validate() + + def test_outcome_outside_merge_state_fails(self) -> None: + self._write(self.chunk, "## Notes\n\n- Outcome on merge: `complete`\n") + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_OUTCOME_INVALID"): + self._validate() + + def test_outcome_must_share_projection_line_with_chunk(self) -> None: + self._write( + f"{self.initiative}/STATUS.md", + "WS-EXAMPLE-001-01 remains active.\nAnother chunk is complete.\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_OUTCOME_MISMATCH"): + self._validate() + + def test_outcome_substring_does_not_pass(self) -> None: + self._write( + f"{self.initiative}/CHUNK_MAP.md", + "| `WS-EXAMPLE-001-01` | Example | L1 | Incomplete |\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_MAP_OUTCOME_MISMATCH"): + self._validate() + + def test_negated_outcome_does_not_pass(self) -> None: + self._write( + f"{self.initiative}/STATUS.md", + "WS-EXAMPLE-001-01 is not complete.\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_OUTCOME_MISMATCH"): + self._validate() + + def test_longer_chunk_identifier_does_not_match(self) -> None: + self._write( + f"{self.initiative}/STATUS.md", + "WS-EXAMPLE-001-010 is complete.\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_ID_MISSING"): + self._validate() + + def test_complete_chunk_cannot_remain_in_review(self) -> None: + self._write( + f"{self.initiative}/CHUNK_MAP.md", + "| `WS-EXAMPLE-001-01` | Example | L1 | Complete; in review |\n", + ) + with self.assertRaisesRegex(gate.ChunkStateError, "CHUNK_STATE_REVIEW_WORDING"): + self._validate() + + def test_planning_outcome_is_supported(self) -> None: + self._write(self.chunk, "## Merge state\n\n- Outcome on merge: `planned`\n") + self._write( + f"{self.initiative}/CHUNK_MAP.md", + "| `WS-EXAMPLE-001-01` | Example | L1 | Planned |\n", + ) + self._write(f"{self.initiative}/STATUS.md", "WS-EXAMPLE-001-01 is planned.\n") + self._write(".agent-loop/CURRENT_STATE.md", "WS-EXAMPLE-001-01 is planned.\n") + self._validate() + + def test_documentation_only_change_needs_no_chunk_contract(self) -> None: + self._validate(["README.md"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index a3650395e..7603599f8 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -136,6 +136,9 @@ def test_backend_uses_distributed_semantic_lanes_and_stable_fan_in(self) -> None "run: python3 backend/scripts/check_guide_extractor_dependencies.py", agent_gates, ) + self.assertIn("python3 scripts/check_chunk_state_sync.py", agent_gates) + self.assertIn('WORKSTREAM_BASE_SHA: ${{ github.event.pull_request.base.sha }}', agent_gates) + self.assertIn("scripts.test_chunk_state_sync", agent_gates) def test_retired_behavior_mutation_gate_stays_out_of_required_ci(self) -> None: backend = Path(".github/workflows/backend.yml").read_text(encoding="utf-8")