codevigil 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevigil/__init__.py +19 -0
- codevigil/__main__.py +10 -0
- codevigil/aggregator.py +506 -0
- codevigil/bootstrap.py +284 -0
- codevigil/cli.py +732 -0
- codevigil/collectors/__init__.py +24 -0
- codevigil/collectors/_text_match.py +271 -0
- codevigil/collectors/parse_health.py +94 -0
- codevigil/collectors/read_edit_ratio.py +258 -0
- codevigil/collectors/reasoning_loop.py +167 -0
- codevigil/collectors/stop_phrase.py +266 -0
- codevigil/config.py +776 -0
- codevigil/errors.py +211 -0
- codevigil/parser.py +673 -0
- codevigil/privacy.py +191 -0
- codevigil/projects.py +132 -0
- codevigil/registry.py +121 -0
- codevigil/renderers/__init__.py +20 -0
- codevigil/renderers/json_file.py +105 -0
- codevigil/renderers/terminal.py +236 -0
- codevigil/types.py +189 -0
- codevigil/watcher.py +456 -0
- codevigil-0.1.0.dist-info/METADATA +351 -0
- codevigil-0.1.0.dist-info/RECORD +27 -0
- codevigil-0.1.0.dist-info/WHEEL +4 -0
- codevigil-0.1.0.dist-info/entry_points.txt +2 -0
- codevigil-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Terminal renderer — ANSI-coloured full-redraw watch-mode output.
|
|
2
|
+
|
|
3
|
+
The ``Renderer`` protocol (``codevigil.types.Renderer``) exposes only
|
|
4
|
+
``render``, ``render_error``, and ``close``. For the terminal output shape
|
|
5
|
+
documented in ``docs/design.md`` §CLI Modes → ``codevigil watch`` we need to
|
|
6
|
+
know when a 1 Hz aggregator tick starts and ends: the aggregator iterates
|
|
7
|
+
``(meta, snapshots)`` pairs and calls ``render()`` once per session, but the
|
|
8
|
+
screen should be cleared exactly once per tick, not once per session.
|
|
9
|
+
|
|
10
|
+
This module extends the frozen protocol with two optional methods,
|
|
11
|
+
``begin_tick()`` and ``end_tick()``, that the CLI layer (next phase) will
|
|
12
|
+
call around each tick's render loop. ``render()`` buffers the per-session
|
|
13
|
+
block internally; ``end_tick()`` flushes all buffered blocks to the output
|
|
14
|
+
stream in a single write, optionally preceded by an ANSI clear-screen
|
|
15
|
+
sequence when writing to a TTY with colour enabled.
|
|
16
|
+
|
|
17
|
+
Coloring uses raw ANSI escapes — no ``rich`` dependency — and is fully
|
|
18
|
+
stripped when ``use_color=False`` so tests can capture plain text by
|
|
19
|
+
routing through ``io.StringIO``. The clear-screen sequence is only emitted
|
|
20
|
+
when the stream is an actual TTY (``stream.isatty()``) *and* ``use_color``
|
|
21
|
+
is true; tests therefore receive clean append output even with colour on.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import TextIO
|
|
30
|
+
|
|
31
|
+
from codevigil.errors import CodevigilError, ErrorLevel
|
|
32
|
+
from codevigil.types import MetricSnapshot, SessionMeta, SessionState, Severity
|
|
33
|
+
|
|
34
|
+
_OK_COLOR: str = "\x1b[32m"
|
|
35
|
+
_WARN_COLOR: str = "\x1b[33m"
|
|
36
|
+
_CRITICAL_COLOR: str = "\x1b[31m"
|
|
37
|
+
_RESET: str = "\x1b[0m"
|
|
38
|
+
_DIM: str = "\x1b[2m"
|
|
39
|
+
_BOLD: str = "\x1b[1m"
|
|
40
|
+
_CLEAR_SCREEN: str = "\x1b[2J\x1b[H"
|
|
41
|
+
|
|
42
|
+
_SEVERITY_WORD: dict[Severity, str] = {
|
|
43
|
+
Severity.OK: "OK",
|
|
44
|
+
Severity.WARN: "WARN",
|
|
45
|
+
Severity.CRITICAL: "CRIT",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_SEVERITY_COLOR: dict[Severity, str] = {
|
|
49
|
+
Severity.OK: _OK_COLOR,
|
|
50
|
+
Severity.WARN: _WARN_COLOR,
|
|
51
|
+
Severity.CRITICAL: _CRITICAL_COLOR,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_STATE_WORD: dict[SessionState, str] = {
|
|
55
|
+
SessionState.ACTIVE: "ACTIVE",
|
|
56
|
+
SessionState.STALE: "STALE",
|
|
57
|
+
SessionState.EVICTED: "EVICTED",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_STATE_COLOR: dict[SessionState, str] = {
|
|
61
|
+
SessionState.ACTIVE: _OK_COLOR,
|
|
62
|
+
SessionState.STALE: _DIM,
|
|
63
|
+
SessionState.EVICTED: _CRITICAL_COLOR,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_PARSE_HEALTH_METRIC: str = "parse_health"
|
|
67
|
+
_RULE: str = "─" * 70
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class _SessionBlock:
|
|
72
|
+
"""Buffered render output for one session in the current tick."""
|
|
73
|
+
|
|
74
|
+
banner_lines: list[str] = field(default_factory=list) # CRITICAL → above header
|
|
75
|
+
body_lines: list[str] = field(default_factory=list)
|
|
76
|
+
footer_lines: list[str] = field(default_factory=list) # WARN/ERROR → below body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TerminalRenderer:
|
|
80
|
+
"""ANSI full-redraw renderer for ``codevigil watch``."""
|
|
81
|
+
|
|
82
|
+
name: str = "terminal"
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
stream: TextIO | None = None,
|
|
88
|
+
show_experimental_badge: bool = True,
|
|
89
|
+
use_color: bool = True,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._stream: TextIO = stream if stream is not None else sys.stdout
|
|
92
|
+
self._show_experimental_badge: bool = show_experimental_badge
|
|
93
|
+
self._use_color: bool = use_color
|
|
94
|
+
self._blocks: dict[str, _SessionBlock] = {}
|
|
95
|
+
self._order: list[str] = []
|
|
96
|
+
self._parse_confidence: float = 1.0
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------- tick lifecycle
|
|
99
|
+
|
|
100
|
+
def begin_tick(self) -> None:
|
|
101
|
+
"""Start a new tick — drop any previously buffered blocks."""
|
|
102
|
+
|
|
103
|
+
self._blocks = {}
|
|
104
|
+
self._order = []
|
|
105
|
+
|
|
106
|
+
def end_tick(self) -> None:
|
|
107
|
+
"""Flush buffered blocks to the output stream in a single write."""
|
|
108
|
+
|
|
109
|
+
parts: list[str] = []
|
|
110
|
+
if self._use_color and self._stream_is_tty():
|
|
111
|
+
parts.append(_CLEAR_SCREEN)
|
|
112
|
+
parts.append(self._header_line())
|
|
113
|
+
parts.append("\n")
|
|
114
|
+
for session_id in self._order:
|
|
115
|
+
block = self._blocks[session_id]
|
|
116
|
+
for line in block.banner_lines:
|
|
117
|
+
parts.append(line)
|
|
118
|
+
parts.append("\n")
|
|
119
|
+
for line in block.body_lines:
|
|
120
|
+
parts.append(line)
|
|
121
|
+
parts.append("\n")
|
|
122
|
+
for line in block.footer_lines:
|
|
123
|
+
parts.append(line)
|
|
124
|
+
parts.append("\n")
|
|
125
|
+
self._stream.write("".join(parts))
|
|
126
|
+
self._stream.flush()
|
|
127
|
+
self._blocks = {}
|
|
128
|
+
self._order = []
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------- render
|
|
131
|
+
|
|
132
|
+
def render(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> None:
|
|
133
|
+
"""Buffer one session's block for the current tick."""
|
|
134
|
+
|
|
135
|
+
block = self._blocks.get(meta.session_id)
|
|
136
|
+
if block is None:
|
|
137
|
+
block = _SessionBlock()
|
|
138
|
+
self._blocks[meta.session_id] = block
|
|
139
|
+
self._order.append(meta.session_id)
|
|
140
|
+
block.body_lines.extend(self._session_body(snapshots, meta))
|
|
141
|
+
|
|
142
|
+
def render_error(self, err: CodevigilError, meta: SessionMeta | None) -> None:
|
|
143
|
+
"""Route errors by level per design.md §Error Taxonomy → Levels and Routes.
|
|
144
|
+
|
|
145
|
+
INFO → silent (log file only). WARN → dim footer under the session
|
|
146
|
+
block. ERROR → bright (non-dim) footer under the session block.
|
|
147
|
+
CRITICAL → red banner above the session header.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
if err.level is ErrorLevel.INFO:
|
|
151
|
+
return
|
|
152
|
+
session_id = meta.session_id if meta is not None else ""
|
|
153
|
+
block = self._blocks.get(session_id)
|
|
154
|
+
if block is None:
|
|
155
|
+
block = _SessionBlock()
|
|
156
|
+
self._blocks[session_id] = block
|
|
157
|
+
self._order.append(session_id)
|
|
158
|
+
text = f"{err.code}: {err.message}"
|
|
159
|
+
if err.level is ErrorLevel.WARN:
|
|
160
|
+
block.footer_lines.append(self._paint(f" ! {text}", _DIM))
|
|
161
|
+
elif err.level is ErrorLevel.ERROR:
|
|
162
|
+
block.footer_lines.append(self._paint(f" !! {text}", _WARN_COLOR + _BOLD))
|
|
163
|
+
elif err.level is ErrorLevel.CRITICAL:
|
|
164
|
+
block.banner_lines.append(self._paint(f"!!! CRITICAL {text}", _CRITICAL_COLOR + _BOLD))
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
"""Flush the output stream. No persistent handles to release."""
|
|
168
|
+
|
|
169
|
+
with contextlib.suppress(ValueError):
|
|
170
|
+
# Stream already closed — nothing to flush.
|
|
171
|
+
self._stream.flush()
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------- helpers
|
|
174
|
+
|
|
175
|
+
def _stream_is_tty(self) -> bool:
|
|
176
|
+
isatty = getattr(self._stream, "isatty", None)
|
|
177
|
+
if not callable(isatty):
|
|
178
|
+
return False
|
|
179
|
+
try:
|
|
180
|
+
return bool(isatty())
|
|
181
|
+
except ValueError:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def _paint(self, text: str, color: str) -> str:
|
|
185
|
+
if not self._use_color:
|
|
186
|
+
return text
|
|
187
|
+
return f"{color}{text}{_RESET}"
|
|
188
|
+
|
|
189
|
+
def _header_line(self) -> str:
|
|
190
|
+
parts: list[str] = [self._paint("codevigil", _BOLD)]
|
|
191
|
+
if self._show_experimental_badge:
|
|
192
|
+
parts.append(self._paint("[experimental thresholds]", _DIM + _WARN_COLOR))
|
|
193
|
+
parts.append(f"| parse_confidence: {self._parse_confidence:.2f}")
|
|
194
|
+
return " ".join(parts)
|
|
195
|
+
|
|
196
|
+
def _session_body(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> list[str]:
|
|
197
|
+
lines: list[str] = []
|
|
198
|
+
# Pick parse_confidence for the header from either meta or a
|
|
199
|
+
# parse_health snapshot if present.
|
|
200
|
+
pc = meta.parse_confidence
|
|
201
|
+
for snap in snapshots:
|
|
202
|
+
if snap.name == _PARSE_HEALTH_METRIC:
|
|
203
|
+
pc = snap.value
|
|
204
|
+
break
|
|
205
|
+
self._parse_confidence = pc
|
|
206
|
+
lines.append(self._session_line(meta))
|
|
207
|
+
lines.append(self._paint(_RULE, _DIM))
|
|
208
|
+
for snap in snapshots:
|
|
209
|
+
lines.append(self._metric_line(snap))
|
|
210
|
+
lines.append(self._paint(_RULE, _DIM))
|
|
211
|
+
return lines
|
|
212
|
+
|
|
213
|
+
def _session_line(self, meta: SessionMeta) -> str:
|
|
214
|
+
sid = meta.session_id[:8]
|
|
215
|
+
project = meta.project_name or meta.project_hash[:8]
|
|
216
|
+
duration = _format_duration((meta.last_event_time - meta.start_time).total_seconds())
|
|
217
|
+
state_word = _STATE_WORD[meta.state]
|
|
218
|
+
state_colored = self._paint(state_word, _STATE_COLOR[meta.state])
|
|
219
|
+
return f"session: {sid} | project: {project} | {duration} {state_colored}"
|
|
220
|
+
|
|
221
|
+
def _metric_line(self, snap: MetricSnapshot) -> str:
|
|
222
|
+
name = f"{snap.name:<18}"
|
|
223
|
+
value = f"{snap.value:>6.1f}"
|
|
224
|
+
sev_word = _SEVERITY_WORD[snap.severity]
|
|
225
|
+
sev_colored = self._paint(sev_word, _SEVERITY_COLOR[snap.severity])
|
|
226
|
+
label = f"[{snap.label}]" if snap.label else ""
|
|
227
|
+
return f" {name} {value} {sev_colored} {label}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _format_duration(seconds: float) -> str:
|
|
231
|
+
total = int(max(0.0, seconds))
|
|
232
|
+
minutes, secs = divmod(total, 60)
|
|
233
|
+
return f"{minutes}m {secs:02d}s"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = ["TerminalRenderer"]
|
codevigil/types.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Core vocabulary: Event, MetricSnapshot, SessionMeta, Collector/Renderer protocols.
|
|
2
|
+
|
|
3
|
+
Everything in this module is a *frozen contract*. Once Phase 1 lands on main
|
|
4
|
+
these names, shapes, and semantics may not change without an explicit
|
|
5
|
+
breaking-change PR, because every downstream subsystem (parser, watcher,
|
|
6
|
+
aggregator, collectors, renderers, CLI) imports from here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from codevigil.errors import CodevigilError, ErrorLevel, ErrorSource, record
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventKind(Enum):
|
|
21
|
+
TOOL_CALL = "tool_call"
|
|
22
|
+
TOOL_RESULT = "tool_result"
|
|
23
|
+
ASSISTANT_MESSAGE = "assistant"
|
|
24
|
+
USER_MESSAGE = "user"
|
|
25
|
+
THINKING = "thinking"
|
|
26
|
+
SYSTEM = "system"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Event:
|
|
31
|
+
"""One typed record emitted by the parser.
|
|
32
|
+
|
|
33
|
+
``payload`` is deliberately unstructured at the type level — the
|
|
34
|
+
per-``EventKind`` schemas live in ``docs/design.md`` §Payload Schemas by
|
|
35
|
+
EventKind and are enforced by ``safe_get`` at read time, not by a
|
|
36
|
+
dataclass tree. This keeps the kind space open for additive growth.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
timestamp: datetime
|
|
40
|
+
session_id: str
|
|
41
|
+
kind: EventKind
|
|
42
|
+
payload: dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Severity(Enum):
|
|
46
|
+
OK = "ok"
|
|
47
|
+
WARN = "warn"
|
|
48
|
+
CRITICAL = "critical"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class MetricSnapshot:
|
|
53
|
+
"""Single metric reading produced by a collector on each ``snapshot()``.
|
|
54
|
+
|
|
55
|
+
``value`` is always a float so every metric has exactly one scalar the
|
|
56
|
+
renderer can threshold, trend, and compare. Structured breakdowns go in
|
|
57
|
+
``detail`` so the scalar contract stays simple.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
value: float
|
|
62
|
+
label: str
|
|
63
|
+
severity: Severity = Severity.OK
|
|
64
|
+
detail: dict[str, Any] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SessionState(Enum):
|
|
68
|
+
ACTIVE = "active"
|
|
69
|
+
STALE = "stale"
|
|
70
|
+
EVICTED = "evicted"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class SessionMeta:
|
|
75
|
+
"""Aggregator-owned view of a session handed to renderers on every tick."""
|
|
76
|
+
|
|
77
|
+
session_id: str
|
|
78
|
+
project_hash: str
|
|
79
|
+
project_name: str | None
|
|
80
|
+
file_path: Path
|
|
81
|
+
start_time: datetime
|
|
82
|
+
last_event_time: datetime
|
|
83
|
+
event_count: int
|
|
84
|
+
parse_confidence: float
|
|
85
|
+
state: SessionState
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@runtime_checkable
|
|
89
|
+
class Collector(Protocol):
|
|
90
|
+
"""Contract every metric collector must honor.
|
|
91
|
+
|
|
92
|
+
The ``complexity`` attribute is a human-readable big-O string documented
|
|
93
|
+
per §Complexity Honesty in the design. Snapshots are pure functions of
|
|
94
|
+
collector state and are idempotent.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
complexity: str
|
|
99
|
+
|
|
100
|
+
def ingest(self, event: Event) -> None: ...
|
|
101
|
+
|
|
102
|
+
def snapshot(self) -> MetricSnapshot: ...
|
|
103
|
+
|
|
104
|
+
def reset(self) -> None: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@runtime_checkable
|
|
108
|
+
class Renderer(Protocol):
|
|
109
|
+
"""Contract every renderer must honor."""
|
|
110
|
+
|
|
111
|
+
name: str
|
|
112
|
+
|
|
113
|
+
def render(self, snapshots: list[MetricSnapshot], meta: SessionMeta) -> None: ...
|
|
114
|
+
|
|
115
|
+
def render_error(self, err: CodevigilError, meta: SessionMeta | None) -> None: ...
|
|
116
|
+
|
|
117
|
+
def close(self) -> None: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_MISSING = object()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def safe_get(
|
|
124
|
+
payload: dict[str, Any],
|
|
125
|
+
key: str,
|
|
126
|
+
default: Any,
|
|
127
|
+
expected: type | None = None,
|
|
128
|
+
*,
|
|
129
|
+
required: bool = False,
|
|
130
|
+
source: ErrorSource = ErrorSource.PARSER,
|
|
131
|
+
event_kind: str | None = None,
|
|
132
|
+
) -> Any:
|
|
133
|
+
"""Typed payload lookup that routes drift through the error channel.
|
|
134
|
+
|
|
135
|
+
Returns ``payload[key]`` when present and (optionally) type-matching the
|
|
136
|
+
``expected`` type. Emits a WARN ``CodevigilError`` to the process-wide
|
|
137
|
+
error channel on missing-but-required or type-mismatch cases, so every
|
|
138
|
+
silent ``KeyError`` becomes a counted, observable drift signal the
|
|
139
|
+
parser's ``parse_confidence`` meter can pick up.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
value: Any = payload.get(key, _MISSING)
|
|
143
|
+
if value is _MISSING:
|
|
144
|
+
if required:
|
|
145
|
+
record(
|
|
146
|
+
CodevigilError(
|
|
147
|
+
level=ErrorLevel.WARN,
|
|
148
|
+
source=source,
|
|
149
|
+
code="safe_get.missing_required",
|
|
150
|
+
message=f"required key {key!r} missing from payload",
|
|
151
|
+
context={"key": key, "event_kind": event_kind},
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return default
|
|
155
|
+
if expected is not None and not isinstance(value, expected):
|
|
156
|
+
record(
|
|
157
|
+
CodevigilError(
|
|
158
|
+
level=ErrorLevel.WARN,
|
|
159
|
+
source=source,
|
|
160
|
+
code="safe_get.type_mismatch",
|
|
161
|
+
message=(
|
|
162
|
+
f"key {key!r} has type {type(value).__name__}, expected {expected.__name__}"
|
|
163
|
+
),
|
|
164
|
+
context={
|
|
165
|
+
"key": key,
|
|
166
|
+
"expected": expected.__name__,
|
|
167
|
+
"actual": type(value).__name__,
|
|
168
|
+
"event_kind": event_kind,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return default
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Re-export field so downstream phases that need default_factory can pull it
|
|
177
|
+
# from types without introducing a second dataclasses import.
|
|
178
|
+
__all__ = [
|
|
179
|
+
"Collector",
|
|
180
|
+
"Event",
|
|
181
|
+
"EventKind",
|
|
182
|
+
"MetricSnapshot",
|
|
183
|
+
"Renderer",
|
|
184
|
+
"SessionMeta",
|
|
185
|
+
"SessionState",
|
|
186
|
+
"Severity",
|
|
187
|
+
"field",
|
|
188
|
+
"safe_get",
|
|
189
|
+
]
|