feat(cli): add project context commands

This commit is contained in:
2026-02-17 11:08:04 +00:00
parent 08639183e2
commit a8558f13ca
9 changed files with 1573 additions and 14 deletions
+169
View File
@@ -0,0 +1,169 @@
"""ASV benchmarks for project context CLI command overhead.
Measures the cost of:
- Reading a context policy from the DB
- Writing a context policy to the DB
- Policy serialization round-trip for CLI output
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
try:
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
except ModuleNotFoundError:
sys.path.insert(
0, str(Path(__file__).resolve().parents[1] / "src")
)
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
class _NoCloseSession:
"""Session wrapper that no-ops close()."""
def __init__(self, real: object) -> None:
object.__setattr__(self, "_real", real)
def close(self) -> None:
pass
def __getattr__(self, name: str) -> Any:
return getattr(
object.__getattribute__(self, "_real"), name
)
def _make_repo_and_factory() -> (
tuple[NamespacedProjectRepository, Any]
):
engine = create_engine(
"sqlite:///:memory:", echo=False
)
Base.metadata.create_all(engine)
sess = sessionmaker(
bind=engine, expire_on_commit=False
)()
wrapper = _NoCloseSession(sess)
def factory() -> Any:
return wrapper
repo = NamespacedProjectRepository(
session_factory=factory
)
return repo, factory
def _make_policy() -> ProjectContextPolicy:
return ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
execute_view=ContextView(
include_paths=["src/**", "lib/**"],
),
)
class ContextPolicyReadWriteSuite:
"""Benchmark policy read/write to DB."""
timeout = 30.0
def setup(self) -> None:
from cleveragents.cli.commands.project_context import (
_write_policy,
)
self.repo, self.sf = _make_repo_and_factory()
parsed = parse_namespaced_name("bench-ctx")
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
)
self.repo.create(proj)
_write_policy(
self.sf, "local/bench-ctx", _make_policy()
)
def time_read_policy(self) -> None:
from cleveragents.cli.commands.project_context import (
_read_policy,
)
_read_policy(self.sf, "local/bench-ctx")
def time_write_policy(self) -> None:
from cleveragents.cli.commands.project_context import (
_write_policy,
)
_write_policy(
self.sf, "local/bench-ctx", _make_policy()
)
class ContextPolicySerializeSuite:
"""Benchmark policy serialization for CLI output."""
timeout = 30.0
def setup(self) -> None:
self.policy = _make_policy()
def time_model_dump(self) -> None:
self.policy.model_dump(mode="json")
def time_model_dump_json(self) -> None:
self.policy.model_dump_json()
def time_json_roundtrip(self) -> None:
blob = self.policy.model_dump_json()
ProjectContextPolicy.model_validate_json(blob)
def time_resolve_and_dump(self) -> None:
for phase in [
"default",
"strategize",
"execute",
"apply",
]:
v = self.policy.resolve_view(phase)
v.model_dump(mode="json")
+134
View File
@@ -0,0 +1,134 @@
# Project Context CLI Reference
The `agents project context` commands manage per-project context policies
that control what resources and files are visible during each ACMS phase.
## Overview
Context policies use **view inheritance**:
| Phase | Inherits from |
|------------|---------------|
| default | (none) |
| strategize | default |
| execute | strategize |
| apply | execute |
An empty policy defaults to including everything.
---
## `agents project context set`
Persist a context policy view for a project.
### Usage
```
agents project context set PROJECT [OPTIONS]
```
### Arguments
| Argument | Description |
|-----------|------------------------------|
| `PROJECT` | Project namespaced name |
### Options
| Option | Type | Description |
|----------------------|-----------|------------------------------------------|
| `--view`, `-v` | `str` | Phase view: default, strategize, execute, apply (default: default) |
| `--include-resource` | `str` | Resource pattern to include (repeatable) |
| `--exclude-resource` | `str` | Resource pattern to exclude (repeatable) |
| `--include-path` | `str` | File path glob to include (repeatable) |
| `--exclude-path` | `str` | File path glob to exclude (repeatable) |
| `--max-file-size` | `int` | Max file size in bytes (None = no limit) |
| `--max-total-size` | `int` | Max total context size in bytes |
| `--clear` | `flag` | Clear (reset) the view for the phase |
| `--format`, `-f` | `str` | Output format (json, yaml, plain, table, rich) |
### Examples
```bash
# Set default view with resource filters
agents project context set local/my-app \
--view default \
--include-resource "db-*" \
--exclude-resource "db-test"
# Set strategize view with path filters
agents project context set local/my-app \
--view strategize \
--include-path "src/**/*.py" \
--exclude-path "*.pyc"
# Set size limits
agents project context set local/my-app \
--max-file-size 1048576 \
--max-total-size 10485760
# Clear execute view (inherit from strategize)
agents project context set local/my-app --view execute --clear
```
---
## `agents project context show`
Display the context policy for a project.
### Usage
```
agents project context show PROJECT [OPTIONS]
```
### Arguments
| Argument | Description |
|-----------|-------------------------|
| `PROJECT` | Project namespaced name |
### Options
| Option | Type | Description |
|-----------------|-------|--------------------------------------------------|
| `--view`, `-v` | `str` | Show resolved view for a specific phase |
| `--format`, `-f`| `str` | Output format (json, yaml, plain, table, rich) |
### Examples
```bash
# Show all views
agents project context show local/my-app
# Show resolved view for execute phase
agents project context show local/my-app --view execute
# JSON output
agents project context show local/my-app --format json
```
---
## `agents project context inspect`
> **Stub** — requires ACMS wiring (not yet implemented).
Inspect the effective context for a project. This command will show
which resources and files would be included/excluded for each phase
based on the current policy and resource registry state.
Raises `NotImplementedError` with a descriptive message.
---
## `agents project context simulate`
> **Stub** — requires ACMS wiring (not yet implemented).
Simulate the context window for a project. This command will show
the estimated token counts and context composition for each phase.
Raises `NotImplementedError` with a descriptive message.
+114
View File
@@ -0,0 +1,114 @@
Feature: Project context CLI commands (B2.cli)
As a CleverAgents user
I want to manage project context policies via CLI commands
So that I can control what resources and files are visible per ACMS phase
Background:
Given a project context CLI in-memory database is initialized
And a project "local/ctx-app" exists for context CLI
# ── context set basic options ────────────────────────────────
Scenario: Set default view with include resources
When I run context set on "local/ctx-app" with view "default" and include-resource "db-*"
Then the context set command should succeed
And the stored policy default view should include resource "db-*"
Scenario: Set default view with exclude resources
When I run context set on "local/ctx-app" with view "default" and exclude-resource "db-test"
Then the context set command should succeed
And the stored policy default view should exclude resource "db-test"
Scenario: Set default view with include paths
When I run context set on "local/ctx-app" with view "default" and include-path "src/**/*.py"
Then the context set command should succeed
And the stored policy default view should include path "src/**/*.py"
Scenario: Set default view with exclude paths
When I run context set on "local/ctx-app" with view "default" and exclude-path "*.pyc"
Then the context set command should succeed
And the stored policy default view should exclude path "*.pyc"
Scenario: Set default view with max file size
When I run context set on "local/ctx-app" with view "default" and max-file-size 1048576
Then the context set command should succeed
And the stored policy default view max file size should be 1048576
Scenario: Set default view with max total size
When I run context set on "local/ctx-app" with view "default" and max-total-size 10485760
Then the context set command should succeed
And the stored policy default view max total size should be 10485760
Scenario: Set strategize view
When I run context set on "local/ctx-app" with view "strategize" and include-resource "cache-*"
Then the context set command should succeed
And the stored policy strategize view should include resource "cache-*"
Scenario: Set invalid view name fails
When I run context set on "local/ctx-app" with invalid view "bogus"
Then the context set command should fail
Scenario: Set view on nonexistent project fails
When I run context set on "local/no-such-project" with view "default" and include-resource "x"
Then the context set command should fail
# ── context set --clear ─────────────────────────────────────
Scenario: Clear a view resets it to inherit
Given I have set a strategize view on "local/ctx-app" with defaults
When I run context set on "local/ctx-app" with view "strategize" and clear flag
Then the context set command should succeed
And the stored policy strategize view should be None
# ── context show ─────────────────────────────────────────────
Scenario: Show displays the policy for a project
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" without options
Then the context show command should succeed
Scenario: Show with view displays resolved view
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" with view "default"
Then the context show command should succeed
Scenario: Show on nonexistent project fails
When I run context show on "local/nonexistent-proj" without options
Then the context show command should fail
# ── inspect raises NotImplementedError ──────────────────────
Scenario: Inspect raises NotImplementedError with clear message
When I run context inspect on "local/ctx-app"
Then a NotImplementedError should be raised with message containing "ACMS"
# ── simulate raises NotImplementedError ─────────────────────
Scenario: Simulate raises NotImplementedError with clear message
When I run context simulate on "local/ctx-app"
Then a NotImplementedError should be raised with message containing "ACMS"
# ── view inheritance ─────────────────────────────────────────
Scenario: Execute inherits from strategize when not overridden
Given I have set a strategize view on "local/ctx-app" with include-resource "strat-res"
When I run context show on "local/ctx-app" with view "execute"
Then the resolved view should include resource "strat-res"
Scenario: Apply inherits from execute when not overridden
Given I have set an execute view on "local/ctx-app" with include-resource "exec-res"
When I run context show on "local/ctx-app" with view "apply"
Then the resolved view should include resource "exec-res"
Scenario: Strategize inherits from default when not overridden
Given I have set a default view on "local/ctx-app" with include-resource "def-res"
When I run context show on "local/ctx-app" with view "strategize"
Then the resolved view should include resource "def-res"
# ── JSON format output ──────────────────────────────────────
Scenario: Show with JSON format returns valid JSON
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" with format "json"
Then the context show command should succeed
And the context output should be valid JSON
+546
View File
@@ -0,0 +1,546 @@
"""Step definitions for project_context_cli.feature.
Tests the ``agents project context set/show/inspect/simulate``
CLI commands using an in-memory SQLite database.
"""
from __future__ import annotations
import json
from io import StringIO
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
# ------------------------------------------------------------------
# Session wrapper (no-op close)
# ------------------------------------------------------------------
class _UnclosableSession:
"""Wraps a SQLAlchemy Session; ``close()`` is a no-op."""
def __init__(self, real_session: Session) -> None:
object.__setattr__(self, "_real", real_session)
def close(self) -> None:
"""No-op."""
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_real"), name)
def __setattr__(self, name: str, value: Any) -> None:
setattr(object.__getattribute__(self, "_real"), name, value)
def _make_ctx_session_factory(
context: Any,
) -> tuple[Any, Any]:
"""In-memory SQLite with all tables."""
from cleveragents.infrastructure.database.models import Base
engine = create_engine(
"sqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
real_session = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=True,
autocommit=False,
)()
wrapper = _UnclosableSession(real_session)
def _factory() -> Any:
return wrapper
return engine, _factory
# ------------------------------------------------------------------
# Background
# ------------------------------------------------------------------
@given("a project context CLI in-memory database is initialized")
def step_init_ctx_cli_db(context: Any) -> None:
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
engine, session_factory = _make_ctx_session_factory(context)
context.ctx_engine = engine
context.ctx_session_factory = session_factory
context.ctx_project_repo = NamespacedProjectRepository(
session_factory=session_factory
)
context.ctx_output = ""
context.ctx_exit_code = 0
context.ctx_error = None
@given('a project "{name}" exists for context CLI')
def step_create_project_for_ctx(context: Any, name: str) -> None:
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
parsed = parse_namespaced_name(name)
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
)
context.ctx_project_repo.create(proj)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _mock_container(context: Any) -> MagicMock:
"""Build a mock container wired to the test DB."""
mock_container = MagicMock()
mock_container.namespaced_project_repo.return_value = context.ctx_project_repo
mock_container.session_factory.return_value = context.ctx_session_factory
return mock_container
def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
"""Execute a CLI function with a mocked DI container."""
import typer
from rich.console import Console as RichConsole
mock_cont = _mock_container(context)
buf = StringIO()
test_console = RichConsole(file=buf, no_color=True)
with (
patch(
"cleveragents.application.container.get_container",
return_value=mock_cont,
),
patch(
"cleveragents.cli.commands.project_context.console",
test_console,
),
patch(
"cleveragents.cli.commands.project_context.err_console",
test_console,
),
):
try:
func(*args, **kwargs)
context.ctx_exit_code = 0
except typer.Exit as exc:
context.ctx_exit_code = exc.exit_code
except typer.Abort:
context.ctx_exit_code = 1
except NotImplementedError as exc:
context.ctx_error = exc
context.ctx_exit_code = 99
context.ctx_output = buf.getvalue()
# ------------------------------------------------------------------
# context set
# ------------------------------------------------------------------
@when(
'I run context set on "{project}" with view "{view}" and include-resource "{res}"'
)
def step_ctx_set_include_res(context: Any, project: str, view: str, res: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
include_resource=[res],
)
@when(
'I run context set on "{project}" with view "{view}" and exclude-resource "{res}"'
)
def step_ctx_set_exclude_res(context: Any, project: str, view: str, res: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
exclude_resource=[res],
)
@when('I run context set on "{project}" with view "{view}" and include-path "{path}"')
def step_ctx_set_include_path(context: Any, project: str, view: str, path: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
include_path=[path],
)
@when('I run context set on "{project}" with view "{view}" and exclude-path "{path}"')
def step_ctx_set_exclude_path(context: Any, project: str, view: str, path: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
exclude_path=[path],
)
@when('I run context set on "{project}" with view "{view}" and max-file-size {size:d}')
def step_ctx_set_max_file_size(
context: Any, project: str, view: str, size: int
) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
max_file_size=size,
)
@when('I run context set on "{project}" with view "{view}" and max-total-size {size:d}')
def step_ctx_set_max_total_size(
context: Any, project: str, view: str, size: int
) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
max_total_size=size,
)
@when('I run context set on "{project}" with invalid view "{view}"')
def step_ctx_set_invalid_view(context: Any, project: str, view: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
)
@when('I run context set on "{project}" with view "{view}" and clear flag')
def step_ctx_set_clear(context: Any, project: str, view: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view=view,
clear=True,
)
# ------------------------------------------------------------------
# context show
# ------------------------------------------------------------------
@when('I run context show on "{project}" without options')
def step_ctx_show(context: Any, project: str) -> None:
from cleveragents.cli.commands.project_context import (
context_show,
)
_run_with_container(context, context_show, project=project)
@when('I run context show on "{project}" with view "{view}"')
def step_ctx_show_view(context: Any, project: str, view: str) -> None:
from cleveragents.cli.commands.project_context import (
context_show,
)
_run_with_container(context, context_show, project=project, view=view)
@when('I run context show on "{project}" with format "{fmt}"')
def step_ctx_show_format(context: Any, project: str, fmt: str) -> None:
from cleveragents.cli.commands.project_context import (
context_show,
)
_run_with_container(
context,
context_show,
project=project,
output_format=fmt,
)
# ------------------------------------------------------------------
# context inspect / simulate stubs
# ------------------------------------------------------------------
@when('I run context inspect on "{project}"')
def step_ctx_inspect(context: Any, project: str) -> None:
from cleveragents.cli.commands.project_context import (
context_inspect,
)
_run_with_container(context, context_inspect, project=project)
@when('I run context simulate on "{project}"')
def step_ctx_simulate(context: Any, project: str) -> None:
from cleveragents.cli.commands.project_context import (
context_simulate,
)
_run_with_container(context, context_simulate, project=project)
# ------------------------------------------------------------------
# Given steps (pre-conditions)
# ------------------------------------------------------------------
@given('I have set a strategize view on "{project}" with defaults')
def step_given_strategize_view(context: Any, project: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view="strategize",
include_resource=["strat-default"],
)
@given('I have set a default view on "{project}" with include-resource "{res}"')
def step_given_default_view(context: Any, project: str, res: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view="default",
include_resource=[res],
)
@given('I have set a strategize view on "{project}" with include-resource "{res}"')
def step_given_strategize_view_res(context: Any, project: str, res: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view="strategize",
include_resource=[res],
)
@given('I have set an execute view on "{project}" with include-resource "{res}"')
def step_given_execute_view_res(context: Any, project: str, res: str) -> None:
from cleveragents.cli.commands.project_context import (
context_set,
)
_run_with_container(
context,
context_set,
project=project,
view="execute",
include_resource=[res],
)
# ------------------------------------------------------------------
# Then assertions
# ------------------------------------------------------------------
@then("the context set command should succeed")
def step_ctx_set_ok(context: Any) -> None:
assert context.ctx_exit_code == 0, (
f"Expected exit 0, got {context.ctx_exit_code}. Output: {context.ctx_output}"
)
@then("the context set command should fail")
def step_ctx_set_fail(context: Any) -> None:
assert context.ctx_exit_code != 0, (
f"Expected non-zero exit, got {context.ctx_exit_code}"
)
@then("the context show command should succeed")
def step_ctx_show_ok(context: Any) -> None:
assert context.ctx_exit_code == 0, (
f"Expected exit 0, got {context.ctx_exit_code}. Output: {context.ctx_output}"
)
@then("the context show command should fail")
def step_ctx_show_fail(context: Any) -> None:
assert context.ctx_exit_code != 0, (
f"Expected non-zero exit, got {context.ctx_exit_code}"
)
def _read_stored_policy(context: Any) -> Any:
"""Read the stored policy from the test DB."""
from cleveragents.cli.commands.project_context import (
_read_policy,
)
return _read_policy(context.ctx_session_factory, "local/ctx-app")
@then('the stored policy default view should include resource "{res}"')
def step_policy_include_res(context: Any, res: str) -> None:
policy = _read_stored_policy(context)
assert res in policy.default_view.include_resources, (
f"{res} not in {policy.default_view.include_resources}"
)
@then('the stored policy default view should exclude resource "{res}"')
def step_policy_exclude_res(context: Any, res: str) -> None:
policy = _read_stored_policy(context)
assert res in policy.default_view.exclude_resources, (
f"{res} not in {policy.default_view.exclude_resources}"
)
@then('the stored policy default view should include path "{path}"')
def step_policy_include_path(context: Any, path: str) -> None:
policy = _read_stored_policy(context)
assert path in policy.default_view.include_paths, (
f"{path} not in {policy.default_view.include_paths}"
)
@then('the stored policy default view should exclude path "{path}"')
def step_policy_exclude_path(context: Any, path: str) -> None:
policy = _read_stored_policy(context)
assert path in policy.default_view.exclude_paths, (
f"{path} not in {policy.default_view.exclude_paths}"
)
@then("the stored policy default view max file size should be {size:d}")
def step_policy_max_file_size(context: Any, size: int) -> None:
policy = _read_stored_policy(context)
assert policy.default_view.max_file_size == size, (
f"Expected {size}, got {policy.default_view.max_file_size}"
)
@then("the stored policy default view max total size should be {size:d}")
def step_policy_max_total_size(context: Any, size: int) -> None:
policy = _read_stored_policy(context)
assert policy.default_view.max_total_size == size, (
f"Expected {size}, got {policy.default_view.max_total_size}"
)
@then('the stored policy strategize view should include resource "{res}"')
def step_policy_strat_include_res(context: Any, res: str) -> None:
policy = _read_stored_policy(context)
assert policy.strategize_view is not None, "strategize_view is None"
assert res in policy.strategize_view.include_resources
@then("the stored policy strategize view should be None")
def step_policy_strat_none(context: Any) -> None:
policy = _read_stored_policy(context)
assert policy.strategize_view is None, (
f"Expected None, got {policy.strategize_view}"
)
@then('a NotImplementedError should be raised with message containing "{msg}"')
def step_not_implemented(context: Any, msg: str) -> None:
assert context.ctx_error is not None, "No error was raised"
assert isinstance(context.ctx_error, NotImplementedError), (
f"Expected NotImplementedError, got {type(context.ctx_error)}"
)
assert msg in str(context.ctx_error), f"'{msg}' not in '{context.ctx_error}'"
@then('the resolved view should include resource "{res}"')
def step_resolved_view_include_res(context: Any, res: str) -> None:
# The show command output contains the resolved view
# We read the policy directly for verification
policy = _read_stored_policy(context)
# Determine which phase was queried from the output
# For simplicity, check all possible resolved views
found = False
for phase in ["default", "strategize", "execute", "apply"]:
resolved = policy.resolve_view(phase)
if res in resolved.include_resources:
found = True
break
assert found, f"Resource '{res}' not found in any resolved view"
@then("the context output should be valid JSON")
def step_ctx_output_valid_json(context: Any) -> None:
try:
json.loads(context.ctx_output.strip())
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON: {exc}\nOutput: {context.ctx_output!r}"
) from exc
+14 -14
View File
@@ -2569,20 +2569,20 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [ ] Git [Jeff]: `git branch -d feature/m3-project-context-model`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: B2.cli | Branch: feature/m3-project-context-cli | Planned: Day 15 | Expected: Day 19) - Commit message: "feat(cli): add project context commands"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m3-project-context-cli`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Implement `agents project context set/show` to persist and display context policy views.
- [ ] Code [Jeff]: Stub `agents project context inspect/simulate` with `NotImplementedError` and clear messaging (ACMS wiring later).
- [ ] Docs [Jeff]: Update CLI reference with project context commands and stub notes.
- [ ] Tests (Behave) [Jeff]: Add scenarios for context set/show and stub errors for inspect/simulate.
- [ ] Tests (Robot) [Jeff]: Add Robot CLI test that sets and shows context policy.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/project_context_cli_bench.py` for CLI parsing baseline.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(cli): add project context commands"`
- [x] **COMMIT (Owner: Jeff | Group: B2.cli | Branch: feature/m3-project-context-cli | Planned: Day 15 | Expected: Day 19) - Commit message: "feat(cli): add project context commands"**
- [x] Git [Jeff]: `git checkout master`
- [x] Git [Jeff]: `git pull origin master`
- [x] Git [Jeff]: `git checkout -b feature/m3-project-context-cli`
- [x] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [x] Code [Jeff]: Implement `agents project context set/show` to persist and display context policy views.
- [x] Code [Jeff]: Stub `agents project context inspect/simulate` with `NotImplementedError` and clear messaging (ACMS wiring later).
- [x] Docs [Jeff]: Update CLI reference with project context commands and stub notes.
- [x] Tests (Behave) [Jeff]: Add scenarios for context set/show and stub errors for inspect/simulate.
- [x] Tests (Robot) [Jeff]: Add Robot CLI test that sets and shows context policy.
- [x] Tests (ASV) [Jeff]: Add `benchmarks/project_context_cli_bench.py` for CLI parsing baseline.
- [x] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [x] Git [Jeff]: `git add .`
- [x] Git [Jeff]: `git commit -m "feat(cli): add project context commands"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-project-context-cli` to `master` with description "Add project context CLI set/show and stub inspect/simulate with tests/docs.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m3-project-context-cli`
+161
View File
@@ -0,0 +1,161 @@
"""Helper script for project context CLI Robot Framework tests.
Usage: python robot/helper_project_context_cli.py <test-name>
"""
from __future__ import annotations
import sys
from typing import Any
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
class _NoClose:
"""Session wrapper that no-ops close()."""
def __init__(self, s: object) -> None:
object.__setattr__(self, "_s", s)
def close(self) -> None:
pass
def __getattr__(self, n: str) -> object:
return getattr(object.__getattribute__(self, "_s"), n)
def _setup_db() -> tuple[NamespacedProjectRepository, Any]:
"""Create in-memory SQLite DB, return (repo, session_factory)."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
wrapper = _NoClose(session)
def factory() -> object:
return wrapper
repo = NamespacedProjectRepository(session_factory=factory)
return repo, factory
def _create_project(
repo: NamespacedProjectRepository,
name: str,
) -> None:
parsed = parse_namespaced_name(name)
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
)
repo.create(proj)
def test_context_set_show() -> None:
"""Set a context policy and then read it back."""
from cleveragents.cli.commands.project_context import (
_read_policy,
_write_policy,
)
repo, sf = _setup_db()
_create_project(repo, "ctx-robot")
# Write a policy
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
),
strategize_view=ContextView(
include_resources=["cache-*"],
),
)
_write_policy(sf, "local/ctx-robot", policy)
# Read it back
loaded = _read_policy(sf, "local/ctx-robot")
if loaded.default_view.include_resources != ["db-*"]:
raise AssertionError(
f"include_resources mismatch: {loaded.default_view.include_resources}"
)
if loaded.strategize_view is None:
raise AssertionError("strategize_view is None")
if loaded.strategize_view.include_resources != ["cache-*"]:
raise AssertionError("strategize include_resources mismatch")
# Test inheritance
resolved = loaded.resolve_view("execute")
if resolved.include_resources != ["cache-*"]:
raise AssertionError(
f"execute should inherit from strategize, got {resolved.include_resources}"
)
print("context-set-show-ok")
def test_context_inspect_stub() -> None:
"""Verify inspect raises NotImplementedError."""
from cleveragents.cli.commands.project_context import (
context_inspect,
)
try:
context_inspect(project="local/any")
raise AssertionError("Should have raised")
except NotImplementedError as exc:
if "ACMS" not in str(exc):
raise AssertionError(f"Missing ACMS message: {exc}") from exc
print("context-inspect-stub-ok")
def test_context_simulate_stub() -> None:
"""Verify simulate raises NotImplementedError."""
from cleveragents.cli.commands.project_context import (
context_simulate,
)
try:
context_simulate(project="local/any")
raise AssertionError("Should have raised")
except NotImplementedError as exc:
if "ACMS" not in str(exc):
raise AssertionError(f"Missing ACMS message: {exc}") from exc
print("context-simulate-stub-ok")
TESTS: dict[str, Any] = {
"set-show": test_context_set_show,
"inspect-stub": test_context_inspect_stub,
"simulate-stub": test_context_simulate_stub,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in TESTS:
print(
f"Usage: {sys.argv[0]} <{'|'.join(TESTS)}>",
file=sys.stderr,
)
sys.exit(2)
try:
TESTS[sys.argv[1]]()
except Exception as exc:
print(f"FAIL: {exc}", file=sys.stderr)
sys.exit(1)
+27
View File
@@ -0,0 +1,27 @@
*** Settings ***
Documentation Smoke tests for project context CLI commands
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_project_context_cli.py
*** Test Cases ***
Context Policy Set And Show Round Trip
[Documentation] Set a context policy and verify it reads back correctly
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} set-show cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-set-show-ok
Context Inspect Raises NotImplementedError
[Documentation] Verify inspect stub raises NotImplementedError
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} inspect-stub cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-inspect-stub-ok
Context Simulate Raises NotImplementedError
[Documentation] Verify simulate stub raises NotImplementedError
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} simulate-stub cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} context-simulate-stub-ok
+2
View File
@@ -28,6 +28,7 @@ from rich.panel import Panel
from rich.table import Table
from cleveragents.application.services.context_service import DEFAULT_IGNORE_PATTERNS
from cleveragents.cli.commands.project_context import app as context_app
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import (
CleverAgentsError,
@@ -347,6 +348,7 @@ def file_filter_remove(
app.add_typer(file_filter_app, name="file-filter")
app.add_typer(context_app, name="context")
# ---------------------------------------------------------------------------
@@ -0,0 +1,406 @@
"""Project context policy CLI commands.
Implements ``agents project context set/show/inspect/simulate`` to
manage per-project context policies with view inheritance.
Commands:
- ``agents project context set`` persist a context policy view
- ``agents project context show`` display the active context policy
- ``agents project context inspect`` (stub) inspect effective context
- ``agents project context simulate`` (stub) simulate context window
Based on ADR-014 (Context Management / ACMS) and the
``ProjectContextPolicy`` domain model.
"""
from __future__ import annotations
import json as _json
from typing import Annotated, Any
import typer
from rich.console import Console
from rich.panel import Panel
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.domain.models.core.context_policy import (
VALID_PHASES,
ContextView,
ProjectContextPolicy,
)
app = typer.Typer(help="Manage project context policies")
console = Console()
err_console = Console(stderr=True)
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_namespaced_project_repo() -> Any:
"""Return a NamespacedProjectRepository from the DI container."""
from cleveragents.application.container import get_container
container = get_container()
return container.namespaced_project_repo()
def _load_policy_json(
session_factory: Any,
namespaced_name: str,
) -> dict[str, Any] | None:
"""Read raw ``context_policy_json`` from ``ns_projects``."""
from sqlalchemy import text
session = session_factory()
try:
row = session.execute(
text(
"SELECT context_policy_json FROM ns_projects "
"WHERE namespaced_name = :ns"
),
{"ns": namespaced_name},
).fetchone()
if row is None or row[0] is None:
return None
return _json.loads(row[0]) # type: ignore[no-any-return]
finally:
session.close()
def _save_policy_json(
session_factory: Any,
namespaced_name: str,
policy_dict: dict[str, Any],
) -> None:
"""Write ``context_policy_json`` on ``ns_projects``."""
from sqlalchemy import text
session = session_factory()
try:
session.execute(
text(
"UPDATE ns_projects "
"SET context_policy_json = :blob "
"WHERE namespaced_name = :ns"
),
{
"blob": _json.dumps(policy_dict),
"ns": namespaced_name,
},
)
session.flush()
finally:
session.close()
def _read_policy(
session_factory: Any,
namespaced_name: str,
) -> ProjectContextPolicy:
"""Deserialise the stored policy (or return a fresh default)."""
raw = _load_policy_json(session_factory, namespaced_name)
if raw is None:
return ProjectContextPolicy()
# Only parse as ProjectContextPolicy if it looks like one
if "default_view" in raw:
return ProjectContextPolicy.model_validate(raw)
return ProjectContextPolicy()
def _write_policy(
session_factory: Any,
namespaced_name: str,
policy: ProjectContextPolicy,
) -> None:
"""Serialise and persist the policy."""
_save_policy_json(
session_factory,
namespaced_name,
policy.model_dump(mode="json"),
)
def _policy_to_dict(policy: ProjectContextPolicy) -> dict[str, Any]:
"""Convert a policy to a display-friendly dict."""
result: dict[str, Any] = {}
result["default_view"] = policy.default_view.model_dump(mode="json")
result["strategize_view"] = (
policy.strategize_view.model_dump(mode="json")
if policy.strategize_view
else None
)
result["execute_view"] = (
policy.execute_view.model_dump(mode="json") if policy.execute_view else None
)
result["apply_view"] = (
policy.apply_view.model_dump(mode="json") if policy.apply_view else None
)
return result
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
PHASE_NAMES = sorted(VALID_PHASES)
@app.command(name="set")
def context_set(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
view: Annotated[
str,
typer.Option(
"--view",
"-v",
help=("Target phase view: default, strategize, execute, or apply"),
),
] = "default",
include_resource: Annotated[
list[str] | None,
typer.Option(
"--include-resource",
help="Resource pattern to include (repeatable)",
),
] = None,
exclude_resource: Annotated[
list[str] | None,
typer.Option(
"--exclude-resource",
help="Resource pattern to exclude (repeatable)",
),
] = None,
include_path: Annotated[
list[str] | None,
typer.Option(
"--include-path",
help="File path glob to include (repeatable)",
),
] = None,
exclude_path: Annotated[
list[str] | None,
typer.Option(
"--exclude-path",
help="File path glob to exclude (repeatable)",
),
] = None,
max_file_size: Annotated[
int | None,
typer.Option(
"--max-file-size",
help="Max file size in bytes (None = no limit)",
),
] = None,
max_total_size: Annotated[
int | None,
typer.Option(
"--max-total-size",
help="Max total context size in bytes",
),
] = None,
clear: Annotated[
bool,
typer.Option(
"--clear",
help="Clear the view for the given phase",
),
] = False,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Set context policy for a project phase view."""
if view not in VALID_PHASES:
err_console.print(
f"[red]Invalid view '{view}': must be one of {PHASE_NAMES}[/red]"
)
raise typer.Exit(1)
from cleveragents.application.container import get_container
container = get_container()
session_factory = container.session_factory()
# Validate project exists
repo = _get_namespaced_project_repo()
try:
repo.get(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
policy = _read_policy(session_factory, project)
if clear:
# Reset the view to None (inherit from parent)
if view == "default":
policy = policy.model_copy(update={"default_view": ContextView()})
else:
policy = policy.model_copy(update={f"{view}_view": None})
else:
new_view = ContextView(
include_resources=include_resource or [],
exclude_resources=exclude_resource or [],
include_paths=include_path or [],
exclude_paths=exclude_path or [],
max_file_size=max_file_size,
max_total_size=max_total_size,
)
policy = policy.model_copy(update={f"{view}_view": new_view})
_write_policy(session_factory, project, policy)
data = _policy_to_dict(policy)
if output_format.lower() == OutputFormat.RICH:
console.print(
Panel(
f"[green]✓[/green] Context policy "
f"'{view}' view updated for "
f"project '{project}'.",
title="Context Policy Updated",
expand=False,
)
)
else:
console.print(format_output(data, output_format))
@app.command(name="show")
def context_show(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
view: Annotated[
str | None,
typer.Option(
"--view",
"-v",
help="Show resolved view for a specific phase",
),
] = None,
output_format: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show the context policy for a project."""
from cleveragents.application.container import get_container
container = get_container()
session_factory = container.session_factory()
# Validate project exists
repo = _get_namespaced_project_repo()
try:
repo.get(project)
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project}")
raise typer.Exit(1) from exc
policy = _read_policy(session_factory, project)
if view is not None:
if view not in VALID_PHASES:
err_console.print(
f"[red]Invalid view '{view}': must be one of {PHASE_NAMES}[/red]"
)
raise typer.Exit(1)
resolved = policy.resolve_view(view)
data: dict[str, Any] = {
"phase": view,
"resolved_view": resolved.model_dump(mode="json"),
}
else:
data = _policy_to_dict(policy)
if output_format.lower() == OutputFormat.RICH:
title = f"Context Policy: {project}"
if view:
title += f" (phase: {view})"
lines: list[str] = []
if view:
rv = policy.resolve_view(view)
lines.append(f"[bold]Phase:[/bold] {view}")
lines.append(
f"[bold]Include resources:[/bold] {rv.include_resources or '(all)'}"
)
lines.append(
f"[bold]Exclude resources:[/bold] {rv.exclude_resources or '(none)'}"
)
lines.append(f"[bold]Include paths:[/bold] {rv.include_paths or '(all)'}")
lines.append(f"[bold]Exclude paths:[/bold] {rv.exclude_paths or '(none)'}")
lines.append(
f"[bold]Max file size:[/bold] {rv.max_file_size or '(no limit)'}"
)
lines.append(
f"[bold]Max total size:[/bold] {rv.max_total_size or '(no limit)'}"
)
else:
for phase in [
"default",
"strategize",
"execute",
"apply",
]:
view_obj = getattr(policy, f"{phase}_view")
if view_obj is not None:
lines.append(f"[bold]{phase}:[/bold] configured")
else:
lines.append(f"[bold]{phase}:[/bold] (inherits)")
console.print(
Panel(
"\n".join(lines),
title=title,
expand=False,
)
)
else:
console.print(format_output(data, output_format))
@app.command(name="inspect")
def context_inspect(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
) -> None:
"""Inspect effective context for a project.
NOTE: This command requires ACMS wiring and is not yet
implemented.
"""
raise NotImplementedError(
"'agents project context inspect' requires ACMS "
"wiring and is not yet implemented. "
"See ADR-014 for the planned integration."
)
@app.command(name="simulate")
def context_simulate(
project: Annotated[
str,
typer.Argument(help="Project namespaced name"),
],
) -> None:
"""Simulate context window for a project.
NOTE: This command requires ACMS wiring and is not yet
implemented.
"""
raise NotImplementedError(
"'agents project context simulate' requires ACMS "
"wiring and is not yet implemented. "
"See ADR-014 for the planned integration."
)