Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .

Expand Down
3 changes: 2 additions & 1 deletion adapters/claude/hooks/tests/validate_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 9 additions & 9 deletions src/context_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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",
]
5 changes: 4 additions & 1 deletion src/context_lifecycle/cli/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/context_lifecycle/cli/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion src/context_lifecycle/cli/reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
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,
apply_plan,
build_plan,
format_plan,
)
from context_lifecycle.reconcile.privacy import PrivateArchiveUnavailable

app = typer.Typer(no_args_is_help=True, add_completion=False)

Expand Down
9 changes: 6 additions & 3 deletions src/context_lifecycle/cli/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import os
import shutil
from pathlib import Path
from typing import Optional

import typer

Expand All @@ -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,
)
Expand All @@ -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.",
),
Expand Down Expand Up @@ -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}).",
),
Expand Down
8 changes: 4 additions & 4 deletions src/context_lifecycle/context_engine/attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/context_lifecycle/context_engine/attribution_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/context_lifecycle/context_engine/cold.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
5 changes: 2 additions & 3 deletions src/context_lifecycle/context_engine/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/context_lifecycle/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
4 changes: 2 additions & 2 deletions src/context_lifecycle/hooks/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/context_lifecycle/hooks/pre_tool_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {},
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/context_lifecycle/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion src/context_lifecycle/ledger/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/context_lifecycle/ledger/observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading