diff --git a/a0/router.py b/a0/router.py index d503d63a..2dcef115 100644 --- a/a0/router.py +++ b/a0/router.py @@ -1,6 +1,7 @@ -# 57:5 0:0 4:8 +# 59:5 0:0 4:8 from __future__ import annotations +import os from pathlib import Path from .contract import A0Request, A0Response, normalize_hmmm from .logging import log_event @@ -11,7 +12,8 @@ from .tools.pdf_tool import run_pdf_extract from .tools.whisper_tool import run_whisper_segments -LOG_DIR = Path(__file__).resolve().parent / "logs" +_DEFAULT_LOG_DIR = Path(__file__).resolve().parent / "logs" +LOG_DIR = Path(os.environ.get("A0_LOG_DIR", _DEFAULT_LOG_DIR)) def _select_adapter(req: A0Request): @@ -76,4 +78,4 @@ def handle(req: A0Request) -> A0Response: "hmmm": hmmm, }) return A0Response(task_id=req.task_id, result={"text": resp.get("text", ""), "artifacts": []}, hmmm=hmmm) -# 57:5 0:0 4:8 +# 59:5 0:0 4:8 diff --git a/a0/state.py b/a0/state.py index feb2b5cc..769901dd 100644 --- a/a0/state.py +++ b/a0/state.py @@ -1,11 +1,13 @@ -# 12:0 0:0 2:0 +# 14:0 0:0 2:0 from __future__ import annotations import json +import os from pathlib import Path from typing import Any, Dict -STATE_PATH = Path(__file__).resolve().parent / "state" / "a0_state.json" +_DEFAULT_STATE_PATH = Path(__file__).resolve().parent / "state" / "a0_state.json" +STATE_PATH = Path(os.environ.get("A0_STATE_PATH", _DEFAULT_STATE_PATH)) def load_state() -> Dict[str, Any]: if STATE_PATH.exists(): @@ -15,4 +17,4 @@ def load_state() -> Dict[str, Any]: def save_state(state: Dict[str, Any]) -> None: STATE_PATH.parent.mkdir(parents=True, exist_ok=True) STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") -# 12:0 0:0 2:0 +# 14:0 0:0 2:0 diff --git a/python/routes/__init__.py b/python/routes/__init__.py index 5963c670..4bf46b95 100644 --- a/python/routes/__init__.py +++ b/python/routes/__init__.py @@ -1,4 +1,4 @@ -# 163:21 0:0 0:31 +# 162:41 0:0 0:31 from .chat import router as chat_router from .agents import router as agents_router from .memory import router as memory_router @@ -87,11 +87,11 @@ def collect_ui_meta() -> list[dict]: "python.routes.editable_schema", "python.routes.cli", "python.routes.liminals", - "python.routes.artifacts", - # transcripts is a standalone page (routed at /transcripts via the top - # nav), not a metadata-driven console tab. Its UI_META is a page-nav - # descriptor (path, no tab_id/sections) and must not be aggregated here, - # or the console renders an unrenderable placeholder tab for it. + # artifacts (the "Archive") and transcripts are standalone pages, + # routed at /archive and /transcripts via the top nav, not + # metadata-driven console tabs. Their UI_META has no custom renderer + # and no sections, so aggregating them here makes the console render an + # unrenderable placeholder tab (caught by the console-tab guard). "python.routes.fleet", ] tabs = [] @@ -195,7 +195,26 @@ def collect_doc_meta() -> list[dict]: # Stale ALLOWLIST entries (route no longer exists) also fail. # class: security # call: python.tests.contracts.route_gating.test_every_write_route_is_gated +# +# id: routes_doc_blocks_complete +# given: every python/routes/*.py file (excluding __init__.py) +# then: it declares # DOC module/label/description/tier/role exactly +# once each, with role drawn from the allowed doctrine set +# class: correctness +# call: python.tests.contracts.module_doctrine.test_route_doc_blocks_are_complete +# +# id: routes_files_annotated +# given: every python/routes/*.py file (excluding __init__.py) +# then: its first and last non-blank lines are # N:M annotation comments +# class: correctness +# call: python.tests.contracts.module_doctrine.test_route_files_are_annotated +# +# id: routes_routers_registered +# given: every python/routes/*.py file that defines a module-level router +# then: it is imported and added to ALL_ROUTERS in __init__.py +# class: correctness +# call: python.tests.contracts.module_doctrine.test_router_defining_files_are_registered # === END CONTRACTS === # 171:16 -# 163:21 0:0 0:31 +# 162:41 0:0 0:31 diff --git a/python/routes/_admin_gate.py b/python/routes/_admin_gate.py index 107b45da..aac97c4f 100644 --- a/python/routes/_admin_gate.py +++ b/python/routes/_admin_gate.py @@ -1,7 +1,9 @@ -# 29:21 0:0 11:2 +# 29:23 0:0 11:2 # DOC module: _admin_gate # DOC label: Admin Gate # DOC description: Shared write-gate for instrument-wide mutation endpoints. +# DOC tier: admin +# DOC role: service """Shared admin / operator gate for routes that mutate global instrument state (memory seeds, PCNA channels, sigma watches, system toggles, agents, deals). @@ -55,4 +57,4 @@ async def require_admin(request: Request) -> None: except Exception: pass raise HTTPException(status_code=403, detail="Admin only") -# 29:21 0:0 11:2 +# 29:23 0:0 11:2 diff --git a/python/routes/admin.py b/python/routes/admin.py index 04ea4d43..37ae06a8 100644 --- a/python/routes/admin.py +++ b/python/routes/admin.py @@ -1,8 +1,9 @@ -# 77:7 0:0 1:1 +# 77:8 0:0 1:1 # DOC module: admin # DOC label: Admin Email Allowlist # DOC description: Admin-only endpoints for listing, adding, and removing admin email allowlist entries. # DOC tier: admin +# DOC role: route # DOC endpoint: GET /api/v1/admin/emails | List configured admin email entries. # DOC endpoint: POST /api/v1/admin/emails | Add an admin email entry. # DOC endpoint: DELETE /api/v1/admin/emails/{email} | Remove an admin email entry. @@ -100,4 +101,4 @@ async def remove_admin_email(request: Request, email: str): if result.rowcount == 0: return JSONResponse(status_code=404, content={"error": "Not found"}) return {"ok": True, "email": target} -# 77:7 0:0 1:1 +# 77:8 0:0 1:1 diff --git a/python/routes/agents.py b/python/routes/agents.py index fe705301..8b540887 100644 --- a/python/routes/agents.py +++ b/python/routes/agents.py @@ -1,4 +1,4 @@ -# 265:31 1:3 2:8 +# 265:32 1:3 2:8 import time import logging from fastapi import APIRouter, HTTPException, Request @@ -30,6 +30,7 @@ # DOC label: Agents # DOC description: Manages agent instances and sub-agent spawning. Lists running agents and supports manual merge operations. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/agents | List all agent instances # DOC endpoint: POST /api/v1/agents/spawn | Spawn a new agent instance # DOC endpoint: POST /api/v1/agents/{name}/merge | Merge a named agent configuration @@ -324,4 +325,4 @@ def _num(v, caster): } -# 265:31 1:3 2:8 +# 265:32 1:3 2:8 diff --git a/python/routes/approval_scopes.py b/python/routes/approval_scopes.py index 2c32d291..3427f85e 100644 --- a/python/routes/approval_scopes.py +++ b/python/routes/approval_scopes.py @@ -1,4 +1,4 @@ -# 118:18 3:4 1:4 +# 118:19 3:4 1:4 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel @@ -9,6 +9,7 @@ # DOC label: Approval Scopes # DOC description: Pre-approved action scope registry. Users can grant the agent permission to perform specific write actions without a per-action approval prompt. Viewing the catalog is free; granting and revoking requires ws tier. # DOC tier: ws +# DOC role: route # DOC endpoint: GET /api/v1/approval-scopes/catalog | List all available scope categories and their actions # DOC endpoint: GET /api/v1/approval-scopes | List the current user's granted scopes # DOC endpoint: POST /api/v1/approval-scopes | Grant a scope to the current user @@ -160,4 +161,4 @@ async def revoke_approval_scope(scope: str, request: Request): patch_endpoint="/api/v1/approval-scopes", query_key="/api/v1/approval-scopes", )) -# 118:18 3:4 1:4 +# 118:19 3:4 1:4 diff --git a/python/routes/artifacts.py b/python/routes/artifacts.py index 22b81b42..92af755d 100644 --- a/python/routes/artifacts.py +++ b/python/routes/artifacts.py @@ -1,4 +1,4 @@ -# 104:16 2:5 3:1 +# 104:17 2:5 3:1 """HTTP API for the unified artifacts archive.""" from typing import Optional import datetime as _dt @@ -13,6 +13,7 @@ # DOC label: Archive # DOC description: Unified archive of every file a0 produces (images, reports, evidence). Backed by Replit Object Storage with sha256 dedupe and full provenance. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/artifacts | List artifacts with filters (kind, tool, since) # DOC endpoint: GET /api/v1/artifacts/{id} | Fetch a single artifact + provenance # DOC endpoint: GET /api/v1/artifacts/{id}/download | Stream the artifact bytes @@ -146,4 +147,4 @@ async def patch_artifact(artifact_id: str, body: PatchArtifact, request: Request if not row: raise HTTPException(status_code=404, detail="artifact not found") return _serialize(row) -# 104:16 2:5 3:1 +# 104:17 2:5 3:1 diff --git a/python/routes/billing.py b/python/routes/billing.py index 724a7da6..66bf4e0a 100644 --- a/python/routes/billing.py +++ b/python/routes/billing.py @@ -1,4 +1,4 @@ -# 418:311 5:7 2:4 +# 418:312 5:7 2:4 import os import stripe from urllib.parse import urlparse @@ -14,6 +14,7 @@ # DOC label: Billing # DOC description: Donations-only billing surface. a0p is a research instrument, not a subscription product — there is no recurring sign-up tier. Existing Supporter subscribers are honored until they cancel via the Stripe portal. ws tier auto-assigned to @interdependentway.org accounts; admin tier reserved for the owner + invited collaborators. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/billing/status | Get current user billing status and tier # DOC endpoint: GET /api/v1/billing/plans | List supported flows (donation only; legacy Supporter tier retired) # DOC endpoint: GET /api/v1/billing/funding-statement | Verbatim 501c3/$500 disclosure copy @@ -858,4 +859,4 @@ async def explainer_checkout(request: Request): # class: idempotency # call: python.tests.contracts.billing.test_webhook_replay_is_idempotent # === END CONTRACTS === -# 418:311 5:7 2:4 +# 418:312 5:7 2:4 diff --git a/python/routes/billing_helpers.py b/python/routes/billing_helpers.py index b00f9085..a4a7fd95 100644 --- a/python/routes/billing_helpers.py +++ b/python/routes/billing_helpers.py @@ -1,8 +1,9 @@ -# 7:20 0:0 1:0 +# 7:21 0:0 1:0 # DOC module: billing_helpers # DOC label: Billing Helpers # DOC description: Legacy Supporter-tier helper functions used by billing webhook handling paths. # DOC tier: admin +# DOC role: service """ Billing helpers — Supporter-tier shims kept alive only for legacy webhook traffic. @@ -32,4 +33,4 @@ def is_supporter_subscription(metadata: dict) -> bool: legacy Supporter sub. Used to filter webhook events so we only touch rows that were actually Supporter subscribers.""" return metadata.get("product_key") == "supporter" -# 7:20 0:0 1:0 +# 7:21 0:0 1:0 diff --git a/python/routes/chat.py b/python/routes/chat.py index 3ff2fd4a..402558aa 100644 --- a/python/routes/chat.py +++ b/python/routes/chat.py @@ -1,4 +1,4 @@ -# 637:186 2:7 2:16 +# 637:187 2:7 2:16 import time import traceback from fastapi import APIRouter, HTTPException, Request @@ -94,6 +94,7 @@ def _attach_cost_usd(usage: dict | None, provider_id: str | None) -> None: # DOC label: Chat # DOC description: Manages conversations and messages between users and the agent. Supports streaming replies, conversation history, and per-conversation metadata. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/conversations | List all conversations for the current user # DOC endpoint: POST /api/v1/conversations | Create a new conversation # DOC endpoint: GET /api/v1/conversations/{id} | Get a single conversation @@ -905,4 +906,4 @@ async def _grant_scope_if_valid(scope: str) -> tuple[bool, str]: # class: correctness # call: python.tests.contracts.chat.test_unknown_body_model_400 # === END CONTRACTS === -# 637:186 2:7 2:16 +# 637:187 2:7 2:16 diff --git a/python/routes/cli.py b/python/routes/cli.py index 22de5ff5..c0ccc335 100644 --- a/python/routes/cli.py +++ b/python/routes/cli.py @@ -1,8 +1,9 @@ -# 124:88 2:4 1:6 +# 124:89 2:4 1:6 # DOC module: cli # DOC label: CLI Keys # DOC description: API key management for CLI and Termux access. Users generate bearer tokens (a0k_...) used to authenticate one-shot or interactive terminal sessions without a browser session. # DOC tier: ws +# DOC role: route # DOC endpoint: POST /api/v1/cli/keys | Generate a new CLI API key # DOC endpoint: GET /api/v1/cli/keys | List your CLI keys # DOC endpoint: DELETE /api/v1/cli/keys/{key_id} | Revoke a CLI key @@ -249,4 +250,4 @@ async def cli_chat(body: CliChatBody, request: Request): "tier": tier, "usage": usage, } -# 124:88 2:4 1:6 +# 124:89 2:4 1:6 diff --git a/python/routes/contexts.py b/python/routes/contexts.py index 4b3e0369..47c6db7f 100644 --- a/python/routes/contexts.py +++ b/python/routes/contexts.py @@ -1,4 +1,4 @@ -# 80:168 0:2 2:2 +# 80:169 0:2 2:2 import math import os from fastapi import APIRouter, HTTPException, Request @@ -13,6 +13,7 @@ # DOC label: Contexts # DOC description: Manages named prompt context blocks injected into the agent's system prompt. Admin-only. Each context is a named text value retrieved by the agent at inference time. # DOC tier: admin +# DOC role: route # DOC endpoint: GET /api/v1/contexts/{name} | Get a named prompt context value # DOC endpoint: PUT /api/v1/contexts/{name} | Set or replace a named prompt context value @@ -293,4 +294,4 @@ async def save_core_context(body: CoreContextBody, request: Request): patch_endpoint="/api/v1/context/system-sections", query_key="/api/v1/contexts", )) -# 80:168 0:2 2:2 +# 80:169 0:2 2:2 diff --git a/python/routes/docs.py b/python/routes/docs.py index dad8d6ec..dd9b88e5 100644 --- a/python/routes/docs.py +++ b/python/routes/docs.py @@ -1,8 +1,9 @@ -# 27:8 1:2 1:0 +# 27:9 1:2 1:0 # DOC module: docs # DOC label: Docs # DOC description: Living API reference. Each route module self-declares its documentation via # DOC comment blocks in its source file; this module aggregates and serves them. Minimum required fields per module: module, label, description, tier. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/docs | Return all module documentation entries sorted by label # DOC endpoint: GET /api/v1/docs/readme | Return replit.md content and per-module code:comment stats @@ -41,4 +42,4 @@ async def get_readme(request: Request): content = "# a0p\n\nNo README found." modules = collect_doc_meta() return {"content": content, "modules": modules} -# 27:8 1:2 1:0 +# 27:9 1:2 1:0 diff --git a/python/routes/edcm.py b/python/routes/edcm.py index e1a32dec..d1cb649f 100644 --- a/python/routes/edcm.py +++ b/python/routes/edcm.py @@ -1,4 +1,4 @@ -# 89:8 0:4 4:3 +# 89:9 0:4 4:3 from fastapi import APIRouter, Request from pydantic import BaseModel from typing import Optional, Any @@ -11,6 +11,7 @@ # DOC label: EDCM # DOC description: Emotional-Dimensional Calibration Module. Tracks affective metrics and stores periodic snapshots of the agent's internal state dimensions. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/edcm/metrics | Get current EDCM metric values # DOC endpoint: POST /api/v1/edcm/metrics | Update EDCM metrics # DOC endpoint: GET /api/v1/edcm/snapshots | List historical EDCM snapshots @@ -113,4 +114,4 @@ async def add_snapshot(request: Request, body: EdcmSnapshotInput): await require_admin(request) row = await storage.add_edcm_snapshot(body.model_dump(exclude_none=True)) return {"edcmbone_version": EDCMBONE_VERSION, "item": row} -# 89:8 0:4 4:3 +# 89:9 0:4 4:3 diff --git a/python/routes/editable_schema.py b/python/routes/editable_schema.py index e480f990..f08efb85 100644 --- a/python/routes/editable_schema.py +++ b/python/routes/editable_schema.py @@ -1,4 +1,4 @@ -# 38:8 1:2 1:1 +# 38:9 1:2 1:1 import os from fastapi import APIRouter, HTTPException, Request @@ -8,6 +8,7 @@ # DOC label: Editable Schema # DOC description: Machine-readable index of all registered mutable backend fields. WSEM fetches this index on activation to know what is editable, what control type to render, and which endpoint to PATCH. Also serves the project README with live per-module code:comment stats. # DOC tier: ws +# DOC role: route # DOC endpoint: GET /api/v1/editable-schema/index | Return all registered editable fields in camelCase (ws/admin only) # DOC endpoint: GET /api/v1/editable-schema/readme | Return replit.md content and per-module stats (all authenticated users) @@ -53,4 +54,4 @@ async def get_editable_schema_readme(request: Request): content = "# a0p\n\nNo README found." modules = collect_doc_meta() return {"content": content, "modules": modules} -# 38:8 1:2 1:1 +# 38:9 1:2 1:1 diff --git a/python/routes/fleet.py b/python/routes/fleet.py index c3fc6c4c..e9c95831 100644 --- a/python/routes/fleet.py +++ b/python/routes/fleet.py @@ -1,4 +1,4 @@ -# 392:32 2:10 1:4 +# 392:33 2:10 1:4 # N:M """Fleet benchmarking — head-to-head comparison of model/agent/orchestration tuples. @@ -26,6 +26,7 @@ # DOC label: Fleet # DOC description: Persistent benchmarks — fan one prompt across N (model, agent, orchestration) tuples and compare. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/fleet/benchmarks | List user's saved benchmarks. # DOC endpoint: POST /api/v1/fleet/benchmarks | Create a benchmark. # DOC endpoint: GET /api/v1/fleet/benchmarks/{id} | Benchmark + contestants + recent runs. @@ -487,4 +488,4 @@ async def list_runs(bid: int, request: Request): "WHERE benchmark_id = :bid ORDER BY started_at DESC LIMIT 50" ), {"bid": bid})).mappings().all() return [dict(r) for r in rows] -# 392:32 2:10 1:4 +# 392:33 2:10 1:4 diff --git a/python/routes/focus.py b/python/routes/focus.py index a1a66791..cfc8f3ca 100644 --- a/python/routes/focus.py +++ b/python/routes/focus.py @@ -1,8 +1,9 @@ -# 321:52 0:7 1:9 +# 321:53 0:7 1:9 # DOC module: focus # DOC label: Focus # DOC description: Model focus management. Provides context boost injection per conversation, focus regain directives, per-conversation tool selection, and system prompt preview. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/conversations/{id}/boost | Get the context boost for a conversation # DOC endpoint: PUT /api/v1/conversations/{id}/boost | Set context boost text injected into the system prompt # DOC endpoint: DELETE /api/v1/conversations/{id}/boost | Clear the context boost @@ -448,4 +449,4 @@ async def get_prompt_sections(conv_id: int, request: Request): sections["has_messages"] = has_messages sections["conversation_id"] = conv_id return sections -# 321:52 0:7 1:9 +# 321:53 0:7 1:9 diff --git a/python/routes/forge.py b/python/routes/forge.py index 943a9011..89a748dd 100644 --- a/python/routes/forge.py +++ b/python/routes/forge.py @@ -1,4 +1,4 @@ -# 213:37 5:8 1:7 +# 213:38 5:8 1:7 """The Forge — character-sheet style agent instantiation. Self-updating tool/model docs DB: @@ -23,6 +23,7 @@ # DOC label: Forge # DOC description: Character-sheet style agent creation. Pick a template archetype, swap in a model, check tools, set personality. Self-updating tool/model registry feeds the form. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/forge/templates | List built-in archetype templates # DOC endpoint: GET /api/v1/forge/tools | Live tool catalog (auto-introspected from TOOL_SCHEMAS_CHAT) # DOC endpoint: GET /api/v1/forge/models | Live model catalog (delegated to model_catalog.list_models_for_user) @@ -293,4 +294,4 @@ async def duel_stub(request: Request) -> dict: def _jsonb(value) -> str: import json return json.dumps(value) if value is not None else "null" -# 213:37 5:8 1:7 +# 213:38 5:8 1:7 diff --git a/python/routes/forge_archetypes.py b/python/routes/forge_archetypes.py index 0d51755a..c23fac7f 100644 --- a/python/routes/forge_archetypes.py +++ b/python/routes/forge_archetypes.py @@ -1,8 +1,9 @@ -# 104:7 0:0 1:0 +# 104:8 0:0 1:0 # DOC module: forge_archetypes # DOC label: Forge Archetypes # DOC description: Static archetype template catalog and tool category mapping consumed by forge route handlers. # DOC tier: free +# DOC role: config """Archetype template data and tool category map for the Forge. Pure data module — no FastAPI, no DB. Imported by forge.py routes. @@ -113,4 +114,4 @@ "system_prompt": "You are The Captain — break the task into orders, dispatch sub-agents, merge results, report.", }, ] -# 104:7 0:0 1:0 +# 104:8 0:0 1:0 diff --git a/python/routes/founders.py b/python/routes/founders.py index 6562b656..5c867477 100644 --- a/python/routes/founders.py +++ b/python/routes/founders.py @@ -1,10 +1,11 @@ -# 2:4 0:0 1:0 +# 2:5 0:0 1:0 from fastapi import APIRouter # DOC module: founders # DOC label: Founders # DOC description: Legacy founders module — retired with tier simplification. # DOC tier: admin +# DOC role: route router = APIRouter(prefix="/api/v1/founders", tags=["founders"]) -# 2:4 0:0 1:0 +# 2:5 0:0 1:0 diff --git a/python/routes/guest.py b/python/routes/guest.py index 0a27a15f..8910bf28 100644 --- a/python/routes/guest.py +++ b/python/routes/guest.py @@ -1,8 +1,9 @@ -# 32:5 0:0 1:2 +# 32:6 0:0 1:2 # DOC module: guest # DOC label: Guest Chat # DOC description: Unauthenticated preview chat endpoint that routes through the currently active provider. # DOC tier: free +# DOC role: route # DOC endpoint: POST /api/v1/guest/chat | Send one guest preview message. from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -47,4 +48,4 @@ async def guest_chat(body: GuestChatBody): tokens_used = max(10, len(body.message.split()) + len(content.split())) return {"content": content, "tokens_used": tokens_used} -# 32:5 0:0 1:2 +# 32:6 0:0 1:2 diff --git a/python/routes/heartbeat_api.py b/python/routes/heartbeat_api.py index 40b59844..580ebcda 100644 --- a/python/routes/heartbeat_api.py +++ b/python/routes/heartbeat_api.py @@ -1,4 +1,4 @@ -# 86:9 0:5 1:2 +# 86:10 0:5 1:2 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from typing import Optional @@ -10,6 +10,7 @@ # DOC label: Heartbeat # DOC description: Scheduled task runner and activity log. Heartbeat tasks fire on configurable intervals and their execution history is stored in the log. # DOC tier: free +# DOC role: api # DOC endpoint: GET /api/v1/heartbeat/tasks | List all scheduled tasks # DOC endpoint: POST /api/v1/heartbeat/tasks | Create a new scheduled task # DOC endpoint: PATCH /api/v1/heartbeat/tasks/{id} | Update a task schedule or payload @@ -113,4 +114,4 @@ async def delete_task(task_id: int, request: Request): @router.get("/heartbeat/logs") async def list_logs(limit: int = 24): return await storage.get_heartbeats(limit) -# 86:9 0:5 1:2 +# 86:10 0:5 1:2 diff --git a/python/routes/instances_api.py b/python/routes/instances_api.py index c2208e75..38e4a940 100644 --- a/python/routes/instances_api.py +++ b/python/routes/instances_api.py @@ -1,8 +1,9 @@ -# 338:41 3:17 1:4 +# 338:42 3:17 1:4 # DOC module: instances_api # DOC label: Model Instances # DOC description: CRUD for model instances (D&D party), per-instance memory, task board, and chat/archive sub-routes. # DOC tier: ws +# DOC role: api # DOC endpoint: GET /api/v1/agents/models | Model roster grouped by vendor # DOC endpoint: GET /api/v1/agents/instances | List all instances with counts # DOC endpoint: POST /api/v1/agents/instances | Create a new instance (admin) @@ -453,4 +454,4 @@ async def get_archives(iid: str): return [{"id": str(r["id"]), "label": r["label"], "archived_at": str(r["archived_at"]), "merge_status": r["merge_status"]} for r in rows] -# 338:41 3:17 1:4 +# 338:42 3:17 1:4 diff --git a/python/routes/liminals.py b/python/routes/liminals.py index 397c0f6d..8444108e 100644 --- a/python/routes/liminals.py +++ b/python/routes/liminals.py @@ -1,4 +1,4 @@ -# 60:7 1:1 1:1 +# 60:8 1:1 1:1 from fastapi import APIRouter, HTTPException, Request from ..storage import storage @@ -7,6 +7,7 @@ # DOC label: Liminals # DOC description: Aggregated view of in-between system states — running sub-agents and archived conversations. Read-only convenience surface; each item links back to its native tab. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/liminals | Aggregated liminal items grouped by category UI_META = { @@ -78,4 +79,4 @@ async def get_liminals(request: Request): "categories": categories, "total": sum(c["count"] for c in categories), } -# 60:7 1:1 1:1 +# 60:8 1:1 1:1 diff --git a/python/routes/memory.py b/python/routes/memory.py index 0e22d8c8..84437547 100644 --- a/python/routes/memory.py +++ b/python/routes/memory.py @@ -1,4 +1,4 @@ -# 306:10 0:6 2:2 +# 306:11 0:6 2:2 import math import random from fastapi import APIRouter, HTTPException, Request @@ -12,6 +12,7 @@ # DOC label: Memory # DOC description: Manages memory seeds — structured slots that persist context across sessions. Seeds can be seeded with initial values, updated, cleared, or imported in bulk. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/memory/seeds | List all memory seeds # DOC endpoint: GET /api/v1/memory/seeds/{index} | Get a specific seed by index # DOC endpoint: PUT /api/v1/memory/seeds/{index} | Replace a seed's value @@ -372,4 +373,4 @@ async def get_subcore_state(): "anomalies": anomalies, }, } -# 306:10 0:6 2:2 +# 306:11 0:6 2:2 diff --git a/python/routes/models.py b/python/routes/models.py index fece0558..923e7557 100644 --- a/python/routes/models.py +++ b/python/routes/models.py @@ -1,8 +1,9 @@ -# 9:12 1:1 5:1 +# 9:13 1:1 5:1 # DOC module: models # DOC label: Models # DOC description: Unified model catalog — every model the caller can actually use, with provider + role-assignment + tier provenance. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/models | List every model the caller can invoke, grouped by provider, with provenance from typing import Optional @@ -29,4 +30,4 @@ async def list_models(request: Request) -> dict: user's disabled_models list. """ return await list_models_for_user(_uid(request)) -# 9:12 1:1 5:1 +# 9:13 1:1 5:1 diff --git a/python/routes/openai_api.py b/python/routes/openai_api.py index 9f6b2c6c..58845deb 100644 --- a/python/routes/openai_api.py +++ b/python/routes/openai_api.py @@ -1,4 +1,4 @@ -# 62:6 0:2 1:2 +# 62:7 0:2 1:2 from fastapi import APIRouter, Request from pydantic import BaseModel, ConfigDict, Field from typing import Optional @@ -10,6 +10,7 @@ # DOC label: OpenAI # DOC description: OpenAI integration log and observation buffer. Stores and retrieves open questions (hmmm entries) captured during agent interactions with OpenAI models. # DOC tier: ws +# DOC role: api # DOC endpoint: GET /api/v1/openai/hmmm | Retrieve the rolling open-question buffer # DOC endpoint: POST /api/v1/openai/hmmm | Append a new open-question entry @@ -78,4 +79,4 @@ async def add_hmmm(request: Request, body: HmmmItem): } await append_openai_hmmm(item) return {"ok": True, "item": item} -# 62:6 0:2 1:2 +# 62:7 0:2 1:2 diff --git a/python/routes/orch_progress.py b/python/routes/orch_progress.py index 3d394fce..ff1c7e7f 100644 --- a/python/routes/orch_progress.py +++ b/python/routes/orch_progress.py @@ -1,4 +1,4 @@ -# 39:10 0:1 2:1 +# 39:11 0:1 2:1 """SSE endpoint for live multi-model orchestration progress. GET /api/v1/orchestration/{client_run_id}/stream — read-only.""" import asyncio @@ -17,6 +17,7 @@ # DOC label: Live Orchestration # DOC description: SSE channel for live per-voice token meters during multi-model chat sends. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/orchestration/{client_run_id}/stream | SSE per-voice progress events for one in-flight send. # DOC notes: Read-only. Bus is in-memory and ephemeral; events are not persisted. @@ -58,4 +59,4 @@ async def gen(): unregister_subscriber(client_run_id, q) return StreamingResponse(gen(), media_type="text/event-stream") -# 39:10 0:1 2:1 +# 39:11 0:1 2:1 diff --git a/python/routes/pcna_api.py b/python/routes/pcna_api.py index 5a8333b3..bc6aa96d 100644 --- a/python/routes/pcna_api.py +++ b/python/routes/pcna_api.py @@ -1,4 +1,4 @@ -# 291:11 0:6 1:3 +# 291:12 0:6 1:3 import time from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel @@ -9,6 +9,7 @@ # DOC label: PCNA Engine # DOC description: Probabilistic Cognitive Network Architecture engine. Manages inference, reward signaling, phi-state propagation, and audit trails for the core reasoning subsystem. # DOC tier: ws +# DOC role: api # DOC endpoint: GET /api/v1/pcna/state | Get current PCNA engine state # DOC endpoint: POST /api/v1/pcna/infer | Run an inference step # DOC endpoint: POST /api/v1/pcna/reward | Submit a reward signal @@ -380,4 +381,4 @@ async def pcna_compare(): "psi_delta": round(p7_psi - p8_psi, 4), "omega_delta": round(p7_omega - p8_omega, 4), } -# 291:11 0:6 1:3 +# 291:12 0:6 1:3 diff --git a/python/routes/preferences.py b/python/routes/preferences.py index 55eb22df..0e2859a1 100644 --- a/python/routes/preferences.py +++ b/python/routes/preferences.py @@ -1,4 +1,4 @@ -# 56:14 2:2 1:1 +# 56:15 2:2 1:1 # N:M """User preferences — small key/value store backed by the settings table. @@ -17,6 +17,7 @@ # DOC label: User Preferences # DOC description: Per-user key/value preferences (orchestration_mode, cut_mode, etc.) backed by the settings table. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/users/me/preferences | Return all preferences for the caller. # DOC endpoint: PATCH /api/v1/users/me/preferences | Upsert one or more preferences. # DOC notes: Anonymous callers get an empty dict and PATCH 401s. @@ -84,4 +85,4 @@ async def patch_preferences(body: PrefPatch, request: Request): ), {"u": uid, "k": k, "v": __import__("json").dumps({"v": v})}) return {"ok": True, "updated": len(updates), "values": updates} # N:M -# 56:14 2:2 1:1 +# 56:15 2:2 1:1 diff --git a/python/routes/runs.py b/python/routes/runs.py index 37fea63b..ff167586 100644 --- a/python/routes/runs.py +++ b/python/routes/runs.py @@ -1,4 +1,4 @@ -# 202:28 0:4 2:1 +# 202:29 0:4 2:1 # N:M """Fleet view API — agent_runs tree, per-run summary, paginated logs, SSE tail. @@ -20,6 +20,7 @@ # DOC label: Fleet # DOC description: Live tree of agent_runs with per-recursion-level structured logs and SSE tail. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/runs/tree | Full subtree of agent_runs from optional root. # DOC endpoint: GET /api/v1/runs/{run_id} | Single run summary + recent log events. # DOC endpoint: GET /api/v1/runs/{run_id}/logs | Paginated log entries (limit+before cursor). @@ -261,4 +262,4 @@ async def gen(): return StreamingResponse(gen(), media_type="text/event-stream") # N:M -# 202:28 0:4 2:1 +# 202:29 0:4 2:1 diff --git a/python/routes/sigma_api.py b/python/routes/sigma_api.py index 7d7b24b7..42e573f0 100644 --- a/python/routes/sigma_api.py +++ b/python/routes/sigma_api.py @@ -1,8 +1,9 @@ -# 88:10 5:6 1:2 +# 88:11 5:6 1:2 # DOC module: sigma # DOC label: Σ Sigma Core # DOC description: Filesystem substrate companion tensor core. Maps the workspace as a prime-node ring. Resolution 1-5 controls scan depth. Content-watch pins specific files and emits events on hash change. # DOC tier: ws +# DOC role: api # DOC endpoint: GET /api/v1/sigma/state | Get Sigma core state # DOC endpoint: PATCH /api/v1/sigma/resolution | Set scan resolution (1-5) # DOC endpoint: POST /api/v1/sigma/rescan | Trigger an immediate rescan @@ -121,4 +122,4 @@ async def sigma_intervals(req: IntervalsRequest, request: Request): sig.content_interval = req.content_interval sig.save_checkpoint() return {"ok": True, "structural_interval": sig.structural_interval, "content_interval": sig.content_interval} -# 88:10 5:6 1:2 +# 88:11 5:6 1:2 diff --git a/python/routes/system.py b/python/routes/system.py index b7c73311..bba9e11b 100644 --- a/python/routes/system.py +++ b/python/routes/system.py @@ -1,4 +1,4 @@ -# 226:12 0:6 2:4 +# 226:13 0:6 2:4 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from typing import Optional, Any @@ -10,6 +10,7 @@ # DOC label: System # DOC description: Controls platform-level subsystem toggles, cost tracking, and event logs. Restricted to admin users. # DOC tier: admin +# DOC role: route # DOC endpoint: GET /api/v1/system/toggles | List all subsystem toggles # DOC endpoint: PUT /api/v1/system/toggles/{subsystem} | Enable or configure a subsystem toggle # DOC endpoint: DELETE /api/v1/system/toggles/{subsystem} | Remove a subsystem toggle @@ -283,4 +284,4 @@ async def get_doc_file(file: str, request: Request): patch_endpoint="/api/v1/system/toggles/{subsystem}", query_key="/api/v1/system/toggles", )) -# 226:12 0:6 2:4 +# 226:13 0:6 2:4 diff --git a/python/routes/tools.py b/python/routes/tools.py index e0769b7d..e3889846 100644 --- a/python/routes/tools.py +++ b/python/routes/tools.py @@ -1,4 +1,4 @@ -# 125:20 0:5 4:2 +# 125:21 0:5 4:2 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, ConfigDict from typing import Optional, Any @@ -9,6 +9,7 @@ # DOC label: Tools # DOC description: Registry for custom agent tools. Tools define callable capabilities the agent can invoke during conversations, with typed parameters and descriptions. # DOC tier: free +# DOC role: route # DOC endpoint: GET /api/v1/tools | List all registered tools # DOC endpoint: POST /api/v1/tools | Register a new tool # DOC endpoint: GET /api/v1/tools/{id} | Get a specific tool @@ -176,4 +177,4 @@ async def delete_tool(tool_id: int, request: Request): patch_endpoint="/api/v1/tools/{id}", query_key="/api/v1/tools", )) -# 125:20 0:5 4:2 +# 125:21 0:5 4:2 diff --git a/python/routes/transcripts.py b/python/routes/transcripts.py index 0822a8ae..7d0ed15c 100644 --- a/python/routes/transcripts.py +++ b/python/routes/transcripts.py @@ -1,6 +1,9 @@ -# 231:75 3:6 2:4 +# 231:78 3:6 2:4 # DOC module: transcripts # DOC label: Transcripts +# DOC description: Transcript upload and EDCMBONE scoring — ingest files, list uploads/reports, and drill into per-round messages. +# DOC tier: free +# DOC role: route # DOC endpoint: POST /api/v1/transcripts/upload | Upload a transcript file (txt/md/html/json/pdf/zip) for EDCMBONE scoring # DOC endpoint: GET /api/v1/transcripts/uploads | List the caller's recent uploads with status # DOC endpoint: GET /api/v1/transcripts/uploads/{id} | Get one upload's status (poll target for async) @@ -361,4 +364,4 @@ async def explain_report_endpoint(request: Request, report_id: int): status_code=502, detail=f"explainer failed: {type(exc).__name__}: {exc}", ) -# 231:75 3:6 2:4 +# 231:78 3:6 2:4 diff --git a/python/routes/zfae_api.py b/python/routes/zfae_api.py index 8575f698..4c24c649 100644 --- a/python/routes/zfae_api.py +++ b/python/routes/zfae_api.py @@ -1,4 +1,4 @@ -# 113:37 0:7 1:3 +# 113:38 0:7 1:3 """ ZFAE API — Zeta Function Alpha Echo routes. @@ -20,6 +20,7 @@ # DOC label: ZFAE # DOC description: Zeta Function Alpha Echo subsystem. Maintains a rolling event echo buffer and exposes the ZetaEngine state and review history. Supports per-directory and global resolution levels (1–5) to control observation depth; comment lines are free of the 400-line budget. # DOC tier: ws +# DOC role: api # DOC endpoint: GET /api/v1/zfae/echo | Get the rolling 50-event echo buffer # DOC endpoint: GET /api/v1/zfae/state | Get ZetaEngine state including resolution config # DOC endpoint: GET /api/v1/zfae/review-history | Get recent review history entries @@ -185,4 +186,4 @@ async def remove_directory_resolution(body: RemoveDirectoryResolutionBody, reque config = _get_zeta().remove_directory_resolution(body.path) await _persist_resolution(config) return config -# 113:37 0:7 1:3 +# 113:38 0:7 1:3 diff --git a/python/services/gating_allowlist.py b/python/services/gating_allowlist.py index 587395d3..9cab16bd 100644 --- a/python/services/gating_allowlist.py +++ b/python/services/gating_allowlist.py @@ -64,7 +64,6 @@ class AllowEntry(NamedTuple): AllowEntry("focus.py", "PUT", "/conversations/{conv_id}/boost", "Owner-of-conv check via _assert_conv_owner"), AllowEntry("focus.py", "DELETE", "/conversations/{conv_id}/boost", "Owner-of-conv check via _assert_conv_owner"), AllowEntry("focus.py", "POST", "/conversations/{conv_id}/focus", "Owner-of-conv check via _assert_conv_owner"), - AllowEntry("focus.py", "POST", "/subagent", "Caller must be authenticated (401 if uid missing); spawns sub-agent conversation owned by caller uid — no shared instrument state mutated"), AllowEntry("transcripts.py", "POST", "/upload", "Caller uploads to their own quota; uid from header + quota check"), AllowEntry("transcripts.py", "POST", "/reports/{report_id}/explain", "Owner-only EDCMbone explainer; ownership checked via get_transcript_report join, billed against caller's own credits"), AllowEntry("billing.py", "POST", "/explainer-checkout", "Caller buys their own explainer pack; uid from header"), diff --git a/python/tests/contracts/module_doctrine.py b/python/tests/contracts/module_doctrine.py new file mode 100644 index 00000000..7fba0533 --- /dev/null +++ b/python/tests/contracts/module_doctrine.py @@ -0,0 +1,81 @@ +# 56:12 0:0 0:0 +# DOC module: tests.contracts.module_doctrine +# DOC label: Module doctrine adherence +# DOC description: Enforces the a0p module doctrine for python/routes/*.py: +# every route file carries a complete # DOC block (module, label, +# description, tier, role — each exactly once) with role drawn from the +# allowed set, opens/closes with the # N:M annotation, and — when it +# defines a module-level APIRouter — is registered in ALL_ROUTERS. +from __future__ import annotations + +import re +import pathlib + +_ROUTES_DIR = pathlib.Path(__file__).resolve().parents[2] / "routes" +_INIT = _ROUTES_DIR / "__init__.py" + +_REQUIRED_ONCE = ("module", "label", "description", "tier", "role") +_ALLOWED_ROLES = { + "route", "api", "service", "engine", "orchestrator", "schema", + "component", "page", "test", "contract", "doctrine", "config", + "script", "adapter", "hot_swap", "module", +} +_ANNOTATION = re.compile(r"^#\s*\d+:\d+(\s+\d+:\d+){0,2}\s*$") +_DOC_LINE = re.compile(r"^# DOC (\w+):") +_ROUTER_DEF = re.compile(r"^router\s*[:=]") + + +def _route_files() -> list[pathlib.Path]: + return [p for p in sorted(_ROUTES_DIR.glob("*.py")) if p.name != "__init__.py"] + + +def test_route_doc_blocks_are_complete() -> None: + """Every route file declares module/label/description/tier/role exactly + once, with role from the allowed set.""" + problems: list[str] = [] + for p in _route_files(): + keys: list[str] = [] + role_val: str | None = None + for line in p.read_text(encoding="utf-8").splitlines(): + m = _DOC_LINE.match(line) + if m: + keys.append(m.group(1)) + if m.group(1) == "role": + role_val = line.split(":", 1)[1].strip() + for req in _REQUIRED_ONCE: + n = keys.count(req) + if n != 1: + problems.append(f"{p.name}: '# DOC {req}:' appears {n}× (want exactly 1)") + if role_val is not None and role_val not in _ALLOWED_ROLES: + problems.append(f"{p.name}: role '{role_val}' not in allowed set") + assert not problems, "\n " + "\n ".join(problems) + + +def test_route_files_are_annotated() -> None: + """Every route file opens and closes with a # N:M annotation comment.""" + problems: list[str] = [] + for p in _route_files(): + lines = [l for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] + if not lines: + problems.append(f"{p.name}: empty file") + continue + if not _ANNOTATION.match(lines[0]): + problems.append(f"{p.name}: first line is not an annotation: {lines[0]!r}") + if not _ANNOTATION.match(lines[-1]): + problems.append(f"{p.name}: last line is not an annotation: {lines[-1]!r}") + assert not problems, "\n " + "\n ".join(problems) + + +def test_router_defining_files_are_registered() -> None: + """Any route file that defines a module-level APIRouter is imported and + placed in ALL_ROUTERS (else its endpoints never mount).""" + init_text = _INIT.read_text(encoding="utf-8") + imported = set(re.findall(r"from \.(\w+) import router", init_text)) + problems: list[str] = [] + for p in _route_files(): + text = p.read_text(encoding="utf-8") + if any(_ROUTER_DEF.match(l) for l in text.splitlines()): + if p.stem not in imported: + problems.append(f"{p.name}: defines a router but is not imported in __init__.py") + assert not problems, "\n " + "\n ".join(problems) +# 56:12 0:0 0:0 diff --git a/python/tests/contracts/spawn_executor.py b/python/tests/contracts/spawn_executor.py index e5a65579..380e0872 100644 --- a/python/tests/contracts/spawn_executor.py +++ b/python/tests/contracts/spawn_executor.py @@ -187,17 +187,17 @@ async def test_marks_failed_on_exception() -> None: await _delete_run(rid) -def test_resolve_provider_rejects_empty() -> None: +async def test_resolve_provider_rejects_empty() -> None: """_resolve_provider raises ValueError on empty/malformed providers input — no silent default-to-active fallback.""" try: - _resolve_provider([]) + await _resolve_provider([]) except ValueError: pass else: raise AssertionError("expected ValueError on empty providers list") try: - _resolve_provider("not a list and not json") + await _resolve_provider("not a list and not json") except ValueError: pass else: diff --git a/tests/test_a0_package.py b/tests/test_a0_package.py index 105e0c3e..2e103813 100644 --- a/tests/test_a0_package.py +++ b/tests/test_a0_package.py @@ -1,4 +1,4 @@ -# 53:10 0:0 0:0 +# 63:10 0:0 0:0 # DOC module: tests.test_a0_package # DOC label: a0 package import + CLI smoke # DOC description: Imports every module under the a0/ package to catch @@ -45,9 +45,14 @@ def test_subagent_registry_populated_without_sdk(): assert bandit.tools and bandit.model -def test_handle_round_trips_in_process(): +def test_handle_round_trips_in_process(tmp_path, monkeypatch): + import a0.state + import a0.router + + monkeypatch.setattr(a0.state, "STATE_PATH", tmp_path / "a0_state.json") + monkeypatch.setattr(a0.router, "LOG_DIR", tmp_path / "logs") + from a0.contract import A0Request, normalize_hmmm - from a0.router import handle req = A0Request( task_id="unit-1", @@ -56,12 +61,14 @@ def test_handle_round_trips_in_process(): mode="analyze", hmmm=normalize_hmmm(["hmm"]), ) - resp = handle(req) + resp = a0.router.handle(req) assert resp.task_id == "unit-1" assert resp.result is not None -def test_a0_cli_smoke(): +def test_a0_cli_smoke(tmp_path): + import os + payload = { "task_id": "smoke1", "input": {"text": "hello a0", "files": [], "metadata": {}}, @@ -69,13 +76,19 @@ def test_a0_cli_smoke(): "mode": "analyze", "hmmm": ["hmm"], } + env = { + **os.environ, + "A0_STATE_PATH": str(tmp_path / "a0_state.json"), + "A0_LOG_DIR": str(tmp_path / "logs"), + } proc = subprocess.run( [sys.executable, "-m", "a0.a0"], input=json.dumps(payload).encode("utf-8"), stdout=subprocess.PIPE, + env=env, check=True, ) out = json.loads(proc.stdout.decode("utf-8")) assert out["task_id"] == "smoke1" assert "result" in out -# 53:10 0:0 0:0 +# 63:10 0:0 0:0