From da5388a1a9511093f846a9a87749e7835d46dd13 Mon Sep 17 00:00:00 2001 From: No9 Labs Date: Thu, 9 Apr 2026 07:32:54 -0400 Subject: [PATCH 1/3] fix: guard state counters on load --- .recursive/architecture/MODULE_MAP.md | 6 +- .recursive/tasks/0273.md | 4 +- nightshift/core/state.py | 23 ++++--- nightshift/tests/test_nightshift.py | 86 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/.recursive/architecture/MODULE_MAP.md b/.recursive/architecture/MODULE_MAP.md index f1793b1..6511821 100644 --- a/.recursive/architecture/MODULE_MAP.md +++ b/.recursive/architecture/MODULE_MAP.md @@ -1,6 +1,6 @@ # Module Map -Last updated: 2026-04-09 by session #0089 +Last updated: 2026-04-09 by session #0092 Generated via: `python3 -m nightshift module-map --write` Stale after: 5 newer sessions without a refresh @@ -17,7 +17,7 @@ Read it before opening modules one by one when you need fast orientation. | `core/constants.py` | 847 | Module-level constants and tiny utilities used across the package. | `now_local`, `print_status`, `DATA_VERSION`, `SUPPORTED_AGENTS` | PR #269 (2e91d5f) | | `raven/summary.py` | 141 | Feature summary generation for Loop 2 build output. | `generate_feature_summary`, `_API_DIR_SEGMENTS`, `_CLI_DIR_SEGMENTS`, `_CONFIG_DIR_SEGMENTS` | 1636b72 | | `core/shell.py` | 256 | Subprocess execution: streaming runner, git helper, shell utilities. | `run_command`, `run_capture`, `git`, `command_exists` | PR #269 (2e91d5f) | -| `core/state.py` | 229 | Shift state: read, write, mutate counters, JSON I/O. | `load_json`, `write_json`, `read_state`, `top_path` | PR #267 (65f423d) | +| `core/state.py` | 236 | Shift state: read, write, mutate counters, JSON I/O. | `load_json`, `write_json`, `read_state`, `top_path` | session #0092 | | `owl/readiness.py` | 234 | Production-readiness checks for Loop 2 feature builds. | `collect_changed_files`, `check_secrets`, `check_debug_prints`, `check_test_coverage` | PR #204 (df36eff) | | `raven/coordination.py` | 196 | Sub-agent coordination for Loop 2 -- detects file overlaps and generates hints. | `extract_file_references`, `detect_overlaps`, `generate_coordination_hints`, `inject_hints` | PR #229 (c2acba2) | | `infra/module_map.py` | 473 | Generate a persistent module map for fast cross-session orientation. | `module_map_path`, `generate_module_map`, `render_module_map`, `write_module_map` | PR #251 (c32e527) | @@ -29,7 +29,7 @@ Read it before opening modules one by one when you need fast orientation. | `owl/eval_runner.py` | 698 | Evaluation runner: score nightshift against a target repo (or dry-run with synthetic data). | `score_artifacts`, `format_eval_table`, `run_eval_dry_run`, `run_eval_full` | PR #269 (2e91d5f) | | `raven/e2e.py` | 113 | End-to-end test runner for Loop 2 feature builds. | `infer_test_command`, `detect_smoke_test`, `run_e2e_tests`, `_MAKEFILE_TEST_TARGET` | 1636b72 | | `raven/profiler.py` | 547 | Repo profiling for Loop 2 -- detects language, framework, dependencies, structure. | `profile_repo` | PR #220 (d9e4320) | -| `owl/cycle.py` | 987 | Per-cycle logic: prompt building, agent dispatch, verification, evaluation. | `extract_json`, `read_repo_instructions`, `wrap_repo_instructions`, `command_for_agent` | session #0089 | +| `owl/cycle.py` | 974 | Per-cycle logic: prompt building, agent dispatch, verification, evaluation. | `extract_json`, `read_repo_instructions`, `wrap_repo_instructions`, `command_for_agent` | PR #267 (65f423d) | | `raven/planner.py` | 483 | Feature planner for Loop 2 -- builds structured plans from repo profiles. | `build_plan_prompt`, `validate_plan`, `parse_plan`, `execution_order` | 1636b72 | | `raven/subagent.py` | 281 | Sub-agent spawner for Loop 2 -- executes work orders via codex or claude CLI. | `spawn_task`, `spawn_wave`, `format_wave_result`, `_TASK_COMPLETION_REQUIRED_KEYS` | 1636b72 | | `raven/decomposer.py` | 175 | Task decomposer for Loop 2 -- converts FeaturePlans into sub-agent work orders. | `build_work_order_prompt`, `decompose_plan`, `format_work_orders` | 1636b72 | diff --git a/.recursive/tasks/0273.md b/.recursive/tasks/0273.md index abc9eb9..ad614ef 100644 --- a/.recursive/tasks/0273.md +++ b/.recursive/tasks/0273.md @@ -1,11 +1,11 @@ --- -status: pending +status: done priority: low target: v0.0.8 vision_section: loop1 source: code-review created: 2026-04-09 -completed: +completed: 2026-04-09 --- # Guard int() calls in _build_state counters block diff --git a/nightshift/core/state.py b/nightshift/core/state.py index c6b8b23..006d36f 100644 --- a/nightshift/core/state.py +++ b/nightshift/core/state.py @@ -37,6 +37,13 @@ def write_json(path: Path, payload: dict[str, Any] | ShiftState | FeatureState) _REQUIRED_STATE_KEYS = {"version", "date", "branch", "agent", "baseline", "counters", "cycles"} +def _safe_int(value: object, default: int = 0) -> int: + """Convert numeric values to int and fall back for corrupt data.""" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + return default + + def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: """Construct a ShiftState from a validated raw dict, field by field.""" baseline_raw = raw.get("baseline", {}) @@ -52,14 +59,14 @@ def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: message=str(baseline_raw.get("message", "")), ) counters = Counters( - fixes=int(counters_raw.get("fixes", 0)), - issues_logged=int(counters_raw.get("issues_logged", 0)), - files_touched=int(counters_raw.get("files_touched", 0)), - low_impact_fixes=int(counters_raw.get("low_impact_fixes", 0)), - failed_verifications=int(counters_raw.get("failed_verifications", 0)), - empty_cycles=int(counters_raw.get("empty_cycles", 0)), - agent_failures=int(counters_raw.get("agent_failures", 0)), - tests_written=int(counters_raw.get("tests_written", 0)), + fixes=_safe_int(counters_raw.get("fixes", 0)), + issues_logged=_safe_int(counters_raw.get("issues_logged", 0)), + files_touched=_safe_int(counters_raw.get("files_touched", 0)), + low_impact_fixes=_safe_int(counters_raw.get("low_impact_fixes", 0)), + failed_verifications=_safe_int(counters_raw.get("failed_verifications", 0)), + empty_cycles=_safe_int(counters_raw.get("empty_cycles", 0)), + agent_failures=_safe_int(counters_raw.get("agent_failures", 0)), + tests_written=_safe_int(counters_raw.get("tests_written", 0)), ) cycles_raw = raw.get("cycles", []) if not isinstance(cycles_raw, list): diff --git a/nightshift/tests/test_nightshift.py b/nightshift/tests/test_nightshift.py index 0925a6b..44a5aee 100644 --- a/nightshift/tests/test_nightshift.py +++ b/nightshift/tests/test_nightshift.py @@ -780,6 +780,92 @@ def test_valid_category_counts_load_correctly(self, tmp_path): ) assert state["category_counts"] == {"Security": 3, "Tests": 2, "Code Quality": 1} + def test_corrupt_counter_values_fall_back_to_zero(self, tmp_path): + """Non-numeric counter values must not crash read_state.""" + state_path = tmp_path / "state.json" + existing = { + "version": 1, + "date": "2026-04-09", + "branch": "nightshift/2026-04-09", + "agent": "claude", + "baseline": {"status": "passed", "command": None, "message": ""}, + "counters": { + "fixes": "bad", + "issues_logged": None, + "files_touched": ["oops"], + "low_impact_fixes": {"count": 1}, + "failed_verifications": "7", + "empty_cycles": False, + "agent_failures": [], + "tests_written": "many", + }, + "recent_cycle_paths": [], + "cycles": [], + "halt_reason": None, + "log_only_mode": False, + } + state_path.write_text(json.dumps(existing)) + state = nightshift.read_state( + state_path, + today="2026-04-09", + branch="nightshift/2026-04-09", + agent="claude", + verify_command=None, + ) + assert state["counters"] == { + "fixes": 0, + "issues_logged": 0, + "files_touched": 0, + "low_impact_fixes": 0, + "failed_verifications": 0, + "empty_cycles": 0, + "agent_failures": 0, + "tests_written": 0, + } + + def test_valid_counter_values_load_correctly(self, tmp_path): + """Valid numeric counter values must still load and normalize.""" + state_path = tmp_path / "state.json" + existing = { + "version": 1, + "date": "2026-04-09", + "branch": "nightshift/2026-04-09", + "agent": "claude", + "baseline": {"status": "passed", "command": None, "message": ""}, + "counters": { + "fixes": 5.0, + "issues_logged": 2, + "files_touched": 7, + "low_impact_fixes": 1, + "failed_verifications": 3, + "empty_cycles": 4, + "agent_failures": 6, + "tests_written": 8, + }, + "recent_cycle_paths": [], + "cycles": [], + "halt_reason": None, + "log_only_mode": False, + } + state_path.write_text(json.dumps(existing)) + state = nightshift.read_state( + state_path, + today="2026-04-09", + branch="nightshift/2026-04-09", + agent="claude", + verify_command=None, + ) + assert state["counters"] == { + "fixes": 5, + "issues_logged": 2, + "files_touched": 7, + "low_impact_fixes": 1, + "failed_verifications": 3, + "empty_cycles": 4, + "agent_failures": 6, + "tests_written": 8, + } + class TestTopPath: def test_basic(self): From 8b918eb87f2b545c9304f6305c7dcfa10c39fa20 Mon Sep 17 00:00:00 2001 From: No9 Labs Date: Thu, 9 Apr 2026 07:36:56 -0400 Subject: [PATCH 2/3] fix: fail closed on corrupt safety counters --- nightshift/core/state.py | 34 ++++++++++++-------- nightshift/tests/test_nightshift.py | 49 ++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/nightshift/core/state.py b/nightshift/core/state.py index 006d36f..02e2e1e 100644 --- a/nightshift/core/state.py +++ b/nightshift/core/state.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import math from pathlib import Path from typing import Any @@ -35,13 +36,16 @@ def write_json(path: Path, payload: dict[str, Any] | ShiftState | FeatureState) _REQUIRED_STATE_KEYS = {"version", "date", "branch", "agent", "baseline", "counters", "cycles"} +_SAFETY_SENSITIVE_COUNTERS = {"failed_verifications", "empty_cycles", "agent_failures"} -def _safe_int(value: object, default: int = 0) -> int: - """Convert numeric values to int and fall back for corrupt data.""" - if isinstance(value, (int, float)) and not isinstance(value, bool): +def _load_counter_value(name: str, value: object, state_path: Path) -> int: + """Load a counter value, failing closed for safety-sensitive fields.""" + if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value): return int(value) - return default + if name in _SAFETY_SENSITIVE_COUNTERS: + raise NightshiftError(f"Corrupt state file {state_path}: counter '{name}' must be numeric") + return 0 def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: @@ -59,14 +63,18 @@ def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: message=str(baseline_raw.get("message", "")), ) counters = Counters( - fixes=_safe_int(counters_raw.get("fixes", 0)), - issues_logged=_safe_int(counters_raw.get("issues_logged", 0)), - files_touched=_safe_int(counters_raw.get("files_touched", 0)), - low_impact_fixes=_safe_int(counters_raw.get("low_impact_fixes", 0)), - failed_verifications=_safe_int(counters_raw.get("failed_verifications", 0)), - empty_cycles=_safe_int(counters_raw.get("empty_cycles", 0)), - agent_failures=_safe_int(counters_raw.get("agent_failures", 0)), - tests_written=_safe_int(counters_raw.get("tests_written", 0)), + fixes=_load_counter_value("fixes", counters_raw.get("fixes", 0), state_path), + issues_logged=_load_counter_value("issues_logged", counters_raw.get("issues_logged", 0), state_path), + files_touched=_load_counter_value("files_touched", counters_raw.get("files_touched", 0), state_path), + low_impact_fixes=_load_counter_value("low_impact_fixes", counters_raw.get("low_impact_fixes", 0), state_path), + failed_verifications=_load_counter_value( + "failed_verifications", + counters_raw.get("failed_verifications", 0), + state_path, + ), + empty_cycles=_load_counter_value("empty_cycles", counters_raw.get("empty_cycles", 0), state_path), + agent_failures=_load_counter_value("agent_failures", counters_raw.get("agent_failures", 0), state_path), + tests_written=_load_counter_value("tests_written", counters_raw.get("tests_written", 0), state_path), ) cycles_raw = raw.get("cycles", []) if not isinstance(cycles_raw, list): @@ -78,7 +86,7 @@ def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: category_counts: dict[str, int] = { k: int(v) for k, v in raw_category_counts.items() - if isinstance(k, str) and k in _VALID_CATEGORIES and isinstance(v, (int, float)) + if isinstance(k, str) and k in _VALID_CATEGORIES and isinstance(v, (int, float)) and math.isfinite(v) } return ShiftState( diff --git a/nightshift/tests/test_nightshift.py b/nightshift/tests/test_nightshift.py index 44a5aee..265abd3 100644 --- a/nightshift/tests/test_nightshift.py +++ b/nightshift/tests/test_nightshift.py @@ -688,8 +688,8 @@ def test_corrupt_category_counts_values_dropped_silently(self, tmp_path): Task #0266: int(v) in the category_counts comprehension has no guard. A state file with {"Security": "not-a-number"} or {"Security": null} - would previously raise ValueError/TypeError. The isinstance check - ensures corrupt values are silently skipped, consistent with how + would previously raise ValueError/TypeError. Non-finite floats are also + skipped, so corrupt values are silently dropped, consistent with how unknown keys are already silently dropped. """ state_path = tmp_path / "state.json" @@ -715,6 +715,8 @@ def test_corrupt_category_counts_values_dropped_silently(self, tmp_path): "Error Handling": None, "A11y": [], "Performance": "not-a-number", + "Docs": float("nan"), + "Polish": float("inf"), }, "recent_cycle_paths": [], "cycles": [], @@ -736,6 +738,8 @@ def test_corrupt_category_counts_values_dropped_silently(self, tmp_path): assert "Error Handling" not in state["category_counts"] assert "A11y" not in state["category_counts"] assert "Performance" not in state["category_counts"] + assert "Docs" not in state["category_counts"] + assert "Polish" not in state["category_counts"] def test_valid_category_counts_load_correctly(self, tmp_path): """Valid numeric category_counts survive the sanitization pass intact. @@ -780,8 +784,8 @@ def test_valid_category_counts_load_correctly(self, tmp_path): ) assert state["category_counts"] == {"Security": 3, "Tests": 2, "Code Quality": 1} - def test_corrupt_counter_values_fall_back_to_zero(self, tmp_path): - """Non-numeric counter values must not crash read_state.""" + def test_corrupt_noncritical_counter_values_fall_back_to_zero(self, tmp_path): + """Non-critical counter values may be sanitized to zero.""" state_path = tmp_path / "state.json" existing = { "version": 1, @@ -794,9 +798,6 @@ def test_corrupt_counter_values_fall_back_to_zero(self, tmp_path): "issues_logged": None, "files_touched": ["oops"], "low_impact_fixes": {"count": 1}, - "failed_verifications": "7", - "empty_cycles": False, - "agent_failures": [], "tests_written": "many", }, "recent_cycle_paths": [], @@ -823,6 +824,40 @@ def test_corrupt_counter_values_fall_back_to_zero(self, tmp_path): "tests_written": 0, } + def test_corrupt_safety_sensitive_counter_values_raise(self, tmp_path): + """Safety-sensitive counters must fail closed on corruption.""" + state_path = tmp_path / "state.json" + existing = { + "version": 1, + "date": "2026-04-09", + "branch": "nightshift/2026-04-09", + "agent": "claude", + "baseline": {"status": "passed", "command": None, "message": ""}, + "counters": { + "fixes": 1, + "issues_logged": 0, + "files_touched": 1, + "low_impact_fixes": 0, + "failed_verifications": "7", + "empty_cycles": float("inf"), + "agent_failures": [], + "tests_written": 0, + }, + "recent_cycle_paths": [], + "cycles": [], + "halt_reason": None, + "log_only_mode": False, + } + state_path.write_text(json.dumps(existing)) + with pytest.raises(nightshift.NightshiftError, match="counter 'failed_verifications' must be numeric"): + nightshift.read_state( + state_path, + today="2026-04-09", + branch="nightshift/2026-04-09", + agent="claude", + verify_command=None, + ) + def test_valid_counter_values_load_correctly(self, tmp_path): """Valid numeric counter values must still load and normalize.""" state_path = tmp_path / "state.json" From d946e0bafe149fddda37279a8047efccac30d97f Mon Sep 17 00:00:00 2001 From: No9 Labs Date: Thu, 9 Apr 2026 07:43:21 -0400 Subject: [PATCH 3/3] fix: sanitize corrupt state counters --- .recursive/architecture/MODULE_MAP.md | 2 +- nightshift/core/state.py | 27 +++++++----------- nightshift/tests/test_nightshift.py | 41 ++++----------------------- 3 files changed, 16 insertions(+), 54 deletions(-) diff --git a/.recursive/architecture/MODULE_MAP.md b/.recursive/architecture/MODULE_MAP.md index 6511821..56f1a26 100644 --- a/.recursive/architecture/MODULE_MAP.md +++ b/.recursive/architecture/MODULE_MAP.md @@ -17,7 +17,7 @@ Read it before opening modules one by one when you need fast orientation. | `core/constants.py` | 847 | Module-level constants and tiny utilities used across the package. | `now_local`, `print_status`, `DATA_VERSION`, `SUPPORTED_AGENTS` | PR #269 (2e91d5f) | | `raven/summary.py` | 141 | Feature summary generation for Loop 2 build output. | `generate_feature_summary`, `_API_DIR_SEGMENTS`, `_CLI_DIR_SEGMENTS`, `_CONFIG_DIR_SEGMENTS` | 1636b72 | | `core/shell.py` | 256 | Subprocess execution: streaming runner, git helper, shell utilities. | `run_command`, `run_capture`, `git`, `command_exists` | PR #269 (2e91d5f) | -| `core/state.py` | 236 | Shift state: read, write, mutate counters, JSON I/O. | `load_json`, `write_json`, `read_state`, `top_path` | session #0092 | +| `core/state.py` | 237 | Shift state: read, write, mutate counters, JSON I/O. | `load_json`, `write_json`, `read_state`, `top_path` | session #0092 | | `owl/readiness.py` | 234 | Production-readiness checks for Loop 2 feature builds. | `collect_changed_files`, `check_secrets`, `check_debug_prints`, `check_test_coverage` | PR #204 (df36eff) | | `raven/coordination.py` | 196 | Sub-agent coordination for Loop 2 -- detects file overlaps and generates hints. | `extract_file_references`, `detect_overlaps`, `generate_coordination_hints`, `inject_hints` | PR #229 (c2acba2) | | `infra/module_map.py` | 473 | Generate a persistent module map for fast cross-session orientation. | `module_map_path`, `generate_module_map`, `render_module_map`, `write_module_map` | PR #251 (c32e527) | diff --git a/nightshift/core/state.py b/nightshift/core/state.py index 02e2e1e..4e8b2dd 100644 --- a/nightshift/core/state.py +++ b/nightshift/core/state.py @@ -36,15 +36,12 @@ def write_json(path: Path, payload: dict[str, Any] | ShiftState | FeatureState) _REQUIRED_STATE_KEYS = {"version", "date", "branch", "agent", "baseline", "counters", "cycles"} -_SAFETY_SENSITIVE_COUNTERS = {"failed_verifications", "empty_cycles", "agent_failures"} -def _load_counter_value(name: str, value: object, state_path: Path) -> int: - """Load a counter value, failing closed for safety-sensitive fields.""" +def _load_counter_value(value: object) -> int: + """Convert a counter value to int, falling back to zero for bad data.""" if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value): return int(value) - if name in _SAFETY_SENSITIVE_COUNTERS: - raise NightshiftError(f"Corrupt state file {state_path}: counter '{name}' must be numeric") return 0 @@ -63,18 +60,14 @@ def _build_state(raw: dict[str, Any], state_path: Path) -> ShiftState: message=str(baseline_raw.get("message", "")), ) counters = Counters( - fixes=_load_counter_value("fixes", counters_raw.get("fixes", 0), state_path), - issues_logged=_load_counter_value("issues_logged", counters_raw.get("issues_logged", 0), state_path), - files_touched=_load_counter_value("files_touched", counters_raw.get("files_touched", 0), state_path), - low_impact_fixes=_load_counter_value("low_impact_fixes", counters_raw.get("low_impact_fixes", 0), state_path), - failed_verifications=_load_counter_value( - "failed_verifications", - counters_raw.get("failed_verifications", 0), - state_path, - ), - empty_cycles=_load_counter_value("empty_cycles", counters_raw.get("empty_cycles", 0), state_path), - agent_failures=_load_counter_value("agent_failures", counters_raw.get("agent_failures", 0), state_path), - tests_written=_load_counter_value("tests_written", counters_raw.get("tests_written", 0), state_path), + fixes=_load_counter_value(counters_raw.get("fixes", 0)), + issues_logged=_load_counter_value(counters_raw.get("issues_logged", 0)), + files_touched=_load_counter_value(counters_raw.get("files_touched", 0)), + low_impact_fixes=_load_counter_value(counters_raw.get("low_impact_fixes", 0)), + failed_verifications=_load_counter_value(counters_raw.get("failed_verifications", 0)), + empty_cycles=_load_counter_value(counters_raw.get("empty_cycles", 0)), + agent_failures=_load_counter_value(counters_raw.get("agent_failures", 0)), + tests_written=_load_counter_value(counters_raw.get("tests_written", 0)), ) cycles_raw = raw.get("cycles", []) if not isinstance(cycles_raw, list): diff --git a/nightshift/tests/test_nightshift.py b/nightshift/tests/test_nightshift.py index 265abd3..5876fbf 100644 --- a/nightshift/tests/test_nightshift.py +++ b/nightshift/tests/test_nightshift.py @@ -784,8 +784,8 @@ def test_valid_category_counts_load_correctly(self, tmp_path): ) assert state["category_counts"] == {"Security": 3, "Tests": 2, "Code Quality": 1} - def test_corrupt_noncritical_counter_values_fall_back_to_zero(self, tmp_path): - """Non-critical counter values may be sanitized to zero.""" + def test_corrupt_counter_values_fall_back_to_zero(self, tmp_path): + """Corrupt counter values must not crash read_state and fall back to zero.""" state_path = tmp_path / "state.json" existing = { "version": 1, @@ -798,6 +798,9 @@ def test_corrupt_noncritical_counter_values_fall_back_to_zero(self, tmp_path): "issues_logged": None, "files_touched": ["oops"], "low_impact_fixes": {"count": 1}, + "failed_verifications": "7", + "empty_cycles": float("inf"), + "agent_failures": [], "tests_written": "many", }, "recent_cycle_paths": [], @@ -824,40 +827,6 @@ def test_corrupt_noncritical_counter_values_fall_back_to_zero(self, tmp_path): "tests_written": 0, } - def test_corrupt_safety_sensitive_counter_values_raise(self, tmp_path): - """Safety-sensitive counters must fail closed on corruption.""" - state_path = tmp_path / "state.json" - existing = { - "version": 1, - "date": "2026-04-09", - "branch": "nightshift/2026-04-09", - "agent": "claude", - "baseline": {"status": "passed", "command": None, "message": ""}, - "counters": { - "fixes": 1, - "issues_logged": 0, - "files_touched": 1, - "low_impact_fixes": 0, - "failed_verifications": "7", - "empty_cycles": float("inf"), - "agent_failures": [], - "tests_written": 0, - }, - "recent_cycle_paths": [], - "cycles": [], - "halt_reason": None, - "log_only_mode": False, - } - state_path.write_text(json.dumps(existing)) - with pytest.raises(nightshift.NightshiftError, match="counter 'failed_verifications' must be numeric"): - nightshift.read_state( - state_path, - today="2026-04-09", - branch="nightshift/2026-04-09", - agent="claude", - verify_command=None, - ) - def test_valid_counter_values_load_correctly(self, tmp_path): """Valid numeric counter values must still load and normalize.""" state_path = tmp_path / "state.json"