From 8120de65c09a58940a137817a097ab1740db0bc5 Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:07:04 -0400 Subject: [PATCH] fix(lint): pin the ruff rule set, and clear what it selects CI was red with 201 findings that no code change caused. This repo carries no ruff configuration at all, so its rule set is whatever the installed ruff happens to default to -- and CI installs it unpinned (`ruff>=0.5`). ruff 0.16 widened its defaults, CI picked up 0.16.1, and a green repo went red on rules the project never opted into. Measured rather than assumed: ruff 0.16.1 with `--select E4,E7,E9,F` (its own documented default set) reports ZERO findings here, exactly as 0.15.13 does with no config. Every one of the 201 came from the default set expanding underneath us. Custodian hit the same class of failure from the other side in its adapters, where a global 0.16.1 shadowed a pinned 0.15.13 and produced 1222 phantom findings against a gate no venv could fix. So the fix is to state the intent once, in `[tool.ruff.lint].select`, where a future release cannot revise it. The selection is the defaults this repo has always passed, plus nine rules adopted deliberately -- each mechanical, auto-fixable, and cleared in this same commit rather than left as a promise: I001, UP017, UP035, UP037, UP045, RUF022, RUF023, FURB188, PLR0402. 112 fixes applied by `ruff check --fix` across 37 files, the bulk of it `datetime.timezone.utc` -> `datetime.UTC` and import ordering. The one class that can change behaviour is FURB188; all four sites were the guarded form (`if s.startswith(p): s = s[len(p):]`), which `removeprefix` reproduces exactly. Suite is unchanged at 16 failed / 488 passed, and the 16 are identical by name before and after -- diffed, not counted. The rules NOT selected are listed in the config with a reason each, rather than being silently absent. BLE001, S110/S112, PLC0415 and E402 are mostly deliberate patterns already annotated at their sites; B008 is a straight false positive for Typer, whose whole CLI surface is built from `typer.Option(...)` defaults. DTZ*, PLW1510, TRY004 and RUF059 are real signals worth adopting later, but each needs judgement per site and adopting one means clearing every hit in that pass. CI's floor moves 0.5 -> 0.15 so every selected code is known to the installed ruff. It is a floor, not a pin: the config is what stops the drift now. No log entry: `.console/log.md` is at exactly 400/400, so any entry trips RC1 and fails the audit. That the convention to log every change and the gate capping the log are now mutually exclusive here is itself the argument in Custodian ADR 0001, which recommends rationale live in the commit message. It does. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 ++- .../claude/hooks/tests/validate_examples.py | 3 +- pyproject.toml | 38 +++++++++++++++++++ src/context_lifecycle/__init__.py | 18 ++++----- src/context_lifecycle/cli/ledger.py | 5 ++- src/context_lifecycle/cli/loop.py | 4 +- src/context_lifecycle/cli/reconcile.py | 2 +- src/context_lifecycle/cli/session.py | 9 +++-- .../context_engine/attribution.py | 8 ++-- .../context_engine/attribution_apply.py | 2 +- src/context_lifecycle/context_engine/cold.py | 3 +- src/context_lifecycle/context_engine/route.py | 5 +-- src/context_lifecycle/hooks/__init__.py | 8 ++-- src/context_lifecycle/hooks/decisions.py | 4 +- src/context_lifecycle/hooks/pre_tool_use.py | 6 +-- src/context_lifecycle/io/__init__.py | 4 +- src/context_lifecycle/ledger/check.py | 2 +- src/context_lifecycle/ledger/observe.py | 2 +- src/context_lifecycle/lifecycle.py | 12 +++--- src/context_lifecycle/models/__init__.py | 16 ++++---- src/context_lifecycle/models/config.py | 2 +- src/context_lifecycle/models/handoff.py | 6 +-- .../pseudo_operator/engine.py | 14 +++---- .../pseudo_operator/limits.py | 10 ++--- .../pseudo_operator/sessions.py | 12 +++--- src/context_lifecycle/session/__init__.py | 20 +++++----- src/context_lifecycle/session/ids.py | 4 +- tests/conftest.py | 4 +- tests/test_consolidate.py | 1 - tests/test_engine.py | 14 +++---- tests/test_handoff.py | 8 ++-- tests/test_ids.py | 4 +- tests/test_lifecycle.py | 1 - tests/test_limits.py | 6 +-- tests/test_paths.py | 1 - tests/test_pre_tool_use.py | 7 ++-- tests/test_retention.py | 5 ++- tests/test_signing.py | 2 +- 38 files changed, 161 insertions(+), 116 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index effb02d..9bd084c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,10 @@ jobs: with: python-version: "3.11" - name: Install ruff - run: pip install "ruff>=0.5" + # Floor, not a pin: the rule set is fixed by [tool.ruff.lint].select + # in pyproject.toml, so a newer ruff cannot switch rules on by itself. + # The floor only guarantees every selected code is known. + run: pip install "ruff>=0.15" - name: Run ruff run: ruff check . diff --git a/adapters/claude/hooks/tests/validate_examples.py b/adapters/claude/hooks/tests/validate_examples.py index 67adb4a..eb1bf69 100644 --- a/adapters/claude/hooks/tests/validate_examples.py +++ b/adapters/claude/hooks/tests/validate_examples.py @@ -6,9 +6,10 @@ Exit 0 = all pass. Exit 1 = failures. """ import sys -import yaml from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent PASS = 0 diff --git a/pyproject.toml b/pyproject.toml index 8807967..769c832 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,41 @@ context_lifecycle = ["py.typed"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] + +[tool.ruff] +# Without this block the rule set is whatever the installed ruff defaults to, +# which is how 201 findings appeared here without a line of code changing: +# 0.16 widened its defaults, CI installed it unpinned, and rules the project +# never opted into red-walled a green repo. `select` below is the project's +# intent, stated once, so a future release cannot decide it for us. +target-version = "py311" # matches requires-python; UP rules key off this +line-length = 100 + +[tool.ruff.lint] +select = [ + # Ruff's own documented defaults — this repo has always passed these. + "E4", "E7", "E9", "F", + # Adopted deliberately: each is mechanical, auto-fixable, and was cleared + # in the same commit that selected it. + "I", # import sorting + "UP017", # datetime.timezone.utc -> datetime.UTC + "UP035", # deprecated import + "UP037", # quoted annotation no longer needed + "UP045", # Optional[X] -> X | None + "RUF022", # __all__ sorted + "RUF023", # __slots__ sorted + "FURB188", # manual slice -> removeprefix/removesuffix + "PLR0402", # import x.y as y -> from x import y +] +# Deliberately NOT selected, rather than silently absent. Each is a real signal +# but needs judgement per site, and adopting one means fixing every hit in the +# same pass — not sprinkling suppressions: +# BLE001 blind except — many here are best-effort discovery, already +# annotated with a reason at the site +# PLC0415 import not at top — several are deliberate lazy imports +# B008 call in argument default — a false positive for Typer, whose +# entire CLI surface is built from `typer.Option(...)` defaults +# E402 import not at top — tests/conftest.py imports after the venv guard +# on purpose, so the guard can fail before anything else loads +# S110/S112, S603, S606, DTZ*, PLW1510, TRY004, RUF059, SIM102/103, C408, +# RUF012, FURB162, ISC004, PIE810 diff --git a/src/context_lifecycle/__init__.py b/src/context_lifecycle/__init__.py index 0b3a40f..8151e63 100644 --- a/src/context_lifecycle/__init__.py +++ b/src/context_lifecycle/__init__.py @@ -3,11 +3,11 @@ """ContextLifecycle — cognition lifecycle schemas, I/O, policy enforcement.""" from context_lifecycle.errors import ( - CLError, - AnchorMissing, - AnchorInvalid, AmbiguousAnchor, + AnchorInvalid, + AnchorMissing, BoundaryViolation, + CLError, ManifestNotFound, SessionNotStarted, ) @@ -21,16 +21,16 @@ __version__ = "0.3.0" __all__ = [ - "__version__", - "CLError", - "AnchorMissing", - "AnchorInvalid", "AmbiguousAnchor", + "AnchorInvalid", + "AnchorMissing", "BoundaryViolation", + "CLError", + "HydratedContext", "ManifestNotFound", "SessionNotStarted", - "HydratedContext", - "hydrate", + "__version__", "capture", + "hydrate", "peek", ] diff --git a/src/context_lifecycle/cli/ledger.py b/src/context_lifecycle/cli/ledger.py index 5cad6ba..f604303 100644 --- a/src/context_lifecycle/cli/ledger.py +++ b/src/context_lifecycle/cli/ledger.py @@ -22,10 +22,13 @@ import typer from context_lifecycle.ledger import LedgerUnavailable, capture -from context_lifecycle.ledger.check import DEFAULT_MAX_AGE_DAYS, check as run_check +from context_lifecycle.ledger.check import DEFAULT_MAX_AGE_DAYS +from context_lifecycle.ledger.check import check as run_check from context_lifecycle.ledger.observe import ( DEFAULT_MIN_COUNT, DEFAULT_WINDOW_DAYS, +) +from context_lifecycle.ledger.observe import ( observe as run_observe, ) from context_lifecycle.ledger.promote import promote as run_promote diff --git a/src/context_lifecycle/cli/loop.py b/src/context_lifecycle/cli/loop.py index 360c49b..af89a26 100644 --- a/src/context_lifecycle/cli/loop.py +++ b/src/context_lifecycle/cli/loop.py @@ -21,7 +21,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import NoReturn @@ -203,7 +203,7 @@ def signal_cmd( cfg.state_path.mkdir(parents=True, exist_ok=True) payload = { "task": task, - "injected_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "injected_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "injected_by": "cl loop signal", "consumed": False, } diff --git a/src/context_lifecycle/cli/reconcile.py b/src/context_lifecycle/cli/reconcile.py index 834bcc2..ac4659e 100644 --- a/src/context_lifecycle/cli/reconcile.py +++ b/src/context_lifecycle/cli/reconcile.py @@ -21,6 +21,7 @@ render_index, ) from context_lifecycle.reconcile.lock import PruneLockHeld +from context_lifecycle.reconcile.privacy import PrivateArchiveUnavailable from context_lifecycle.reconcile.prune import ( DEFAULT_RECENT_N, PruneRefused, @@ -28,7 +29,6 @@ build_plan, format_plan, ) -from context_lifecycle.reconcile.privacy import PrivateArchiveUnavailable app = typer.Typer(no_args_is_help=True, add_completion=False) diff --git a/src/context_lifecycle/cli/session.py b/src/context_lifecycle/cli/session.py index 1c86252..d0dde66 100644 --- a/src/context_lifecycle/cli/session.py +++ b/src/context_lifecycle/cli/session.py @@ -16,7 +16,6 @@ import os import shutil from pathlib import Path -from typing import Optional import typer @@ -28,11 +27,15 @@ ) from context_lifecycle.session.anchor import ( ENV_VAR as ANCHOR_ENV, +) +from context_lifecycle.session.anchor import ( resolve_anchor_arg, validate_anchor, ) from context_lifecycle.session.ids import ( ENV_VAR as SESSION_ENV, +) +from context_lifecycle.session.ids import ( generate_session_id, is_valid_session_id, ) @@ -50,7 +53,7 @@ @app.command("start") def start( - manifest: Optional[str] = typer.Argument( + manifest: str | None = typer.Argument( None, help="Manifest name or absolute path. If omitted, P2 will infer via RepoGraph.", ), @@ -182,7 +185,7 @@ def end( @app.command("prune") def prune( - manifest: Optional[str] = typer.Argument( + manifest: str | None = typer.Argument( None, help=f"Anchor manifest name or path (default: ${ANCHOR_ENV}).", ), diff --git a/src/context_lifecycle/context_engine/attribution.py b/src/context_lifecycle/context_engine/attribution.py index e174481..6046b97 100644 --- a/src/context_lifecycle/context_engine/attribution.py +++ b/src/context_lifecycle/context_engine/attribution.py @@ -53,10 +53,10 @@ import re import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Callable # The verbatim sentinel cold.py stores when CI cannot be resolved (matches # ci_status.UNKNOWN; duplicated here so the planner loads standalone). @@ -287,7 +287,7 @@ def _matches_any_glob(changed: tuple[str, ...], globs: tuple[str, ...], cold) -> means precisely what "this item surfaces for that path" means. """ for raw in changed: - target = raw[2:] if raw.startswith("./") else raw + target = raw.removeprefix("./") for glob in globs: if cold._glob_to_regex(glob).match(target): return True @@ -357,7 +357,7 @@ def plan_attribution( # sort last and are rejected per-commit below. def _commit_key(c: CitedCommit) -> tuple: dt = _as_dt(c.author_date) - return (dt is None, dt or datetime.max.replace(tzinfo=timezone.utc), c.sha) + return (dt is None, dt or datetime.max.replace(tzinfo=UTC), c.sha) commits.sort(key=_commit_key) diff --git a/src/context_lifecycle/context_engine/attribution_apply.py b/src/context_lifecycle/context_engine/attribution_apply.py index 5cc3046..ce0b3f8 100644 --- a/src/context_lifecycle/context_engine/attribution_apply.py +++ b/src/context_lifecycle/context_engine/attribution_apply.py @@ -60,9 +60,9 @@ import re import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field, replace from pathlib import Path -from typing import Callable # The verbatim sentinel cold.py stores when CI cannot be resolved (matches # ci_status.UNKNOWN / attribution.UNKNOWN; duplicated so this module loads diff --git a/src/context_lifecycle/context_engine/cold.py b/src/context_lifecycle/context_engine/cold.py index caa23ea..bbaf46f 100644 --- a/src/context_lifecycle/context_engine/cold.py +++ b/src/context_lifecycle/context_engine/cold.py @@ -298,8 +298,7 @@ def surface_cold(target: str, knowledge_dir: Path, max_items: int) -> list[str]: (spec §1). """ try: - if target.startswith("./"): - target = target[2:] + target = target.removeprefix("./") matched: list[tuple[str, str]] = [] # (topic, line) for item in load_index(knowledge_dir): if item.tier != "cold": diff --git a/src/context_lifecycle/context_engine/route.py b/src/context_lifecycle/context_engine/route.py index bfc911e..4789389 100644 --- a/src/context_lifecycle/context_engine/route.py +++ b/src/context_lifecycle/context_engine/route.py @@ -24,8 +24,8 @@ import json import re import sys -from datetime import datetime from dataclasses import dataclass +from datetime import datetime from pathlib import Path import yaml @@ -207,8 +207,7 @@ def select_docs_split( """ # Strip only a leading literal "./" prefix — NOT a character set, which # would mangle dot-prefixed paths like ".github/...". - if target.startswith("./"): - target = target[2:] + target = target.removeprefix("./") best: dict[str, int] = {} order: list[str] = [] for route in routes: diff --git a/src/context_lifecycle/hooks/__init__.py b/src/context_lifecycle/hooks/__init__.py index 155c5b7..8954df8 100644 --- a/src/context_lifecycle/hooks/__init__.py +++ b/src/context_lifecycle/hooks/__init__.py @@ -3,21 +3,21 @@ """Hook decision functions — pure logic over loaded state.""" from context_lifecycle.hooks.decisions import ( - Decision, Allow, Block, - Warn, + Decision, DecisionResult, + Warn, ) from context_lifecycle.hooks.pre_tool_use import evaluate_pre_tool_use from context_lifecycle.hooks.stop import evaluate_stop __all__ = [ - "Decision", "Allow", "Block", - "Warn", + "Decision", "DecisionResult", + "Warn", "evaluate_pre_tool_use", "evaluate_stop", ] diff --git a/src/context_lifecycle/hooks/decisions.py b/src/context_lifecycle/hooks/decisions.py index 4ba274b..d9d5359 100644 --- a/src/context_lifecycle/hooks/decisions.py +++ b/src/context_lifecycle/hooks/decisions.py @@ -31,14 +31,14 @@ class DecisionResult: reason: str = "" warnings: list[Warn] = field(default_factory=list) - def block(self, reason: str) -> "DecisionResult": + def block(self, reason: str) -> DecisionResult: # First block wins (matches bash's `exit 2` short-circuit). if self.decision is Decision.ALLOW: self.decision = Decision.BLOCK self.reason = reason return self - def warn(self, reason: str) -> "DecisionResult": + def warn(self, reason: str) -> DecisionResult: self.warnings.append(Warn(reason)) return self diff --git a/src/context_lifecycle/hooks/pre_tool_use.py b/src/context_lifecycle/hooks/pre_tool_use.py index 1636bf5..94df1a0 100644 --- a/src/context_lifecycle/hooks/pre_tool_use.py +++ b/src/context_lifecycle/hooks/pre_tool_use.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -28,7 +28,7 @@ class HookInput: tool_input: dict[str, Any] @classmethod - def from_payload(cls, payload: dict[str, Any]) -> "HookInput": + def from_payload(cls, payload: dict[str, Any]) -> HookInput: return cls( tool_name=str(payload.get("tool_name", "")), tool_input=payload.get("tool_input") or {}, @@ -89,7 +89,7 @@ def evaluate_pre_tool_use( ) -> DecisionResult: """Pure decision function. Returns a DecisionResult; CLI maps it to exit codes.""" result = Allow() - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) # --- require_capsule --- if config.guard.require_capsule: diff --git a/src/context_lifecycle/io/__init__.py b/src/context_lifecycle/io/__init__.py index 871d06d..a4e0f81 100644 --- a/src/context_lifecycle/io/__init__.py +++ b/src/context_lifecycle/io/__init__.py @@ -2,6 +2,6 @@ # Copyright (C) 2026 ProtocolWarden """YAML I/O helpers.""" -from context_lifecycle.io.yaml_io import load_yaml, dump_yaml, load_yaml_safe +from context_lifecycle.io.yaml_io import dump_yaml, load_yaml, load_yaml_safe -__all__ = ["load_yaml", "dump_yaml", "load_yaml_safe"] +__all__ = ["dump_yaml", "load_yaml", "load_yaml_safe"] diff --git a/src/context_lifecycle/ledger/check.py b/src/context_lifecycle/ledger/check.py index bda77c5..787da3c 100644 --- a/src/context_lifecycle/ledger/check.py +++ b/src/context_lifecycle/ledger/check.py @@ -30,7 +30,7 @@ class StaleCandidate: """A candidate older than the threshold. Plain value object.""" - __slots__ = ("date", "signal", "context", "age_days") + __slots__ = ("age_days", "context", "date", "signal") def __init__(self, date: str, signal: str, context: str, age_days: int) -> None: self.date = date diff --git a/src/context_lifecycle/ledger/observe.py b/src/context_lifecycle/ledger/observe.py index 29c4f2d..1696db2 100644 --- a/src/context_lifecycle/ledger/observe.py +++ b/src/context_lifecycle/ledger/observe.py @@ -38,7 +38,7 @@ class RecurringSignal: """A signal recurring across candidates within the window. Value object.""" - __slots__ = ("signal", "count", "latest_date", "contexts") + __slots__ = ("contexts", "count", "latest_date", "signal") def __init__( self, signal: str, count: int, latest_date: str, contexts: list[str] diff --git a/src/context_lifecycle/lifecycle.py b/src/context_lifecycle/lifecycle.py index ca1cce2..83f2510 100644 --- a/src/context_lifecycle/lifecycle.py +++ b/src/context_lifecycle/lifecycle.py @@ -25,10 +25,11 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Iterable +from typing import Any from context_lifecycle.errors import BoundaryViolation from context_lifecycle.io.yaml_io import dump_yaml, load_yaml_safe @@ -36,7 +37,6 @@ from context_lifecycle.session.ids import require_session_env from context_lifecycle.session.paths import SessionPaths - # --------------------------------------------------------------------------- # Public dataclass # --------------------------------------------------------------------------- @@ -223,7 +223,7 @@ def hydrate(lineage_id: str, work_item: dict[str, Any]) -> HydratedContext: "lineage_id": lineage_id, "session_id": paths.session_id, "status": "fresh", - "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "created_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "work_item": dict(work_item) if isinstance(work_item, dict) else work_item, } fresh = True @@ -256,7 +256,7 @@ def capture(lineage_id: str, result: dict[str, Any]) -> None: if kind == "capsule": target = _active_file(paths, lineage_id) elif kind == "checkpoint": - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") ckpt_id = result.get("checkpoint_id") or ts target = paths.checkpoints / f"{ckpt_id}.yaml" else: # handoff @@ -272,7 +272,7 @@ def capture(lineage_id: str, result: dict[str, Any]) -> None: payload.setdefault("session_id", paths.session_id) payload.setdefault( "captured_at", - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), ) dump_yaml(target, payload) diff --git a/src/context_lifecycle/models/__init__.py b/src/context_lifecycle/models/__init__.py index 92d1310..0badd1b 100644 --- a/src/context_lifecycle/models/__init__.py +++ b/src/context_lifecycle/models/__init__.py @@ -3,19 +3,19 @@ """Pydantic v2 models mirroring the YAML schemas in .context/schemas/.""" from context_lifecycle.models.capsule import InvestigationCapsule -from context_lifecycle.models.checkpoint import LoopCheckpoint, ContextRisk, Orchestrator -from context_lifecycle.models.handoff import WorkerHandoff, WorkerScope, Lease -from context_lifecycle.models.config import GuardConfig, LoopConfig, CLConfig +from context_lifecycle.models.checkpoint import ContextRisk, LoopCheckpoint, Orchestrator +from context_lifecycle.models.config import CLConfig, GuardConfig, LoopConfig +from context_lifecycle.models.handoff import Lease, WorkerHandoff, WorkerScope __all__ = [ + "CLConfig", + "ContextRisk", + "GuardConfig", "InvestigationCapsule", + "Lease", "LoopCheckpoint", - "ContextRisk", + "LoopConfig", "Orchestrator", "WorkerHandoff", "WorkerScope", - "Lease", - "GuardConfig", - "LoopConfig", - "CLConfig", ] diff --git a/src/context_lifecycle/models/config.py b/src/context_lifecycle/models/config.py index abdbdc7..fabc34f 100644 --- a/src/context_lifecycle/models/config.py +++ b/src/context_lifecycle/models/config.py @@ -32,7 +32,7 @@ class CLConfig(BaseModel): loop: LoopConfig = Field(default_factory=LoopConfig) @classmethod - def from_file(cls, path: Path) -> "CLConfig": + def from_file(cls, path: Path) -> CLConfig: """Load from YAML file. Returns defaults if file missing or malformed.""" from context_lifecycle.io.yaml_io import load_yaml_safe diff --git a/src/context_lifecycle/models/handoff.py b/src/context_lifecycle/models/handoff.py index 80bdd0a..ef2be5e 100644 --- a/src/context_lifecycle/models/handoff.py +++ b/src/context_lifecycle/models/handoff.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pydantic import BaseModel, ConfigDict, Field @@ -61,12 +61,12 @@ def is_lease_expired(self, now: datetime | None = None) -> bool: ts = self.effective_expires_at if not ts: return False - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) try: # Accept "Z" suffix and offset-aware ISO formats parsed = datetime.fromisoformat(ts.replace("Z", "+00:00")) except ValueError: return False if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) + parsed = parsed.replace(tzinfo=UTC) return now > parsed diff --git a/src/context_lifecycle/pseudo_operator/engine.py b/src/context_lifecycle/pseudo_operator/engine.py index 6af4956..9cc7c64 100644 --- a/src/context_lifecycle/pseudo_operator/engine.py +++ b/src/context_lifecycle/pseudo_operator/engine.py @@ -26,17 +26,17 @@ import socket import sys import time -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from .config import BackendSpec, PseudoOperatorConfig -from .sessions import SessionMixin, _command_available, _resolve_command # noqa: F401 from .limits import ( GLOBAL_CLAUDE_LIMIT_RE, RATE_LIMIT_BUFFER, classify_limit_kind, parse_rate_limit_reset, ) +from .sessions import SessionMixin, _command_available, _resolve_command # noqa: F401 logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ def _log(self, msg: str) -> None: @staticmethod def _ts() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") # ── locking (atomic + hostname-aware, Track A7 semantics) ───────────── def _lock_payload(self) -> str: @@ -148,11 +148,11 @@ def _backend_available(self, name: str, cooldowns: dict[str, datetime | None]) - spec = self._backends[name] until = cooldowns.get(name) return _command_available(self._cli_for(spec)) and ( - until is None or datetime.now(timezone.utc) >= until + until is None or datetime.now(UTC) >= until ) def _clear_expired_cooldowns(self, cooldowns: dict[str, datetime | None]) -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, until in list(cooldowns.items()): if until is not None and now >= until: cooldowns[backend] = None @@ -179,7 +179,7 @@ def _next_runnable_fallback( return None def _next_backend_reset(self, cooldowns: dict[str, datetime | None]) -> datetime | None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) future = [dt for dt in cooldowns.values() if dt is not None and dt > now] return min(future) if future else None @@ -189,7 +189,7 @@ def _sleep_until_backend_reset(self, cooldowns: dict[str, datetime | None]) -> b return False delay = max( 60, - int((reset_dt - datetime.now(timezone.utc)).total_seconds()) + RATE_LIMIT_BUFFER, + int((reset_dt - datetime.now(UTC)).total_seconds()) + RATE_LIMIT_BUFFER, ) self._log( f"All available backends are cooling down until " diff --git a/src/context_lifecycle/pseudo_operator/limits.py b/src/context_lifecycle/pseudo_operator/limits.py index fd47d3a..a627b1d 100644 --- a/src/context_lifecycle/pseudo_operator/limits.py +++ b/src/context_lifecycle/pseudo_operator/limits.py @@ -12,7 +12,7 @@ import logging import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -95,7 +95,7 @@ def parse_rate_limit_reset( # reset is next year. if reset_local <= now_local: reset_local = reset_local.replace(year=reset_local.year + 1) - return reset_local.astimezone(timezone.utc), text + return reset_local.astimezone(UTC), text m = _TIMEZONE_RESET_RE.search(text) if m: @@ -113,13 +113,13 @@ def parse_rate_limit_reset( ) if reset_local <= now_local: reset_local += timedelta(days=1) - return reset_local.astimezone(timezone.utc), text + return reset_local.astimezone(UTC), text m = _ISO_RESET_RE.search(text) if m: return ( datetime.fromisoformat(m.group(1).replace("Z", "+00:00")).astimezone( - timezone.utc + UTC ), text, ) @@ -132,7 +132,7 @@ def parse_rate_limit_reset( seconds=int(m.group("seconds") or 0), ) if delta.total_seconds() > 0: - return datetime.now(timezone.utc) + delta, text + return datetime.now(UTC) + delta, text if _LIMIT_SIGNAL_RE.search(text): logger.info( diff --git a/src/context_lifecycle/pseudo_operator/sessions.py b/src/context_lifecycle/pseudo_operator/sessions.py index 32d7ca4..181f9cd 100644 --- a/src/context_lifecycle/pseudo_operator/sessions.py +++ b/src/context_lifecycle/pseudo_operator/sessions.py @@ -15,9 +15,10 @@ import re import shutil import subprocess -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path + def _resolve_command(command: str) -> str | None: resolved = shutil.which(command) if resolved is not None: @@ -94,8 +95,7 @@ def _env_file_vars_literal(env_file: Path) -> dict[str, str]: result: dict[str, str] = {} for line in env_file.read_text(encoding="utf-8").splitlines(): line = line.strip() - if line.startswith("export "): - line = line[len("export "):] + line = line.removeprefix("export ") if line and not line.startswith("#") and "=" in line: k, _, v = line.partition("=") result[k.strip()] = v.strip().strip("'\"") @@ -141,7 +141,7 @@ def _seed_cooldowns(self, cooldowns: dict[str, datetime | None]) -> None: except json.JSONDecodeError: self._log("seed_cooldowns hook produced non-JSON output — ignored.") return - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, iso in seeded.items(): if backend not in cooldowns or not iso: continue @@ -168,7 +168,7 @@ def _budget_guard(self, cooldowns: dict[str, datetime | None]) -> None: except json.JSONDecodeError: self._log("budget_guard hook produced non-JSON output — ignored.") return - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for backend, iso in horizons.items(): if backend not in cooldowns or not iso: continue @@ -345,7 +345,7 @@ def _session_command(self, name: str, prompt: str) -> list[str]: ] def _session_log_path(self, iteration: int, name: str) -> Path: - ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") return self.cfg.session_log_dir / f"iter_{iteration:04d}_{ts}_{name}.log" def run_session( diff --git a/src/context_lifecycle/session/__init__.py b/src/context_lifecycle/session/__init__.py index e30b4e9..82dd3d0 100644 --- a/src/context_lifecycle/session/__init__.py +++ b/src/context_lifecycle/session/__init__.py @@ -3,30 +3,30 @@ """Session anchor + id + path helpers.""" from context_lifecycle.session.anchor import ( - resolve_anchor_arg, require_anchor_env, + resolve_anchor_arg, validate_anchor, ) from context_lifecycle.session.ids import generate_session_id, require_session_env from context_lifecycle.session.paths import ( SessionPaths, - session_root, active_dir, + archived_root, checkpoints_dir, handoffs_dir, - archived_root, + session_root, ) __all__ = [ - "resolve_anchor_arg", - "require_anchor_env", - "validate_anchor", - "generate_session_id", - "require_session_env", "SessionPaths", - "session_root", "active_dir", + "archived_root", "checkpoints_dir", + "generate_session_id", "handoffs_dir", - "archived_root", + "require_anchor_env", + "require_session_env", + "resolve_anchor_arg", + "session_root", + "validate_anchor", ] diff --git a/src/context_lifecycle/session/ids.py b/src/context_lifecycle/session/ids.py index 4cf1c25..8524b5f 100644 --- a/src/context_lifecycle/session/ids.py +++ b/src/context_lifecycle/session/ids.py @@ -10,7 +10,7 @@ import os import re import secrets -from datetime import datetime, timezone +from datetime import UTC, datetime from context_lifecycle.errors import SessionNotStarted @@ -20,7 +20,7 @@ def generate_session_id(now: datetime | None = None) -> str: """Generate a fresh session id of the locked format.""" - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) date = now.strftime("%Y-%m-%d") # 4 hex chars from secrets — collision-safe enough for per-day session ids. rand = secrets.token_hex(2) diff --git a/tests/conftest.py b/tests/conftest.py index 61ca0b7..a8dd857 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import os import sys -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -106,7 +106,7 @@ def valid_handoff_data() -> dict: }, "lease": { "max_subagents": 2, - "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)) + "expires_at": (datetime.now(UTC) + timedelta(hours=1)) .strftime("%Y-%m-%dT%H:%M:%SZ"), }, } diff --git a/tests/test_consolidate.py b/tests/test_consolidate.py index 7229a9d..b4ce897 100644 --- a/tests/test_consolidate.py +++ b/tests/test_consolidate.py @@ -22,7 +22,6 @@ import sys from pathlib import Path - _ENGINE_DIR = Path(__file__).resolve().parent.parent / "src" / "context_lifecycle" / "context_engine" _REPO_ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_engine.py b/tests/test_engine.py index 44eab85..886794e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,7 +8,7 @@ import json import os import socket -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -17,8 +17,8 @@ PseudoOperatorEngine, load_pseudo_operator_config, ) -from context_lifecycle.pseudo_operator.config import PseudoOperatorConfig from context_lifecycle.pseudo_operator import engine as engine_mod +from context_lifecycle.pseudo_operator.config import PseudoOperatorConfig assert engine_mod is not None # T6: module import reference @@ -261,7 +261,7 @@ def test_global_claude_limit_cools_all_claude_backends(tmp_path: Path): def test_seed_cooldowns_hook(tmp_path: Path): - future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "seed.py" script.write_text(f"import json; print(json.dumps({{'claude': '{future}', 'codex': None}}))") eng = PseudoOperatorEngine( @@ -274,7 +274,7 @@ def test_seed_cooldowns_hook(tmp_path: Path): def test_budget_guard_hook_extends_cooldown(tmp_path: Path): - future = (datetime.now(timezone.utc) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "guard.py" script.write_text( f"import json; print(json.dumps({{'claude': '{future}', 'opus': '{future}', 'codex': None}}))" @@ -288,11 +288,11 @@ def test_budget_guard_hook_extends_cooldown(tmp_path: Path): def test_budget_guard_never_shortens_a_real_cooldown(tmp_path: Path): """A cheap budget estimate must not mask a longer real limit reset.""" - near = (datetime.now(timezone.utc) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + near = (datetime.now(UTC) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") script = tmp_path / "guard.py" script.write_text(f"import json; print(json.dumps({{'claude': '{near}'}}))") eng = PseudoOperatorEngine(_cfg(tmp_path, hooks={"budget_guard": ["python3", str(script)]})) - real_reset = datetime.now(timezone.utc) + timedelta(hours=3) + real_reset = datetime.now(UTC) + timedelta(hours=3) cooldowns = {"claude": real_reset, "opus": None, "codex": None} eng._budget_guard(cooldowns) assert cooldowns["claude"] == real_reset @@ -302,7 +302,7 @@ def test_budget_guard_null_output_leaves_cooldowns_alone(tmp_path: Path): script = tmp_path / "guard.py" script.write_text("import json; print(json.dumps({'claude': None, 'opus': None}))") eng = PseudoOperatorEngine(_cfg(tmp_path, hooks={"budget_guard": ["python3", str(script)]})) - real_reset = datetime.now(timezone.utc) + timedelta(hours=1) + real_reset = datetime.now(UTC) + timedelta(hours=1) cooldowns = {"claude": real_reset, "opus": None, "codex": None} eng._budget_guard(cooldowns) assert cooldowns["claude"] == real_reset and cooldowns["opus"] is None diff --git a/tests/test_handoff.py b/tests/test_handoff.py index 4c47110..3316103 100644 --- a/tests/test_handoff.py +++ b/tests/test_handoff.py @@ -4,19 +4,19 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from context_lifecycle.models.handoff import WorkerHandoff def test_handoff_lease_not_expired(): - future = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + future = (datetime.now(UTC) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"lease": {"expires_at": future}}) assert h.is_lease_expired() is False def test_handoff_lease_expired(): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"lease": {"expires_at": past}}) assert h.is_lease_expired() is True @@ -28,7 +28,7 @@ def test_handoff_lease_unset_means_not_expired(): def test_handoff_top_level_expires_at_compat(): """The bash hook read top-level `expires_at`; allow it via extra='allow'.""" - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") h = WorkerHandoff.model_validate({"expires_at": past}) assert h.is_lease_expired() is True diff --git a/tests/test_ids.py b/tests/test_ids.py index a0d6196..60c7f13 100644 --- a/tests/test_ids.py +++ b/tests/test_ids.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 ProtocolWarden from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -16,7 +16,7 @@ def test_generate_session_id_format(): - sid = generate_session_id(now=datetime(2026, 5, 22, tzinfo=timezone.utc)) + sid = generate_session_id(now=datetime(2026, 5, 22, tzinfo=UTC)) assert sid.startswith("s-2026-05-22-") assert is_valid_session_id(sid) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 47b9fe2..273f868 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -21,7 +21,6 @@ ) from context_lifecycle.session.paths import SessionPaths - # --------------------------------------------------------------------------- # Helpers / fixtures # --------------------------------------------------------------------------- diff --git a/tests/test_limits.py b/tests/test_limits.py index 0332a37..d0643e6 100644 --- a/tests/test_limits.py +++ b/tests/test_limits.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from context_lifecycle.pseudo_operator import limits @@ -23,14 +23,14 @@ def test_parse_relative_reset(tmp_path: Path): log.write_text("Rate limit reached. Try again in 2h 30m.") reset, text = parse_rate_limit_reset(log) assert reset is not None - assert timedelta(hours=2) < (reset - datetime.now(timezone.utc)) <= timedelta(hours=2, minutes=31) + assert timedelta(hours=2) < (reset - datetime.now(UTC)) <= timedelta(hours=2, minutes=31) def test_parse_iso_reset(tmp_path: Path): log = tmp_path / "s.log" log.write_text("usage limit — resets at 2027-01-02T03:04Z") reset, _ = parse_rate_limit_reset(log) - assert reset == datetime(2027, 1, 2, 3, 4, tzinfo=timezone.utc) + assert reset == datetime(2027, 1, 2, 3, 4, tzinfo=UTC) def test_no_limit_signal_returns_none(tmp_path: Path): diff --git a/tests/test_paths.py b/tests/test_paths.py index 68c9e0d..8a11ac3 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -2,7 +2,6 @@ # Copyright (C) 2026 ProtocolWarden from __future__ import annotations - from context_lifecycle.session.paths import SessionPaths, archived_root diff --git a/tests/test_pre_tool_use.py b/tests/test_pre_tool_use.py index 19a4153..752f837 100644 --- a/tests/test_pre_tool_use.py +++ b/tests/test_pre_tool_use.py @@ -4,8 +4,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone - +from datetime import UTC, datetime, timedelta from context_lifecycle.hooks.decisions import Decision from context_lifecycle.hooks.pre_tool_use import HookInput, evaluate_pre_tool_use @@ -48,7 +47,7 @@ def test_require_capsule_valid_allows(paths, require_capsule_config, write_yaml_ # --- lease expiry --- def test_lease_expired_blocks(paths, default_config, write_yaml_helper): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") write_yaml_helper(paths.handoffs / "h.yaml", {"lease": {"expires_at": past}}) r = evaluate_pre_tool_use(paths=paths, config=default_config, hook_input=_hook_input()) assert r.is_block @@ -62,7 +61,7 @@ def test_lease_future_allows(paths, default_config, write_yaml_helper, valid_han def test_lease_enforce_disabled(paths, write_yaml_helper): - past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + past = (datetime.now(UTC) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ") write_yaml_helper(paths.handoffs / "h.yaml", {"lease": {"expires_at": past}}) cfg = CLConfig.model_validate({"guard": {"enforce_lease": False}}) r = evaluate_pre_tool_use(paths=paths, config=cfg, hook_input=_hook_input()) diff --git a/tests/test_retention.py b/tests/test_retention.py index 59be281..fa863dd 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -232,6 +232,7 @@ def test_auto_gc_full_lifecycle_move_then_delete(tmp_path: Path): def test_maybe_auto_gc_throttles_and_logs(tmp_path: Path): from datetime import datetime, timedelta + from context_lifecycle.session.retention import maybe_auto_gc anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") @@ -250,6 +251,7 @@ def test_maybe_auto_gc_throttles_and_logs(tmp_path: Path): def test_manual_prune_skips_gc_state_dir(tmp_path: Path): from datetime import datetime + from context_lifecycle.session.retention import maybe_auto_gc anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") @@ -351,7 +353,8 @@ def test_auto_gc_corrupt_stamp_is_fail_safe(tmp_path: Path): def test_throttle_is_one_attempt_per_window_even_after_failure(tmp_path: Path, monkeypatch): """The stamp advances on attempt, not success — documented semantics.""" from datetime import datetime, timedelta - import context_lifecycle.session.retention as retention + + from context_lifecycle.session import retention anchor = _anchor(tmp_path) _mk_session(anchor, "s-2026-05-01-aaaa") now = datetime(2026, 6, 6, 12, 0, 0) diff --git a/tests/test_signing.py b/tests/test_signing.py index 55a181d..a317b89 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -21,9 +21,9 @@ from typer.testing import CliRunner from context_lifecycle.cli import loop as loop_cli +from context_lifecycle.cli.loop import sign_config_cmd, verify_config_cmd from context_lifecycle.pseudo_operator import signing from context_lifecycle.pseudo_operator.config import load_verified_config -from context_lifecycle.cli.loop import sign_config_cmd, verify_config_cmd from context_lifecycle.pseudo_operator.signing import ( VerifyResult, canonical_bytes,