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
39 changes: 36 additions & 3 deletions .ai/cli/agent
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ Usage:

Examples:
bash .ai/cli/agent clarification_helper draft \
--session-path .ai/sessions/<sid> "task description"
bash .ai/cli/agent plan_helper draft --session-path .ai/sessions/<sid>
--session-path active "task description"
bash .ai/cli/agent plan_helper draft --session-path active
bash .ai/cli/agent executor_helper draft \
--session-path .ai/sessions/<sid> --step-id S1

Session path resolution:
--session-path active resolves to the kernel's current_session pointer
(read from .ai/state/status.json at invocation time).
--session-path <path> absolute or repo-relative; passed through unchanged.

Advanced (direct module invocation):
cd .ai && python3 -m cli.agents.<agent_name> [args]
USAGE
Expand Down Expand Up @@ -72,5 +77,33 @@ if [ ! -d "$AGENTS_DIR/$AGENT_NAME" ] || [ ! -f "$AGENTS_DIR/$AGENT_NAME/__main_
exit 2
fi

# Pre-process: expand `--session-path active` to the kernel's
# current_session pointer (from .ai/state/status.json). Resolution
# happens once at invocation; concurrent `sss` calls may race and
# land on a different session — same semantics as KI-2026-05-16-001.
new_args=()
prev=""
for arg in "$@"; do
if [ "$prev" = "--session-path" ] && [ "$arg" = "active" ]; then
resolved="$(python3 -c '
import json, sys
from pathlib import Path
status = Path("'"$AI_ROOT"'") / "state" / "status.json"
try:
cur = json.loads(status.read_text())["current_session"]
except Exception as e:
sys.stderr.write(f"agent: failed to resolve --session-path active: {e}\n")
sys.exit(78)
print(cur)
')"
if [ -z "$resolved" ]; then
exit 78
fi
arg="$resolved"
fi
new_args+=("$arg")
prev="$arg"
done

cd "$AI_ROOT"
exec python3 -m "cli.agents.$AGENT_NAME" "$@"
exec python3 -m "cli.agents.$AGENT_NAME" "${new_args[@]}"
50 changes: 50 additions & 0 deletions .ai/cli/agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# In-house Trinity Agents

Trinity ships a small set of proposal-only Python agents at `.ai/cli/agents/<name>/`.
They are invoked through the wrapper `.ai/cli/agent` (mirroring `.ai/cli/ai`).

## Agents

| Agent | Purpose |
|---|---|
| `session_bootstrap` | Draft session slug from a raw task description (sss) |
| `clarification_helper` | Draft 5 vvv answers from session context |
| `plan_helper` | Draft a plan envelope (nnn) |
| `executor_helper` | Draft a per-step gogogo proposal |
| `retro_writer` | Draft retrospective body (rrr) |
| `presentation_synthesizer` | Draft a close pack summary |

## Invocation

```bash
bash .ai/cli/agent <agent> [args...]
bash .ai/cli/agent --list # show available agents
bash .ai/cli/agent --help # full usage
```

## `--session-path active` (added 2026-05-23)

The wrapper resolves the literal token `active` to whatever
`.ai/state/status.json::current_session` points at, at invocation time.

```bash
bash .ai/cli/agent clarification_helper draft \
--session-path active "task description"

bash .ai/cli/agent plan_helper draft --session-path active
```

If no session is open (`status.json` missing or unreadable) the wrapper
exits **78** with a message on stderr — no Python traceback.

Resolution is **one-shot**: the wrapper substitutes the path before
dispatching to Python, so a concurrent `sss` in another shell could
race to a different session. Same race-window as KI-2026-05-16-001;
use an explicit session path (or `--session` flag on the kernel command)
when concurrency matters.

## Boundaries

Agents are **proposal-only** (Article III). They write to a session's
`THINK/` or `SANDBOX/` only, never to `.ai/policies/`, `.ai/audit/` (mutate),
`.ai/schemas/`, `docs/specs/`, or `docs/constitution/`.
43 changes: 39 additions & 4 deletions .ai/cli/commands/vvv.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ def callback(
None, "--answer", help="N=text (repeatable, e.g. --answer 1=...)"
),
answers_file: Optional[Path] = typer.Option(
None, "--answers-file", help='JSON {"1":"...","2":"..."}'
None,
"--answers-file",
help='JSON or YAML file with answers (e.g. {"1":"...","2":"..."} '
'or `1: "..."` per line). Format auto-detected.',
),
show: bool = typer.Option(
False, "--show", help="Print 5 questions and exit (no writes)"
Expand Down Expand Up @@ -146,7 +149,8 @@ def _run_inner(
_print_questions()
console.print(
f"[yellow]Missing answers for Q{missing}. Provide via "
f"--answer N=text (repeatable) or --answers-file path.[/yellow]"
f"--answer N=text (repeatable) or --answers-file <path> "
f'(JSON `{{"1":"..."}}` or YAML `1: "..."`).[/yellow]'
)
# Emit pack-declared vvv.failed before exiting (Article IX —
# evidence trail for failed proposals).
Expand Down Expand Up @@ -329,8 +333,7 @@ def _parse_answers(
) -> Dict[int, str]:
answers: Dict[int, str] = {}
if answers_file:
with answers_file.open() as f:
data = json.load(f)
data = _load_answers_file(answers_file)
for k, v in data.items():
answers[int(k)] = str(v)
for flag in answer_flags:
Expand All @@ -344,6 +347,38 @@ def _parse_answers(
return answers


def _load_answers_file(path: Path) -> Dict:
"""Load Q-answers map from a JSON or YAML file.

Tries JSON first (cheapest, backwards-compatible with prior callers);
falls back to yaml.safe_load on JSONDecodeError. Emits a clear error
that mentions both formats if neither parses.
"""
raw = path.read_text(encoding="utf-8")
try:
return json.loads(raw)
except json.JSONDecodeError as json_err:
try:
import yaml # PyYAML is a kernel runtime dep (see .ai/requirements.txt)

loaded = yaml.safe_load(raw)
except Exception as yaml_err:
console.print(
f"[red]--answers-file {path}: neither valid JSON nor YAML.[/red]\n"
f" JSON error: {json_err}\n"
f" YAML error: {yaml_err}\n"
f' Expected: JSON `{{"1":"...","2":"..."}}` or YAML `1: "..."`.'
)
raise typer.Exit(2)
if not isinstance(loaded, dict):
console.print(
f"[red]--answers-file {path}: YAML root must be a mapping "
f'(got {type(loaded).__name__}). Expected `1: "..."`.[/red]'
)
raise typer.Exit(2)
return loaded


def _query_past_incidents(
project_root: Path, answers: Dict[int, str]
) -> List[Dict]:
Expand Down
95 changes: 95 additions & 0 deletions .ai/cli/tests/test_agent_wrapper_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for `.ai/cli/agent` wrapper `--session-path active` resolution."""

from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[3]
WRAPPER = REPO_ROOT / ".ai" / "cli" / "agent"
STATUS_JSON = REPO_ROOT / ".ai" / "state" / "status.json"


def _read_current_session() -> str:
return json.loads(STATUS_JSON.read_text())["current_session"]


def test_wrapper_exists_and_executable() -> None:
assert WRAPPER.is_file()
assert os.access(WRAPPER, os.X_OK)


def test_help_mentions_active_keyword() -> None:
proc = subprocess.run(
["bash", str(WRAPPER), "--help"], capture_output=True, text=True
)
assert proc.returncode == 0
assert "active" in proc.stdout
assert "current_session" in proc.stdout


def test_active_resolves_to_current_session(tmp_path: Path) -> None:
"""Use clarification_helper --help via the wrapper: passing
--session-path active should reach the python module (which prints
the help banner). If resolution were broken, the wrapper would exit
78 before reaching python.
"""
current = _read_current_session()
assert current # precondition: a session must be open for this test
proc = subprocess.run(
["bash", str(WRAPPER), "clarification_helper", "draft",
"--session-path", "active", "--help"],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
# Help banner emitted by the python module; non-zero exit on help is fine
# for argparse (it exits with 0). What matters: wrapper did NOT fail with 78.
assert proc.returncode != 78, (
f"wrapper exit 78 means active-resolution failed.\n"
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
)


def test_active_fails_cleanly_when_status_missing(tmp_path: Path, monkeypatch) -> None:
"""If .ai/state/status.json is missing, wrapper exits 78 with a message
on stderr — not a confusing python traceback.
"""
# Stage a fake repo with no status.json
fake_repo = tmp_path / "fake_repo"
(fake_repo / ".ai" / "cli" / "agents" / "dummy").mkdir(parents=True)
# Mirror the wrapper into the fake repo
wrapper_copy = fake_repo / ".ai" / "cli" / "agent"
wrapper_copy.write_text(WRAPPER.read_text())
wrapper_copy.chmod(0o755)
# Create a dummy agent with __main__.py so the wrapper passes its existence check
(fake_repo / ".ai" / "cli" / "agents" / "dummy" / "__main__.py").write_text(
"print('dummy')\n"
)

proc = subprocess.run(
["bash", str(wrapper_copy), "dummy", "--session-path", "active"],
capture_output=True,
text=True,
cwd=str(fake_repo),
)
assert proc.returncode == 78
assert "failed to resolve" in proc.stderr or "active" in proc.stderr


def test_non_active_session_path_passes_through(tmp_path: Path) -> None:
"""A literal session path (not 'active') is forwarded as-is."""
current = _read_current_session()
proc = subprocess.run(
["bash", str(WRAPPER), "clarification_helper", "draft",
"--session-path", current, "--help"],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert proc.returncode != 78
4 changes: 2 additions & 2 deletions .ai/cli/tests/test_project_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def test_touch_memory_db_creates_sqlite_file(tmp_path: Path) -> None:
def test_default_homes_live_inside_trinity_checkout(tmp_path: Path, monkeypatch) -> None:
fake_file = (
tmp_path
/ "workspace-root"
/ "yai_project"
/ "trinity_v2"
/ ".ai"
/ "cli"
Expand All @@ -67,7 +67,7 @@ def test_default_homes_live_inside_trinity_checkout(tmp_path: Path, monkeypatch)
)
monkeypatch.setattr(registry_mod, "__file__", str(fake_file))

trinity_root = tmp_path / "workspace-root" / "trinity_v2"
trinity_root = tmp_path / "yai_project" / "trinity_v2"
assert default_trinity_home(env={}) == trinity_root / ".trinity"
assert default_memory_home(env={}) == trinity_root / ".memory"

Expand Down
40 changes: 40 additions & 0 deletions .ai/cli/tests/test_pyproject_pytest_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Verify project-root pyproject.toml carries pytest config for cwd-agnostic discovery."""

from __future__ import annotations

import subprocess
from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[3]
PYPROJECT = REPO_ROOT / "pyproject.toml"


def test_pyproject_exists_at_repo_root() -> None:
assert PYPROJECT.is_file()


def test_pytest_ini_options_section_present() -> None:
# tomllib is 3.11+; for 3.9/3.10 fall back to a plain text check that
# avoids adding a tomli dependency.
body = PYPROJECT.read_text(encoding="utf-8")
assert "[tool.pytest.ini_options]" in body
assert "testpaths" in body


def test_pytest_finds_tests_from_arbitrary_cwd(tmp_path: Path) -> None:
"""The whole point of pyproject's rootdir anchor: pytest must find the
project's tests no matter where you invoke it from.
"""
proc = subprocess.run(
["python3", "-m", "pytest",
str(REPO_ROOT / "tools" / "trinity-bootstrap-pack" / "tests"),
"-q", "--collect-only"],
capture_output=True,
text=True,
cwd=str(tmp_path),
)
assert proc.returncode == 0, f"stdout={proc.stdout}\nstderr={proc.stderr}"
assert "no tests ran" not in proc.stdout.lower()
Loading
Loading