"""Gate decision-table tests for scripts/check-complete.sh --gate (v3 C2). The completion gate is the v3 termination oracle. It blocks a Stop ONLY when ALL of these hold (architecture "Gate decision table"): 1. mode is gated (/.mode contains "gate") 2. an in_progress phase exists (not merely complete bool: return shutil.which("sh") is not None @unittest.skipUnless(have_sh(), "sh not available on this platform") class GateDecisionTableTests(unittest.TestCase): def setUp(self) -> None: self.tmp = Path(tempfile.mkdtemp(prefix="pwf-gate-")) self.plan_dir = self.tmp / ".planning" / "p" self.plan_dir.mkdir(parents=True) (self.tmp / ".planning" / ".active_plan").write_text("p\n", encoding="utf-8") self.env = os.environ.copy() self.env.pop("PLAN_ID", None) self.env.pop("PWF_GATE_CAP", None) def tearDown(self) -> None: shutil.rmtree(self.tmp, ignore_errors=True) # -- helpers ---------------------------------------------------------- def write_plan(self, phase1_status: str = "in_progress", phase2_status: str = "pending") -> None: (self.plan_dir / "task_plan.md").write_text( "# Task Plan\n" "### Phase 1: Build\n" f"- **Status:** {phase1_status}\n" "### Phase 2: Test\n" f"- **Status:** {phase2_status}\n", encoding="utf-8", ) def set_mode(self, mode: str | None) -> None: if mode is None: return (self.plan_dir / ".mode").write_text(mode + "\n", encoding="utf-8") def set_blocks(self, n: int) -> None: (self.plan_dir / ".stop_blocks").write_text(f"{n}\n", encoding="utf-8") def add_ledger_line(self, tick: int) -> None: lf = self.plan_dir / "ledger-main.jsonl" with lf.open("a", encoding="utf-8") as fh: fh.write( f'{{"tick":{tick},"ts":"1970-01-01T00:00:00Z","agent":"main",' f'"phase":"1","event":"progress","summary":"x","files":[]}}\n' ) def run_gate(self, gate: bool, stop_hook_active: bool = False, cap: int | None = None): args = [str(CHECK_COMPLETE)] if gate: args.append("--gate") env = dict(self.env) if cap is not None: env["PWF_GATE_CAP"] = str(cap) return subprocess.run( ["sh", *args], cwd=str(self.tmp), text=True, encoding="utf-8", capture_output=True, input=json.dumps({"stop_hook_active": stop_hook_active}), env=env, check=False, ) def parse_block(self, stdout: str) -> dict: """Find and parse the single decision-JSON line; fail if absent/invalid.""" line = None for candidate in stdout.splitlines(): if candidate.strip().startswith('{"decision"'): line = candidate.strip() break self.assertIsNotNone(line, f"no decision JSON line in output:\n{stdout}") obj = json.loads(line) # raises if malformed → test fails return obj def assert_no_block(self, result) -> None: self.assertEqual(0, result.returncode, result.stderr) self.assertNotIn('"decision":"block"', result.stdout) # -- decision table --------------------------------------------------- def test_advisory_without_gate_flag(self) -> None: # No --gate: always advisory, even with a gated mode + in_progress phase. self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) result = self.run_gate(gate=False) self.assert_no_block(result) self.assertIn("Task in progress", result.stdout) def test_gate_without_mode_is_advisory(self) -> None: # --gate but no .mode file: legacy plans never gate. self.write_plan(phase1_status="in_progress") self.set_mode(None) self.set_blocks(0) result = self.run_gate(gate=True) self.assert_no_block(result) def test_gated_in_progress_blocks_with_valid_json(self) -> None: self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) result = self.run_gate(gate=True, stop_hook_active=False) self.assertEqual(0, result.returncode, result.stderr) obj = self.parse_block(result.stdout) self.assertEqual("block", obj["decision"]) self.assertIn("reason", obj) # Reason carries the phase NAME only, never raw plan body. self.assertIn("Phase 1: Build", obj["reason"]) def test_gated_only_pending_does_not_block(self) -> None: # complete None: # Already inside a forced continuation: allow the stop, no recursion. self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) result = self.run_gate(gate=True, stop_hook_active=True) self.assert_no_block(result) def test_cap_reached_stops_blocking(self) -> None: self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(2) result = self.run_gate(gate=True, cap=2) self.assert_no_block(result) self.assertIn("gate cap reached", result.stdout) def test_stall_stops_blocking(self) -> None: # First block records the ledger count. A second block with NO new ledger # line means the model did not progress, so the gate allows the stop. self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) self.add_ledger_line(1) first = self.run_gate(gate=True) self.parse_block(first.stdout) # asserts it blocked the first time # No new ledger line between the two stops. second = self.run_gate(gate=True) self.assert_no_block(second) self.assertIn("no progress since last gate block", second.stdout) def test_progress_since_block_keeps_blocking(self) -> None: # Counterpart to the stall test: when the ledger DID advance between two # stops, the gate must keep blocking (not misfire the stall guard). self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) self.add_ledger_line(1) first = self.run_gate(gate=True) self.parse_block(first.stdout) self.add_ledger_line(2) # real progress second = self.run_gate(gate=True) obj = self.parse_block(second.stdout) self.assertEqual("block", obj["decision"]) def test_stop_blocks_increments_on_block(self) -> None: self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) self.add_ledger_line(1) self.run_gate(gate=True) after_first = int((self.plan_dir / ".stop_blocks").read_text().strip()) self.assertEqual(1, after_first) self.add_ledger_line(2) # keep blocking (advance ledger) self.run_gate(gate=True) after_second = int((self.plan_dir / ".stop_blocks").read_text().strip()) self.assertEqual(2, after_second) # -- .mode token parse (platform-critical, counterpart to inject-plan.sh) -- def test_autonomous_gate_token_activates_gate(self) -> None: # The gated marker is two space-separated tokens, 'autonomous gate'. # check-complete guard 1 must detect the 'gate' token inside it and arm # the gate. (inject-plan.sh parses the same marker; that side is pinned in # test_hook_body_v240.py ModeTokenParseTests. Both must agree on the token # grammar so a gated plan behaves consistently across the two scripts.) self.write_plan(phase1_status="in_progress") self.set_mode("autonomous gate") self.set_blocks(0) result = self.run_gate(gate=True, stop_hook_active=False) obj = self.parse_block(result.stdout) self.assertEqual("block", obj["decision"]) def test_autonomous_only_token_does_not_gate(self) -> None: # 'autonomous' without 'gate' is not gated mode: guard 1 must keep it # advisory. This pins that the parse keys off the 'gate' token specifically. self.write_plan(phase1_status="in_progress") self.set_mode("autonomous") self.set_blocks(0) result = self.run_gate(gate=True, stop_hook_active=False) self.assert_no_block(result) if __name__ == "__main__": unittest.main()