Skip to content

Commit 250abb2

Browse files
committed
Generalize readonly stores for zero-footprint query commands
Add readonly=True pattern to MemoryCatalog, SkillCandidateStore, and AutomationStore to prevent query commands from creating .teaagent directories in fresh workspaces. Store layer: - Add readonly parameter to MemoryCatalog, SkillCandidateStore, AutomationStore - Skip directory creation when readonly=True - Add RuntimeError guards in mutating methods when readonly=True CLI handlers: - memory list/search/show use MemoryCatalog(readonly=True) - skill candidate list/show use SkillCandidateStore(readonly=True) - automation list/show use AutomationStore(readonly=True) - Add JSON error boundary for memory show missing Preflight chain (critical for status): - build_daily_brief: Add readonly parameter, propagate to RunStore and preflight - build_automation_status: Use AutomationStore(readonly=True) and BackgroundRunStore(readonly=True) - preflight: Add readonly parameter, propagate to MemoryCatalog and build_context_pack - build_context_pack: Use MemoryCatalog(readonly=True) - check_env_health: Skip write health checks in readonly mode - status_short: Pass readonly=True to build_daily_brief Ergonomics: - run_history: Use RunStore(readonly=True) for yesterday/recall - automation_observability: Use AutomationStore(readonly=True) for blocked_reason Tests: - Add table-driven test distinguishing zero-footprint queries from mutating initializers - Add status short and automation status to zero-footprint test table - Add assertions for mutating initializer half - Fix ruff issues (contextlib.suppress, import ordering) This ensures high-frequency commands like status and yesterday/recall are truly zero-footprint for fresh workspaces, suitable for shell prompts, HUDs, and automated watchers. Constraint: Must maintain backward compatibility; readonly defaults to False for all stores. Query commands must not create .teaagent in fresh workspaces. Mutating commands must continue to initialize state normally. Tested: 22 tests in test_cli_ergonomics_handlers.py pass including new table-driven test. Fresh-root CLI black-box verification confirms status, agent automation status, yesterday/recall, memory/skill/automation list/show are zero-footprint. Ruff check passes. Confidence: high
1 parent 2e1dd39 commit 250abb2

13 files changed

Lines changed: 199 additions & 38 deletions

