generated from coulomb/repo-seed
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:
9
src/cya/context/__init__.py
Normal file
9
src/cya/context/__init__.py
Normal 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.
|
||||
"""
|
||||
309
src/cya/context/collector.py
Normal file
309
src/cya/context/collector.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user