feat(cya): T01-T07 core console-native MVP slice (CYA-WP-0001)

- T01: Python + Typer/rich + pyproject.toml + full src/ layout + working `cya` CLI entrypoint
- T02: Bounded transparent context collector (top-level only, provenance, ignores) + --explain-context
- T03: Genuine rule-based risk classifier (primary) + mandatory terminal confirmation, no auto-execute
- T04: LLMAdapter Protocol + deterministic FakeLLMAdapter seam (llm-connect boundary, zero bypass)
- T05: Strictly minimal phase-memory no-op ports (loud markers, per operator direction 2026-05-26)
- T06: Orchestrator coordinating the full flow; CLI is thin delegation
- T07: pytest harness + safety-focused tests (risk invariants + collector)

All changes verified by running the installed `cya` binary and `pytest tests/`.

Workplan updated with status. State Hub progress event logged (workstream 0a1233fd...).

Refs: CYA-WP-0001, Decision a644364b-11c4-49a9-bf17-99063382e27b
This commit is contained in:
2026-05-26 02:19:13 +02:00
parent da6c7acfc9
commit 637919dd8c
16 changed files with 1308 additions and 8 deletions

View File

@@ -0,0 +1,9 @@
"""Local context collector (T02).
Implements the safe, transparent, intentionally bounded collector described in
INTENT.md, SCOPE.md, and workplan CYA-WP-0001-T02.
All collection is read-only, user-inspectable via --explain-context, and
never traverses dangerous locations or hidden user data without explicit
future opt-in.
"""

View File