teaagent/automation_observability.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def automation_blocked_gate_reason(
9898
ticket = validate_automation_spec(spec, root=str(root))
9999
if ticket.errors:
100100
return '; '.join(ticket.errors[:2])
101-
store = AutomationStore(root)
101+
store = AutomationStore(root, readonly=True)
102102
with contextlib.suppress(FileNotFoundError):
103103
store.show_quarantined(spec.automation_id)
104104
return 'automation is quarantined; promote after review'

teaagent/automations.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,14 @@ def compute_next_run_at(schedule: str, *, now: Optional[datetime] = None) -> str
159159

160160

161161
class AutomationStore:
162-
def __init__(self, root: str | Path = '.') -> None:
162+
def __init__(self, root: str | Path = '.', *, readonly: bool = False) -> None:
163163
self.root = Path(root).resolve()
164164
self.dir = self.root / '.teaagent' / 'automations'
165165
self.quarantine_dir = self.root / '.teaagent' / 'automations-quarantine'
166-
self.dir.mkdir(parents=True, exist_ok=True)
167-
self.quarantine_dir.mkdir(parents=True, exist_ok=True)
166+
self.readonly = readonly
167+
if not readonly:
168+
self.dir.mkdir(parents=True, exist_ok=True)
169+
self.quarantine_dir.mkdir(parents=True, exist_ok=True)
168170

169171
def _spec_path(self, automation_id: str) -> Path:
170172
return self.dir / f'{automation_id}.json'
@@ -282,6 +284,8 @@ def create(
282284
context_from: str = '',
283285
provenance_digest: str = '',
284286
) -> AutomationSpec:
287+
if self.readonly:
288+
raise RuntimeError('Cannot create automation in readonly mode')
285289
spec = self.draft(
286290
name=name,
287291
task=task,
@@ -337,6 +341,8 @@ def create_quarantined(
337341
*,
338342
provenance: dict[str, Any],
339343
) -> AutomationSpec:
344+
if self.readonly:
345+
raise RuntimeError('Cannot create quarantined automation in readonly mode')
340346
payload = {
341347
**spec.to_dict(),
342348
'enabled': False,
@@ -378,6 +384,8 @@ def promote_quarantined(
378384
*,
379385
attested: bool = False,
380386
) -> AutomationSpec:
387+
if self.readonly:
388+
raise RuntimeError('Cannot promote quarantined automation in readonly mode')
381389
payload = dict(self.show_quarantined(automation_id))
382390
provenance = payload.pop('provenance', None)
383391
payload.pop('quarantine', None)
@@ -426,6 +434,8 @@ def promote_quarantined(
426434
return promoted
427435

428436
def delete(self, automation_id: str) -> None:
437+
if self.readonly:
438+
raise RuntimeError('Cannot delete automation in readonly mode')
429439
path = self._spec_path(automation_id)
430440
quarantine_path = self._quarantine_path(automation_id)
431441
if path.exists():
@@ -437,13 +447,17 @@ def delete(self, automation_id: str) -> None:
437447
raise FileNotFoundError(f"automation '{automation_id}' not found")
438448

439449
def update(self, spec: AutomationSpec) -> AutomationSpec:
450+
if self.readonly:
451+
raise RuntimeError('Cannot update automation in readonly mode')
440452
updated = AutomationSpec(**{**spec.to_dict(), 'updated_at': iso_utc(utc_now())})
441453
atomic_write_text(
442454
self._spec_path(spec.automation_id), json.dumps(updated.to_dict())
443455
)
444456
return updated
445457

446458
def set_enabled(self, automation_id: str, enabled: bool) -> AutomationSpec:
459+
if self.readonly:
460+
raise RuntimeError('Cannot set enabled in readonly mode')
447461
spec = self.show(automation_id)
448462
next_run_at = spec.next_run_at
449463
if enabled and not next_run_at:
@@ -472,13 +486,14 @@ def build_automation_status(
472486
root: str | Path,
473487
*,
474488
store: Optional[AutomationStore] = None,
489+
readonly: bool = True,
475490
) -> dict[str, Any]:
476491
"""Summarize automation health for CLI status output."""
477492
from teaagent.automation_observability import enrich_automation_status_row
478493
from teaagent.ergonomics.background_run import BackgroundRunStore
479494

480-
automation_store = store or AutomationStore(root)
481-
bg_store = BackgroundRunStore(root)
495+
automation_store = store or AutomationStore(root, readonly=readonly)
496+
bg_store = BackgroundRunStore(root, readonly=readonly)
482497
rows: list[dict[str, Any]] = []
483498
for spec in automation_store.list():
484499
log_tail = ''

teaagent/cli/_handlers/_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,7 @@ def automation_add_command(args: argparse.Namespace) -> int:
960960

961961

962962
def automation_list_command(args: argparse.Namespace) -> int:
963-
store = AutomationStore(args.root)
963+
store = AutomationStore(args.root, readonly=True)
964964
if getattr(args, 'quarantined', False):
965965
print_json(store.list_quarantined())
966966
return 0
@@ -985,7 +985,7 @@ def automation_promote_command(args: argparse.Namespace) -> int:
985985

986986
def automation_show_command(args: argparse.Namespace) -> int:
987987
try:
988-
spec = AutomationStore(args.root).show(args.automation_id)
988+
spec = AutomationStore(args.root, readonly=True).show(args.automation_id)
989989
except FileNotFoundError as exc:
990990
print_json({'status': 'error', 'message': str(exc)})
991991
return 1

teaagent/cli/_handlers/_memory.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def memory_add_command(args: argparse.Namespace) -> int:
4242

4343
def memory_list_command(args: argparse.Namespace) -> int:
4444
print_json(
45-
[entry.to_dict() for entry in MemoryCatalog(args.root).list(limit=args.limit)]
45+
[entry.to_dict() for entry in MemoryCatalog(args.root, readonly=True).list(limit=args.limit)]
4646
)
4747
return 0
4848

@@ -51,15 +51,19 @@ def memory_search_command(args: argparse.Namespace) -> int:
5151
print_json(
5252
[
5353
entry.to_dict()
54-
for entry in MemoryCatalog(args.root).search(args.query, limit=args.limit)
54+
for entry in MemoryCatalog(args.root, readonly=True).search(args.query, limit=args.limit)
5555
]
5656
)
5757
return 0
5858

5959

6060
def memory_show_command(args: argparse.Namespace) -> int:
61-
print_json(MemoryCatalog(args.root).show(args.memory_id).to_dict())
62-
return 0
61+
try:
62+
print_json(MemoryCatalog(args.root, readonly=True).show(args.memory_id).to_dict())
63+
return 0
64+
except FileNotFoundError as exc:
65+
print_json({'status': 'error', 'message': str(exc)})
66+
return 1
6367

6468

6569
def print_json(value: Any) -> None:

teaagent/cli/_handlers/_skill.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,13 @@ def skill_candidate_eval_command(args: argparse.Namespace) -> int:
8181

8282

8383
def skill_candidate_list_command(args: argparse.Namespace) -> int:
84-
rows = [row.to_dict() for row in SkillCandidateStore(args.root).list()]
84+
rows = [row.to_dict() for row in SkillCandidateStore(args.root, readonly=True).list()]
8585
_print_json(rows)
8686
return 0
8787

8888

8989
def skill_candidate_show_command(args: argparse.Namespace) -> int:
90-
store = SkillCandidateStore(args.root)
90+
store = SkillCandidateStore(args.root, readonly=True)
9191
try:
9292
row = store.show(args.candidate_id)
9393
except FileNotFoundError as exc:

teaagent/context_pack.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ def build_context_pack(
427427
hydrate_lsp: bool = False,
428428
search_graph: bool = True,
429429
code_analysis_config: Optional[CodeAnalysisConfig] = None,
430+
readonly: bool = False,
430431
) -> ContextPack:
431432
root_path = Path(root).resolve()
432433
texts = [task]
@@ -440,7 +441,7 @@ def build_context_pack(
440441
]
441442
memories = [
442443
entry.to_dict()
443-
for entry in MemoryCatalog(root_path).search(task, limit=memory_limit)
444+
for entry in MemoryCatalog(root_path, readonly=readonly).search(task, limit=memory_limit)
444445
]
445446
graph_rag = _graph_rag_evidence(
446447
root_path, task, search_graph=search_graph, hit_limit=graph_hit_limit

teaagent/daily.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ def build_daily_brief(
295295
memory_limit: Optional[int] = None,
296296
runs_limit: int = 5,
297297
context_profile: str = 'balanced',
298+
readonly: bool = False,
298299
) -> DailyBrief:
299300
from teaagent.preflight import preflight
300301

@@ -309,8 +310,9 @@ def build_daily_brief(
309310
route=route,
310311
memory_limit=profile.memory_limit,
311312
context_profile=profile.name,
313+
readonly=readonly,
312314
)
313-
store = RunStore(root)
315+
store = RunStore(root, readonly=readonly)
314316
recent_runs = _recent_run_rollups(store, runs_limit)
315317
token_budget = build_token_budget_report(
316318
task=effective_task,

teaagent/ergonomics/run_history.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def _parse_day(value: str) -> date | None:
1616

1717
def list_yesterday_runs(root: str | Path, *, limit: int = 20) -> list[dict[str, Any]]:
1818
target = date.today() - timedelta(days=1)
19-
store = RunStore(root)
19+
store = RunStore(root, readonly=True)
2020
results: list[dict[str, Any]] = []
2121
for summary in store.list_runs(limit=200):
2222
day = _parse_day(summary.created_at)
@@ -28,7 +28,7 @@ def list_yesterday_runs(root: str | Path, *, limit: int = 20) -> list[dict[str,
2828

2929

3030
def list_recall_runs(root: str | Path, *, limit: int = 5) -> list[dict[str, Any]]:
31-
store = RunStore(root)
31+
store = RunStore(root, readonly=True)
3232
return [_enrich_summary(store, summary) for summary in store.list_runs(limit=limit)]
3333

3434

teaagent/ergonomics/status_short.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def build_status_short(
2424
permission_mode=permission_mode,
2525
context_profile='lean',
2626
runs_limit=3,
27+
readonly=True,
2728
)
2829
level = brief.token_budget.usage_level
2930
colour = {'green': 'G', 'yellow': 'Y', 'red': 'R'}.get(level, '?')
@@ -36,7 +37,7 @@ def build_status_short(
3637
break
3738
if not active:
3839
active = brief.recent_runs[0].run_id
39-
store = RunStore(root)
40+
store = RunStore(root, readonly=True)
4041
status = 'idle'
4142
if active:
4243
try:

teaagent/memory.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ def to_dict(self) -> dict[str, Any]:
3434

3535

3636
class MemoryCatalog:
37-
def __init__(self, root: str | Path = '.') -> None:
37+
def __init__(self, root: str | Path = '.', *, readonly: bool = False) -> None:
3838
self.root = Path(root).resolve()
3939
self.path = self.root / '.teaagent' / 'memory.jsonl'
4040
self.quarantine_path = self.root / '.teaagent' / 'memory-quarantine.jsonl'
41-
self.path.parent.mkdir(parents=True, exist_ok=True)
41+
self.readonly = readonly
42+
if not readonly:
43+
self.path.parent.mkdir(parents=True, exist_ok=True)
4244

4345
def add(self, content: str, *, tags: tuple[str, ...] = ()) -> MemoryEntry:
46+
if self.readonly:
47+
raise RuntimeError('Cannot add memory in readonly mode')
4448
entry = MemoryEntry(
4549
memory_id=uuid4().hex, content=content.strip(), tags=normalize_tags(tags)
4650
)
@@ -56,6 +60,8 @@ def add_quarantined(
5660
tags: tuple[str, ...] = (),
5761
provenance: dict[str, Any],
5862
) -> MemoryEntry:
63+
if self.readonly:
64+
raise RuntimeError('Cannot add quarantined memory in readonly mode')
5965
entry = MemoryEntry(
6066
memory_id=uuid4().hex, content=content.strip(), tags=normalize_tags(tags)
6167
)

0 commit comments

Comments
 (0)