Skip to content

Commit cc0d5ba

Browse files
committed
feat(cli): hypothesis registry — list / show / invalidate
Adds `vibe-trading hypothesis {list,show,invalidate}` subcommands that wrap the existing HypothesisRegistry storage. List prints a Rich table (or JSON with --json), supports --status filter and --limit. Show prints a Rich detail panel including linked run cards. Invalidate flips status to rejected and optionally records an invalidation_notes message; existing notes are preserved when --note is omitted. Closes the roadmap P2 backlog item "Add CLI workflows for listing, editing, and reviewing hypotheses" on the CLI side; Web workflow waits on the v0.2.0 IA landing. 22 new tests cover wiring, list/show/invalidate paths, JSON output, status filter, limit, missing-id errors, and note persistence.
1 parent f918c89 commit cc0d5ba

3 files changed

Lines changed: 643 additions & 0 deletions

File tree

agent/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2652,6 +2652,10 @@ def _build_parser() -> argparse.ArgumentParser:
26522652
from src.factors.cli_handlers import add_subparser as _add_alpha_subparser
26532653
_add_alpha_subparser(subparsers)
26542654

2655+
# Hypothesis Registry subcommands
2656+
from src.hypotheses.cli_handlers import add_subparser as _add_hypothesis_subparser
2657+
_add_hypothesis_subparser(subparsers)
2658+
26552659
return parser
26562660

26572661