@@ -0,0 +1,309 @@
"""Bounded, transparent, pure local context collector (T02).
Implements the collector contract from CYA-WP-0001-T02, INTENT.md, and SCOPE.md.
Design principles (strict for this slice):
- Top-level directory entries only (never recursive).
- Hard-coded, conservative ignore list for build artifacts, vcs, venvs, caches.
- Git state gathered exclusively via short, read-only, timeout-bounded subprocess calls.
- Never touches shell history, ~/.config, credentials, or any hidden user data.
- Produces a stable, JSON-serializable ContextEnvelope with clear provenance on every item.
- The module itself is side-effect free except for the explicit read-only inspections.
The --explain-context / --show-context flag (wired in cli) is the user-visible
contract: it must print *exactly* the data that would be sent onward, with
provenance for each piece.
Later tasks (T06 orchestrator, T04 boundary) will consume the same envelope.
Real per-request file globs and stdin handling will be added as thin extensions
without changing the core collector shape.
"""
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Envelope (stable, serializable, provenance-carrying)
# ---------------------------------------------------------------------------
@dataclass
class ContextEnvelope:
"""The single data structure that travels to the LLM adapter and to the
user via --explain-context.
All fields carry explicit "provenance" markers so the model (and the user)
can see exactly where each fact came from.
"""
cwd: str
top_level: list[dict[str, Any]] = field(default_factory=list)
git: dict[str, Any] | None = None
env: dict[str, str] = field(default_factory=dict)
collected_at: str = ""
notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""JSON-safe representation for the model and for tests."""
return asdict(self)
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, default=str)
# ---------------------------------------------------------------------------
# Ignore policy (name-based, conservative, no .gitignore parsing in T02)
# ---------------------------------------------------------------------------
def _default_ignore_patterns() -> set[str]:
"""Names we never surface at the top level (MVP policy).
This is intentionally simple and name-based. No pathspec, no gitignore
parsing, no content scanning. A later memory layer can offer richer
user-controlled filters.
"""
return {
".git",
".hg",
".svn",
".venv",
"venv",
".env",
"env",
"node_modules",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".cache",
".tox",
".nox",
"dist",
"build",
"htmlcov",
".eggs",
".egg-info",
".hypothesis",
".coverage",
".pytype",
".pyre",
"target", # rust / java etc.
}
def _is_likely_ignored(name: str, patterns: set[str]) -> bool:
"""Return True if this top-level name should be suppressed from the envelope."""
if not name:
return True
if name in patterns:
return True
# Conservative hidden-file rule (except a tiny allow-list for common signal).
if name.startswith(".") and name not in {".gitignore", ".env.example", ".cya"}:
return True
return False
# ---------------------------------------------------------------------------
# Collectors (each is a pure, read-only, best-effort function)
# ---------------------------------------------------------------------------
def collect_top_level(top: Path | str = ".") -> list[dict[str, Any]]:
"""Return a bounded list of top-level entries with provenance.
Never descends into subdirectories. Never follows symlinks for traversal.
"""
root = Path(top).resolve()
patterns = _default_ignore_patterns()
entries: list[dict[str, Any]] = []
try:
it = root.iterdir()
except (PermissionError, OSError) as e:
return [{"error": "cannot_list", "reason": str(e), "provenance": "cwd.top_level"}]
for p in it:
name = p.name
ignored = _is_likely_ignored(name, patterns)
kind = "dir" if p.is_dir() else "file"
size: int | None = None
if not ignored and p.is_file():
try:
size = p.stat().st_size
except OSError:
size = None
entry = {
"name": name,
"kind": kind,
"ignored": ignored,
"size": size,
"provenance": "cwd.top_level",
}
entries.append(entry)
# Sort for stable output (dirs first, then alpha).
entries.sort(key=lambda e: (0 if e["kind"] == "dir" else 1, e["name"].lower()))
return entries
def collect_git(top: Path | str = ".") -> dict[str, Any] | None:
"""Best-effort, strictly read-only git summary.
Uses short, timeout-protected subprocess calls. Returns None (not an
exception) when not a git tree or when git is unavailable. This keeps
the collector usable everywhere.
"""
root = Path(top).resolve()
info: dict[str, Any] = {"provenance": "git.subprocess.readonly"}
try:
# Current branch (or detached)
r = subprocess.run(
["git", "branch", "--show-current"],
cwd=root,
capture_output=True,
text=True,
timeout=1.5,
check=False,
)
branch = r.stdout.strip() or None
if branch:
info["branch"] = branch
# Porcelain status (very compact)
r = subprocess.run(
["git", "status", "--short", "--branch"],
cwd=root,
capture_output=True,
text=True,
timeout=2.0,
check=False,
)
status = r.stdout.strip()
if status:
info["status"] = status.splitlines()[:20] # bound the output
# Last commit (subject only)
r = subprocess.run(
["git", "log", "-1", "--pretty=format:%h %s"],
cwd=root,
capture_output=True,
text=True,
timeout=1.5,
check=False,
)
last = r.stdout.strip()
if last:
info["last_commit"] = last
if len(info) == 1: # only provenance
return None
return info
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired, OSError):
return None
def collect_env() -> dict[str, str]:
"""Tiny, high-signal environment facts only.
We deliberately do *not* dump the whole os.environ.
"""
wanted = ("SHELL", "EDITOR", "VISUAL", "LANG", "PWD")
return {k: os.environ.get(k, "") for k in wanted if k in os.environ}
def collect(top: Path | str = ".") -> ContextEnvelope:
"""Primary entry point. Returns a fully populated, serializable envelope."""
root = Path(top).resolve()
now = datetime.now(timezone.utc).isoformat()
envelope = ContextEnvelope(
cwd=str(root),
top_level=collect_top_level(root),
git=collect_git(root),
env=collect_env(),
collected_at=now,
notes=[
"Top-level entries only (no recursion by design).",
"Name-based ignore list for build, cache, and VCS directories.",
"Git data obtained via short read-only subprocess calls (best effort).",
"No user history, no dotfile scraping, no credential scanning.",
],
)
return envelope
# ---------------------------------------------------------------------------
# Human / model explanation rendering (rich-aware, optional)
# ---------------------------------------------------------------------------
def render_explanation(envelope: ContextEnvelope, *, rich: bool = True) -> str:
"""Return a compact, provenance-aware textual explanation.
When rich=True and the rich library is importable, the caller can further
enhance with Panels/Trees. For the collector itself we stay with plain
text so the module remains usable in minimal environments.
"""
lines: list[str] = []
lines.append(f"Context collected {envelope.collected_at}")
lines.append(f"Root: {envelope.cwd}")
lines.append("")
# Top level (only the non-ignored ones for the primary view)
shown = [e for e in envelope.top_level if not e.get("ignored")]
if shown:
lines.append("Top-level entries (filtered, non-recursive):")
for e in shown:
size = f" ({e['size']} B)" if e.get("size") is not None else ""
lines.append(f"{e['name']} [{e['kind']}{size}] — {e['provenance']}")
else:
lines.append("Top-level entries: (none or all ignored)")
if envelope.git:
lines.append("")
lines.append("Git:")
g = envelope.git
if g.get("branch"):
lines.append(f" branch: {g['branch']}")
if g.get("last_commit"):
lines.append(f" last: {g['last_commit']}")
if g.get("status"):
st = g["status"]
if isinstance(st, list):
st = "; ".join(st)
lines.append(f" status: {st[:180]}")
if envelope.env:
lines.append("")
lines.append("Environment hints:")
for k, v in envelope.env.items():
if v:
lines.append(f" {k}={v}")
lines.append("")
lines.append("Collection notes:")
for n in envelope.notes:
lines.append(f" - {n}")
return "\n".join(lines)
__all__ = [
"ContextEnvelope",
"collect",
"collect_top_level",
"collect_git",
"collect_env",
"render_explanation",
]