@@ -3140,6 +3144,9 @@ def main(argv: list[str] | None = None) -> int:
31403144
if args.command == "alpha":
31413145
from src.factors.cli_handlers import dispatch as _alpha_dispatch
31423146
return _coerce_exit_code(_alpha_dispatch(args))
3147+
if args.command == "hypothesis":
3148+
from src.hypotheses.cli_handlers import dispatch as _hyp_dispatch
3149+
return _coerce_exit_code(_hyp_dispatch(args))
31433150
if args.command == "memory":
31443151
if args.memory_command == "list":
31453152
return _coerce_exit_code(cmd_memory_list(args.memory_type))
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
"""CLI handlers for ``vibe-trading hypothesis {list,show,invalidate}``.
2+
3+
All logic lives here; ``agent/cli.py`` only wires this in via :func:`add_subparser`
4+
and :func:`dispatch`. Handlers print to stdout (Rich when available, plain
5+
``print`` fallback) and return an int exit code. Errors are reported as a
6+
one-line stderr message; tracebacks are suppressed unless ``--verbose`` is set
7+
on the namespace.
8+
9+
Storage path resolution defers to :func:`default_hypotheses_path`, so callers
10+
(and tests) can override via the ``VIBE_TRADING_HYPOTHESES_PATH`` env var or by
11+
passing ``--path``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import json
18+
import sys
19+
import traceback
20+
from pathlib import Path
21+
from typing import Any, Callable
22+
23+
try:
24+
from rich.console import Console
25+
from rich.panel import Panel
26+
from rich.table import Table
27+
28+
_console: Console | None = Console()
29+
except Exception: # pragma: no cover — rich is a project dep, fallback only
30+
_console = None
31+
Table = None # type: ignore[assignment]
32+
Panel = None # type: ignore[assignment]
33+
34+
from src.hypotheses.registry import (
35+
HYPOTHESIS_STATUSES,
36+
Hypothesis,
37+
HypothesisRegistry,
38+
)
39+
40+
41+
_STATUS_STYLES = {
42+
"exploring": "cyan",
43+
"testing": "yellow",
44+
"validated": "green",
45+
"rejected": "red",
46+
"monitoring": "magenta",
47+
}
48+
49+
50+
def _print(msg: str) -> None:
51+
if _console is not None:
52+
_console.print(msg)
53+
else:
54+
print(msg)
55+
56+
57+
def _err(msg: str) -> None:
58+
print(msg, file=sys.stderr)
59+
60+
61+
def _registry(args: argparse.Namespace) -> HypothesisRegistry:
62+
override = getattr(args, "path", None)
63+
if override:
64+
return HypothesisRegistry(path=Path(override).expanduser())
65+
return HypothesisRegistry()
66+
67+
68+
def _hypothesis_payload(hyp: Hypothesis) -> dict[str, Any]:
69+
return hyp.to_dict()
70+
71+
72+
def _emit_table(rows: list[Hypothesis]) -> None:
73+
if Table is None:
74+
for hyp in rows:
75+
print(
76+
f"{hyp.hypothesis_id}\t{hyp.status}\t{hyp.title}\t{hyp.updated_at}"
77+
)
78+
return
79+
table = Table(title=f"Hypotheses ({len(rows)})", show_lines=False)
80+
table.add_column("ID", style="bold", overflow="fold")
81+
table.add_column("Status")
82+
table.add_column("Title", overflow="fold")
83+
table.add_column("Universe", overflow="fold")
84+
table.add_column("Run cards", justify="right")
85+
table.add_column("Updated", overflow="fold")
86+
for hyp in rows:
87+
status_style = _STATUS_STYLES.get(hyp.status, "white")
88+
table.add_row(
89+
hyp.hypothesis_id,
90+
f"[{status_style}]{hyp.status}[/{status_style}]",
91+
hyp.title,
92+
hyp.universe or "-",
93+
str(len(hyp.run_cards)),
94+
hyp.updated_at,
95+
)
96+
# Use a wide non-TTY console so piped/captured output keeps each row on
97+
# one line; interactive TTYs continue to honor the live terminal width
98+
# via the module-level _console.
99+
if sys.stdout.isatty() and _console is not None:
100+
_console.print(table)
101+
else:
102+
Console(width=200, force_terminal=False).print(table)
103+
104+
105+
def _emit_detail(hyp: Hypothesis) -> None:
106+
if _console is None or Panel is None:
107+
print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2))
108+
return
109+
status_style = _STATUS_STYLES.get(hyp.status, "white")
110+
body_lines = [
111+
f"[bold]ID:[/bold] {hyp.hypothesis_id}",
112+
f"[bold]Status:[/bold] [{status_style}]{hyp.status}[/{status_style}]",
113+
f"[bold]Universe:[/bold] {hyp.universe or '-'}",
114+
f"[bold]Data sources:[/bold] {', '.join(hyp.data_sources) or '-'}",
115+
f"[bold]Skills:[/bold] {', '.join(hyp.skills) or '-'}",
116+
f"[bold]Created:[/bold] {hyp.created_at}",
117+
f"[bold]Updated:[/bold] {hyp.updated_at}",
118+
"",
119+
"[bold]Thesis[/bold]",
120+
hyp.thesis or "-",
121+
]
122+
if hyp.signal_definition:
123+
body_lines.extend(["", "[bold]Signal[/bold]", hyp.signal_definition])
124+
if hyp.invalidation_notes:
125+
body_lines.extend(
126+
["", "[bold red]Invalidation notes[/bold red]", hyp.invalidation_notes]
127+
)
128+
if hyp.run_cards:
129+
body_lines.append("")
130+
body_lines.append(f"[bold]Linked run cards ({len(hyp.run_cards)})[/bold]")
131+
for idx, link in enumerate(hyp.run_cards, 1):
132+
run_card_path = link.get("run_card_path") or "-"
133+
run_dir = link.get("backtest_run_dir") or "-"
134+
note = link.get("notes") or ""
135+
linked_at = link.get("linked_at") or "-"
136+
body_lines.append(
137+
f" {idx}. run_card={run_card_path} run_dir={run_dir} linked={linked_at}"
138+
)
139+
if note:
140+
body_lines.append(f" note: {note}")
141+
_console.print(Panel("\n".join(body_lines), title=hyp.title, expand=False))
142+
143+
144+
def _cmd_list(args: argparse.Namespace) -> int:
145+
registry = _registry(args)
146+
status_filter: str | None = getattr(args, "status", None)
147+
limit: int = max(0, int(getattr(args, "limit", 50) or 0))
148+
rows = registry.list()
149+
if status_filter:
150+
rows = [hyp for hyp in rows if hyp.status == status_filter]
151+
rows.sort(key=lambda h: h.updated_at, reverse=True)
152+
if limit:
153+
rows = rows[:limit]
154+
155+
if getattr(args, "json", False):
156+
print(
157+
json.dumps(
158+
[_hypothesis_payload(hyp) for hyp in rows],
159+
ensure_ascii=False,
160+
indent=2,
161+
)
162+
)
163+
return 0
164+
165+
if not rows:
166+
suffix = f" status={status_filter}" if status_filter else ""
167+
_print(f"No hypotheses found{suffix}.")
168+
return 0
169+
_emit_table(rows)
170+
return 0
171+
172+
173+
def _cmd_show(args: argparse.Namespace) -> int:
174+
registry = _registry(args)
175+
hypothesis_id = args.hypothesis_id
176+
for hyp in registry.list():
177+
if hyp.hypothesis_id == hypothesis_id:
178+
if getattr(args, "json", False):
179+
print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2))
180+
else:
181+
_emit_detail(hyp)
182+
return 0
183+
_err(f"hypothesis not found: {hypothesis_id}")
184+
return 1
185+
186+
187+
def _cmd_invalidate(args: argparse.Namespace) -> int:
188+
registry = _registry(args)
189+
note = (getattr(args, "note", "") or "").strip()
190+
try:
191+
hyp = registry.update(
192+
args.hypothesis_id,
193+
status="rejected",
194+
invalidation_notes=note if note else None,
195+
)
196+
except KeyError:
197+
_err(f"hypothesis not found: {args.hypothesis_id}")
198+
return 1
199+
except ValueError as exc:
200+
_err(f"invalid update: {exc}")
201+
return 2
202+
203+
if getattr(args, "json", False):
204+
print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2))
205+
else:
206+
_print(
207+
f"[red]rejected[/red] {hyp.hypothesis_id}{hyp.title}"
208+
+ (f"\n note: {note}" if note else "")
209+
)
210+
return 0
211+
212+
213+
_DISPATCH: dict[str, Callable[[argparse.Namespace], int]] = {
214+
"list": _cmd_list,
215+
"show": _cmd_show,
216+
"invalidate": _cmd_invalidate,
217+
}
218+
219+
220+
_HYP_PARSER: argparse.ArgumentParser | None = None
221+
222+
223+
def add_subparser(subparsers: Any) -> argparse.ArgumentParser:
224+
"""Register ``hypothesis`` and its three sub-sub-commands on the parent
225+
subparsers.
226+
227+
Args:
228+
subparsers: The object returned by ``ArgumentParser.add_subparsers(...)``.
229+
230+
Returns:
231+
The ``hypothesis`` parser (mostly for test introspection).
232+
"""
233+
global _HYP_PARSER
234+
235+
hyp_parser = subparsers.add_parser(
236+
"hypothesis",
237+
help="Hypothesis Registry: list / show / invalidate",
238+
)
239+
hyp_parser.add_argument(
240+
"--verbose", action="store_true", help="Show full traceback on errors"
241+
)
242+
hyp_parser.add_argument(
243+
"--path",
244+
default=None,
245+
help=(
246+
"Override registry JSON path (also respects "
247+
"VIBE_TRADING_HYPOTHESES_PATH env var)"
248+
),
249+
)
250+
hyp_sub = hyp_parser.add_subparsers(dest="hypothesis_command")
251+
252+
p_list = hyp_sub.add_parser("list", help="List hypotheses")
253+
p_list.add_argument(
254+
"--status",
255+
choices=HYPOTHESIS_STATUSES,
256+
default=None,
257+
help="Filter by lifecycle status",
258+
)
259+
p_list.add_argument(
260+
"--limit",
261+
type=int,
262+
default=50,
263+
help="Maximum rows to print (default: 50; pass 0 for no cap)",
264+
)
265+
p_list.add_argument(
266+
"--json",
267+
action="store_true",
268+
help="Emit JSON array instead of a table",
269+
)
270+
271+
p_show = hyp_sub.add_parser("show", help="Show hypothesis detail")
272+
p_show.add_argument("hypothesis_id", help="Hypothesis id, e.g. hyp_abcd1234ef56")
273+
p_show.add_argument(
274+
"--json",
275+
action="store_true",
276+
help="Emit JSON object instead of a panel",
277+
)
278+
279+
p_invalidate = hyp_sub.add_parser(
280+
"invalidate", help="Mark a hypothesis as rejected with optional notes"
281+
)
282+
p_invalidate.add_argument(
283+
"hypothesis_id", help="Hypothesis id to invalidate"
284+
)
285+
p_invalidate.add_argument(
286+
"--note",
287+
default="",
288+
help="Invalidation note recorded on the hypothesis",
289+
)
290+
p_invalidate.add_argument(
291+
"--json",
292+
action="store_true",
293+
help="Emit JSON object of the updated hypothesis",
294+
)
295+
296+
_HYP_PARSER = hyp_parser
297+
return hyp_parser
298+
299+
300+
def dispatch(args: argparse.Namespace) -> int:
301+
"""Dispatch ``hypothesis <sub>`` to the matching handler.
302+
303+
Returns the exit code; ``cli.py`` propagates it via ``_coerce_exit_code``.
304+
"""
305+
sub = getattr(args, "hypothesis_command", None)
306+
if sub is None:
307+
if _HYP_PARSER is not None:
308+
_HYP_PARSER.print_help()
309+
else:
310+
_err("hypothesis requires a subcommand. Try: vibe-trading hypothesis list")
311+
return 1
312+
handler = _DISPATCH.get(sub)
313+
if handler is None:
314+
_err(f"unknown hypothesis subcommand: {sub}")
315+
return 1
316+
try:
317+
return int(handler(args))
318+
except Exception as exc: # noqa: BLE001 — surface as one-line stderr
319+
if getattr(args, "verbose", False):
320+
traceback.print_exc()
321+
else:
322+
_err(f"hypothesis {sub} failed: {exc}")
323+
return 1

0 commit comments

Comments
 (0)