diff --git a/CLAUDE.md b/CLAUDE.md index 40b3975b..2df62310 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,19 +312,19 @@ Major tasks are escalated to CEO for final approval: ```python # Git configuration (all tasks follow git workflow) -task_type: TaskType # code, documentation, research, planning, design, administrative -project_id: UUID # Project this task works on (required) -branch_name: str # Branch for this task (auto-created on claim) -work_session_id: UUID # Active work session +task_type: TaskType # code, documentation, research, planning, design, administrative +project_id: UUID # Project this task works on (required) +branch_name: str # Branch for this task (auto-created on claim) +work_session_id: UUID # Active work session # PR tracking (parallel execution in awaiting_documentation) -pr_number: int # GitHub/GitLab PR number -pr_url: str # Full URL to PR -docs_complete: bool # Documenter has finished -pr_created: bool # Developer has created PR +pr_number: int # GitHub/GitLab PR number +pr_url: str # Full URL to PR +docs_complete: bool # Documenter has finished +pr_created: bool # Developer has created PR # Commits linked to task -commits: list[CommitRef] # All commits made for this task +commits: list[CommitRef] # All commits made for this task ``` ## Communication Model diff --git a/docs/backend/services/sqlalchemy-uuid-casts.md b/docs/backend/services/sqlalchemy-uuid-casts.md index c0053acc..0e12549d 100644 --- a/docs/backend/services/sqlalchemy-uuid-casts.md +++ b/docs/backend/services/sqlalchemy-uuid-casts.md @@ -3,7 +3,7 @@ When casting SQLAlchemy `Mapped[UUID]` primary-key columns to the runtime `uuid.UUID` type for typing purposes, use the string-literal form: ```python -cast('UUID', child.id) +cast("UUID", child.id) ``` not the runtime symbol form: diff --git a/docs/rag/architecture/escalation-chain.md b/docs/rag/architecture/escalation-chain.md index 7918318b..cd7b1c54 100644 --- a/docs/rag/architecture/escalation-chain.md +++ b/docs/rag/architecture/escalation-chain.md @@ -43,10 +43,7 @@ Cell Members → Cell PM → Main PM → Product Owner → CEO ## Escalation Tool ```python -escalate_up( - task_id="uuid-here", - reason="Need clarification on requirements" -) +escalate_up(task_id="uuid-here", reason="Need clarification on requirements") ``` Auto-routes to your escalation target. You CANNOT choose a different target. `escalate_up` is a PM verb (Cell PM / Main PM); cell members (devs, QA, documenters) signal blockers with `i_am_blocked(task_id, reason)`, which their Cell PM resolves. @@ -54,10 +51,7 @@ Auto-routes to your escalation target. You CANNOT choose a different target. `es ## CEO Escalation (Main PM / Board Only) ```python -escalate_to_ceo( - task_id="uuid-here", - reason="Major feature ready for approval" -) +escalate_to_ceo(task_id="uuid-here", reason="Major feature ready for approval") ``` Requirements: diff --git a/docs/rag/architecture/preconditions-and-rejections.md b/docs/rag/architecture/preconditions-and-rejections.md index 2d05f9f8..a5e83c81 100644 --- a/docs/rag/architecture/preconditions-and-rejections.md +++ b/docs/rag/architecture/preconditions-and-rejections.md @@ -90,7 +90,7 @@ def _check_intent_preconditions( spec_intent: IntentSpec, task: Any, ctx: Context ) -> Decision | None: """Verb-level extra_preconditions gate. - + If the first failing precondition has rejection_kind='not_authorized', return Decision.reject(kind='not_authorized'). All other failures return Decision.tracing_gap. @@ -102,12 +102,11 @@ def _check_intent_preconditions( ] if not missing: return None - + first_missing = next( - p for p in spec_intent.extra_preconditions - if p.missing_token == missing[0] + p for p in spec_intent.extra_preconditions if p.missing_token == missing[0] ) - + # Check the rejection_kind of the first failing precondition if first_missing.rejection_kind == "not_authorized": return Decision.reject( @@ -115,12 +114,9 @@ def _check_intent_preconditions( message=first_missing.remediate, remediate=first_missing.remediate, ) - + # Default: tracing_gap with missing tokens - return Decision.tracing_gap( - missing=missing, - remediate=first_missing.remediate - ) + return Decision.tracing_gap(missing=missing, remediate=first_missing.remediate) ``` The key insight: **Only the first failing precondition's `rejection_kind` is checked.** This ensures ownership gates are checked early (they usually are in the preconditions list) so unowned tasks fail fast with `not_authorized` instead of collecting other tracing gaps. diff --git a/docs/rag/architecture/review-findings.md b/docs/rag/architecture/review-findings.md index 9193751a..7a25b8bb 100644 --- a/docs/rag/architecture/review-findings.md +++ b/docs/rag/architecture/review-findings.md @@ -17,13 +17,13 @@ Each takes `findings: list[dict]` — a list of structured findings. The legacy ```python { - "file": "roboco/api/routes/rate_limit.py", # optional; repo-relative, no ".." - "line": 88, # optional; >= 1 - "severity": "blocker", # required: blocker | major | minor | nit + "file": "roboco/api/routes/rate_limit.py", # optional; repo-relative, no ".." + "line": 88, # optional; >= 1 + "severity": "blocker", # required: blocker | major | minor | nit "criterion": "", # optional - "expected": "429 on the 101st request", # required, <=300 chars - "actual": "the 100th request also 429s", # required, <=300 chars - "fix": "use > not >= on the window limit", # optional, <=500 chars — describe the change, never a literal patch + "expected": "429 on the 101st request", # required, <=300 chars + "actual": "the 100th request also 429s", # required, <=300 chars + "fix": "use > not >= on the window limit", # optional, <=500 chars — describe the change, never a literal patch "evidence": "", # optional, <=2000 chars } ``` diff --git a/docs/rag/roles/auditor.md b/docs/rag/roles/auditor.md index 4bd293ec..3954e17b 100644 --- a/docs/rag/roles/auditor.md +++ b/docs/rag/roles/auditor.md @@ -129,9 +129,9 @@ Biweekly cron, org-scoped. Playbook curation is otherwise reactive — you only propose_playbook_drafts( drafts=[ { - "title": "...", # <=200 chars, must not duplicate an existing playbook (case-insensitive) - "body": "...", # <=4000 chars, the procedure itself - "pattern_evidence": "...", # REQUIRED, <=500 chars — which repeated journal/learning pattern justifies this + "title": "...", # <=200 chars, must not duplicate an existing playbook (case-insensitive) + "body": "...", # <=4000 chars, the procedure itself + "pattern_evidence": "...", # REQUIRED, <=500 chars — which repeated journal/learning pattern justifies this }, # 1-3 drafts ], diff --git a/docs/rag/roles/cell-pm.md b/docs/rag/roles/cell-pm.md index ac02d519..9fa76740 100644 --- a/docs/rag/roles/cell-pm.md +++ b/docs/rag/roles/cell-pm.md @@ -149,9 +149,9 @@ Each finding is inserted onto the task's revision-findings ledger (`origin=pm`) ## Monitoring Your Cell ```python -triage() # surfaces tasks waiting on you -roboco_git_status(...) # workspace state -roboco_git_log(...) # cell branch history +triage() # surfaces tasks waiting on you +roboco_git_status(...) # workspace state +roboco_git_log(...) # cell branch history note(text="...", scope="reflect") # journal observations ``` @@ -159,12 +159,20 @@ note(text="...", scope="reflect") # journal observations ```python # Cross-cell coordination -dm(recipient="fe-pm", text="Need to align on shared schema; task X.", - task_id="...", skill="api_design") +dm( + recipient="fe-pm", + text="Need to align on shared schema; task X.", + task_id="...", + skill="api_design", +) # Ack-required notification (PMs / Board only) -notify(target="be-dev-1", text="Please prioritise task X by EOD.", - priority="high", task_id="...") +notify( + target="be-dev-1", + text="Please prioritise task X by EOD.", + priority="high", + task_id="...", +) ``` ## Assembling + Submitting Finished Work @@ -206,7 +214,9 @@ Use `escalate_up(task_id, reason)` when: - A non-cell agent is blocking you ```python -escalate_up(task_id="", - reason="Frontend cell needs the new auth endpoint we own; " - "they're blocked. Want to confirm priority swap.") +escalate_up( + task_id="", + reason="Frontend cell needs the new auth endpoint we own; " + "they're blocked. Want to confirm priority swap.", +) ``` diff --git a/docs/rag/roles/documenter.md b/docs/rag/roles/documenter.md index 3e333a72..d7974602 100644 --- a/docs/rag/roles/documenter.md +++ b/docs/rag/roles/documenter.md @@ -68,13 +68,15 @@ roboco_kb_search("similar documentation") Use `roboco_docs_write()` — handles paths and deduplication automatically: ```python -roboco_docs_write({ - "task_id": "your-task-uuid", - "filename": "feature-api.md", - "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design - "title": "Feature API Documentation", - "content": "# Feature API\n\n..." -}) +roboco_docs_write( + { + "task_id": "your-task-uuid", + "filename": "feature-api.md", + "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design + "title": "Feature API Documentation", + "content": "# Feature API\n\n...", + } +) ``` **SMART DEDUPLICATION**: RAG searches for similar existing docs. diff --git a/docs/rag/roles/head-marketing.md b/docs/rag/roles/head-marketing.md index 035cde66..a01391e9 100644 --- a/docs/rag/roles/head-marketing.md +++ b/docs/rag/roles/head-marketing.md @@ -69,9 +69,9 @@ propose_market_brief( {"claim": "...", "source_url": "https://...", "relevance": "..."}, # 1-7 findings, source_url REQUIRED per finding — an uncited claim is rejected ], - threats=["..."], # optional, up to 5 - opportunities=["..."], # optional, up to 5 - positioning_note="...", # optional + threats=["..."], # optional, up to 5 + opportunities=["..."], # optional, up to 5 + positioning_note="...", # optional ) ``` @@ -99,8 +99,12 @@ Quarterly cron, project-scoped (`projects.board_programs` contains `"mirror"`). propose_messaging_fixes( items=[ { - "title": "...", "description": "...", "acceptance_criteria": ["..."], - "project_slug": "roboco-website", "team": "backend", "priority": 2, + "title": "...", + "description": "...", + "acceptance_criteria": ["..."], + "project_slug": "roboco-website", + "team": "backend", + "priority": 2, "evidence": "BOTH the drifted claim and the reality it contradicts — REQUIRED", }, # 1-5 items @@ -138,8 +142,8 @@ Cron every 2 days, org-scoped. The task carries a set of SCREENED candidate X co propose_conversation_replies( items=[ { - "tweet_id": "...", # REQUIRED — must be one of the candidate ids verbatim - "reply_body": "...", # your voice, <=280 chars, no invented facts + "tweet_id": "...", # REQUIRED — must be one of the candidate ids verbatim + "reply_body": "...", # your voice, <=280 chars, no invented facts "rationale": "why this conversation is worth replying to", # REQUIRED }, # up to 5 items @@ -166,7 +170,11 @@ The CEO acts via the panel/UI; you idle until the CEO decides. ## A2A ```python -dm(recipient="product-owner", text="Market analysis for the launch — ...", task_id="...") +dm( + recipient="product-owner", + text="Market analysis for the launch — ...", + task_id="...", +) ``` Skills: market_analysis diff --git a/docs/rag/roles/main-pm.md b/docs/rag/roles/main-pm.md index 807f0a21..40c61e26 100644 --- a/docs/rag/roles/main-pm.md +++ b/docs/rag/roles/main-pm.md @@ -68,7 +68,7 @@ notify(target="be-pm", text="New initiative assigned — see task", task_id=subt Monitor via: ```python -triage_all() # actionable tasks across all teams (Main PM only) +triage_all() # actionable tasks across all teams (Main PM only) ``` ## Tool Surface (per-spawn manifest) diff --git a/docs/rag/roles/product-owner.md b/docs/rag/roles/product-owner.md index b9a42b81..5e3e21a3 100644 --- a/docs/rag/roles/product-owner.md +++ b/docs/rag/roles/product-owner.md @@ -107,8 +107,12 @@ Biweekly cron, project-scoped (`projects.board_programs` contains `"spackle"`). propose_gap_fill( items=[ { - "title": "...", "description": "...", "acceptance_criteria": ["..."], - "project_slug": "roboco-api", "team": "backend", "priority": 2, + "title": "...", + "description": "...", + "acceptance_criteria": ["..."], + "project_slug": "roboco-api", + "team": "backend", + "priority": 2, "evidence": "BOTH sides of the gap — REQUIRED", }, # 1-5 items @@ -146,8 +150,12 @@ Event-triggered only (a release-publish hook, or the CEO's "run now") — no cro propose_friction_fixes( items=[ { - "title": "...", "description": "...", "acceptance_criteria": ["..."], - "project_slug": "roboco-api", "team": "frontend", "priority": 2, + "title": "...", + "description": "...", + "acceptance_criteria": ["..."], + "project_slug": "roboco-api", + "team": "frontend", + "priority": 2, "evidence": "the walked path (which pages, which clicks) — prose only, never a screenshot — REQUIRED", }, # 1-5 items diff --git a/docs/rag/roles/qa.md b/docs/rag/roles/qa.md index 11b2ab63..94733cac 100644 --- a/docs/rag/roles/qa.md +++ b/docs/rag/roles/qa.md @@ -143,10 +143,11 @@ The system blocks QA from reviewing their own dev work. The `original_developer` `escalate_up` is **not** in your manifest. Use `dm` to your Cell PM if something needs attention beyond pass/fail: ```python -dm(recipient="be-pm", - text="Task X — security concern, can you take a look before we " - "merge?", - task_id="...") +dm( + recipient="be-pm", + text="Task X — security concern, can you take a look before we merge?", + task_id="...", +) ``` For an external blocker (test environment broken, can't reproduce, missing infra), use `i_am_blocked(task_id, reason="...")` — your Cell PM is notified and `unblock`s you. If the work itself is wrong, `fail(task_id, findings=[...])` with the full context is the right move; the Cell PM picks it up from `needs_revision`. diff --git a/docs/rag/standards/python-basics.md b/docs/rag/standards/python-basics.md index 2feb9350..81961a79 100644 --- a/docs/rag/standards/python-basics.md +++ b/docs/rag/standards/python-basics.md @@ -33,12 +33,11 @@ All functions MUST have type hints: ```python # Good -async def fetch_user(user_id: UUID) -> User | None: - ... +async def fetch_user(user_id: UUID) -> User | None: ... + # Bad - no type hints -def fetch_user(user_id): - ... +def fetch_user(user_id): ... ``` ## Naming Conventions @@ -79,6 +78,7 @@ ALL I/O operations must be async: async def fetch_user(user_id: str) -> User: return await db.users.get(user_id) + # Bad - blocking def fetch_user(user_id: str) -> User: return db.users.get(user_id) # Blocks! diff --git a/docs/rag/standards/python-errors.md b/docs/rag/standards/python-errors.md index 48e70b4b..04a64e71 100644 --- a/docs/rag/standards/python-errors.md +++ b/docs/rag/standards/python-errors.md @@ -29,12 +29,15 @@ Define domain-specific exceptions: class TaskError(Exception): """Base exception for task operations.""" + class TaskNotFoundError(TaskError): """Task does not exist.""" + class TaskAlreadyClaimedError(TaskError): """Task is already claimed.""" + # Usage if task is None: raise TaskNotFoundError(f"Task {task_id} not found") @@ -62,6 +65,7 @@ Use structlog, NEVER print: ```python import structlog + logger = structlog.get_logger(__name__) # Good @@ -86,6 +90,7 @@ async def create_task(request: TaskCreate) -> TaskResponse: # Pydantic validates automatically ... + # Internal service - trust validated data async def process_task(task: Task) -> None: # No need to re-validate diff --git a/docs/rag/standards/python-security.md b/docs/rag/standards/python-security.md index e08cc8d9..a3694f61 100644 --- a/docs/rag/standards/python-security.md +++ b/docs/rag/standards/python-security.md @@ -12,6 +12,7 @@ DATABASE_URL = "postgresql://user:password@host/db" # Good - environment variables from pydantic_settings import BaseSettings + class Settings(BaseSettings): api_key: str database_url: str @@ -27,9 +28,7 @@ NEVER use string concatenation for SQL: query = f"SELECT * FROM users WHERE id = '{user_id}'" # Good - parameterized query -result = await session.execute( - select(User).where(User.id == user_id) -) +result = await session.execute(select(User).where(User.id == user_id)) ``` ## Command Injection Prevention @@ -39,10 +38,12 @@ NEVER pass user input directly to shell: ```python # Bad - command injection import os + os.system(f"process_file {filename}") # Good - use subprocess with list import subprocess + subprocess.run(["process_file", filename], check=True) ``` @@ -56,6 +57,7 @@ result = eval(user_input) # Good - safe parsing import ast + result = ast.literal_eval(user_input) # Only literals ``` @@ -68,7 +70,7 @@ import hashlib content_hash = hashlib.md5( content.encode(), - usedforsecurity=False # Required flag + usedforsecurity=False, # Required flag ).hexdigest()[:12] ``` diff --git a/docs/rag/standards/testing.md b/docs/rag/standards/testing.md index 007853d4..ffc241ad 100644 --- a/docs/rag/standards/testing.md +++ b/docs/rag/standards/testing.md @@ -16,6 +16,7 @@ Use pytest-asyncio: ```python import pytest + @pytest.mark.asyncio async def test_fetch_user() -> None: user = await fetch_user("test-123") @@ -48,11 +49,12 @@ Use factory-boy for test data: ```python from factory import Factory, Faker, LazyAttribute + class TaskFactory(Factory): class Meta: model = Task - title = Faker('sentence') + title = Faker("sentence") status = TaskStatus.PENDING created_at = LazyAttribute(lambda _: datetime.now(UTC)) ``` @@ -84,7 +86,7 @@ When you assign `None` to an attribute inside a test function, mypy narrows that def test_example() -> None: t = _Task() t.notes = None # mypy narrows type to None - process(t) # Even though process may write to t.notes + process(t) # Even though process may write to t.notes assert t.notes is not None # [unreachable] — mypy sees this as always False ``` @@ -94,11 +96,12 @@ def test_example() -> None: # ✅ GOOD: Annotation-typed class preserves union type class _TaskWithNoNotes: """Variant where notes starts as None (no prior state).""" - + def __init__(self) -> None: self.id = uuid4() self.notes: dict[str, Any] | None = None # Declared as union, not narrowed - + + def test_example() -> None: t = _TaskWithNoNotes() # Use the helper instead process(t) diff --git a/docs/rag/tools/a2a-tools.md b/docs/rag/tools/a2a-tools.md index dada23f6..05f17a28 100644 --- a/docs/rag/tools/a2a-tools.md +++ b/docs/rag/tools/a2a-tools.md @@ -6,10 +6,10 @@ A2A is direct peer-to-peer messaging between agents. There is **no** `roboco_age ```python dm( - recipient="be-qa", # target agent slug + recipient="be-qa", # target agent slug text="Please review my changes", - task_id="abc123...", # auto-filled from your active task if omitted - skill=None, # optional skill slug to scope the conversation + task_id="abc123...", # auto-filled from your active task if omitted + skill=None, # optional skill slug to scope the conversation ) ``` @@ -37,7 +37,7 @@ Same-cell peers (e.g. `be-dev-1` alongside `be-dev-2`/`be-qa`/`be-doc`/`be-pm`) When another agent messages you, your claim briefing surfaces it under `unread_a2a` — each entry shows the sender and a preview of their latest incoming message. To read the full bodies (and clear them), call: ```python -read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]} +read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]} ``` `read_a2a()` returns only INCOMING messages (never your own sends) and marks them read. `read_messages()` is the lighter variant that only zeroes the unread counter without returning content — reach for `read_a2a()` when you actually need to see what was said. Either clears `i_am_idle()`'s unread-A2A soft-block. @@ -45,9 +45,9 @@ read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]} Formal, ack-required notifications are a separate inbox — see `docs/rag/tools/messaging-tools.md`: ```python -notify_list(unread_only=True) # list pending items -notify_get(notification_id) # read one (marks it read) -notify_ack(notification_id) # acknowledge after handling +notify_list(unread_only=True) # list pending items +notify_get(notification_id) # read one (marks it read) +notify_ack(notification_id) # acknowledge after handling ``` ## When to use A2A diff --git a/docs/rag/tools/kb-tools.md b/docs/rag/tools/kb-tools.md index f595324d..71f25fec 100644 --- a/docs/rag/tools/kb-tools.md +++ b/docs/rag/tools/kb-tools.md @@ -16,31 +16,24 @@ roboco_kb_search( query="rate limiting redis", top_k=5, project="roboco-api", - index_types=["code", "docs"] + index_types=["code", "docs"], ) ``` ## AI-Generated Answers ```python -roboco_rag_query( - query="How does authentication work?", - top_k=5 -) +roboco_rag_query(query="How does authentication work?", top_k=5) ``` ## Mentor (Conversational) ```python -response = roboco_ask_mentor( - question="How do I handle auth?", - domain="coding" -) +response = roboco_ask_mentor(question="How do I handle auth?", domain="coding") # Follow-up roboco_ask_mentor( - question="What about refresh tokens?", - conversation_id=response["conversation_id"] + question="What about refresh tokens?", conversation_id=response["conversation_id"] ) ``` @@ -48,13 +41,15 @@ roboco_ask_mentor( ```python # Write/update documentation (auto-dedup via RAG) -roboco_docs_write({ - "task_id": "task-uuid", - "filename": "api-endpoints.md", - "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design - "title": "API Endpoints", - "content": "# API Endpoints\n\n..." -}) +roboco_docs_write( + { + "task_id": "task-uuid", + "filename": "api-endpoints.md", + "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design + "title": "API Endpoints", + "content": "# API Endpoints\n\n...", + } +) # List docs for a task roboco_docs_list(task_id="task-uuid") @@ -69,33 +64,24 @@ roboco_docs_read(path="backend/api/endpoints.md") ```python # Index code (PM, Developer) -roboco_kb_index_code( - sources=["src/**/*.py"], - project="roboco-api" -) +roboco_kb_index_code(sources=["src/**/*.py"], project="roboco-api") # Index docs (PM, Documenter) - for bulk/explicit indexing # Note: roboco_docs_write() auto-indexes when writing -roboco_kb_index_docs( - sources=["docs/**/*.md"], - project="roboco-api" -) +roboco_kb_index_docs(sources=["docs/**/*.md"], project="roboco-api") ``` ## Error Tracking ```python # Search for similar errors -roboco_search_error( - error_message="Redis connection timed out", - context="startup" -) +roboco_search_error(error_message="Redis connection timed out", context="startup") # Record solution roboco_record_error_solution( error_message="Redis connection timed out", solution="Added retry with backoff", - worked=True + worked=True, ) ``` @@ -106,11 +92,9 @@ roboco_record_error_solution( roboco_check_decision(topic="session storage") # Record decision -roboco_record_decision(params={ - topic: "Session storage", - decision: "Use Redis", - rationale: "Sub-ms reads" -}) +roboco_record_decision( + params={topic: "Session storage", decision: "Use Redis", rationale: "Sub-ms reads"} +) ``` ## Standards & Validation @@ -135,7 +119,7 @@ def create_user(email, password): user = User(email=email, password=password) db.add(user) return user -""" +""", ) ``` @@ -172,7 +156,7 @@ def create_user(email, password): roboco_review_code( code="def handle(...):", file_path="src/api/auth.py", - change_type="modify" # add, modify, delete + change_type="modify", # add, modify, delete ) ``` diff --git a/docs/rag/tools/messaging-tools.md b/docs/rag/tools/messaging-tools.md index 5db531a6..257b3916 100644 --- a/docs/rag/tools/messaging-tools.md +++ b/docs/rag/tools/messaging-tools.md @@ -19,9 +19,9 @@ notify(target="be-dev-1", text="Task ready for you", priority="normal", task_id= Every role with an inbox gets these (so `i_am_idle()` doesn't soft-block on unread items): ```python -notify_list(unread_only=True, limit=20) # your inbox -notify_get(notification_id) # read one (marks it read) -notify_ack(notification_id) # acknowledge after handling +notify_list(unread_only=True, limit=20) # your inbox +notify_get(notification_id) # read one (marks it read) +notify_ack(notification_id) # acknowledge after handling ``` When `i_am_idle()` reports unread A2A or @mentions, clear A2A with `read_a2a()` (see `a2a-tools.md`) and clear notifications with list -> get -> ack, then idle again. (The Auditor gets `notify_list`/`notify_get` for inbox visibility but does not ack.) diff --git a/docs/rag/tools/project-tools.md b/docs/rag/tools/project-tools.md index e08e548e..7dddc195 100644 --- a/docs/rag/tools/project-tools.md +++ b/docs/rag/tools/project-tools.md @@ -31,8 +31,11 @@ There is **no** `roboco_git_commit / _push / _checkout / _create_pr / _merge_pr` To learn how a project's codebase is laid out or how a subsystem works, query the knowledge base rather than a project tool: ```python -roboco_kb_search(query="rate limiting redis", project="roboco-api", - index_types=["code", "documentation"]) +roboco_kb_search( + query="rate limiting redis", + project="roboco-api", + index_types=["code", "documentation"], +) roboco_ask_mentor(question="How is auth wired up in this project?") ``` diff --git a/docs/rag/tools/task-tools.md b/docs/rag/tools/task-tools.md index 069588ad..08cc70b2 100644 --- a/docs/rag/tools/task-tools.md +++ b/docs/rag/tools/task-tools.md @@ -7,20 +7,20 @@ The verbs below are grouped by who calls them. ## Developer flow ```python -give_me_work() # returns your most-actionable pending task +give_me_work() # returns your most-actionable pending task i_will_work_on(task_id, plan="...") - # claims + sets plan + starts; auto-creates and - # checks out feature/{team}/{task-hierarchy} -commit(message, files=None) # content tool — repeat per change (auto-pushed) -open_pr(task_id) # pushes branch + opens the PR +# claims + sets plan + starts; auto-creates and +# checks out feature/{team}/{task-hierarchy} +commit(message, files=None) # content tool — repeat per change (auto-pushed) +open_pr(task_id) # pushes branch + opens the PR i_am_done(task_id, notes="", resolved_findings=None) - # verifying -> awaiting_qa (PR must already be open); - # on a bounced task, name every open ledger finding - # via resolved_findings=[{finding_id, commit?, note?}] -i_am_blocked(task_id, reason) # external dependency; cell PM unblocks -unclaim(task_id) # release a claimed task back to the queue -resume(task_id) # recover a paused task after compact/restart -i_am_idle() # no work in your queue right now +# verifying -> awaiting_qa (PR must already be open); +# on a bounced task, name every open ledger finding +# via resolved_findings=[{finding_id, commit?, note?}] +i_am_blocked(task_id, reason) # external dependency; cell PM unblocks +unclaim(task_id) # release a claimed task back to the queue +resume(task_id) # recover a paused task after compact/restart +i_am_idle() # no work in your queue right now ``` There is no separate claim / start / pause verb — `i_will_work_on` composes claim + set-plan + start atomically, and `i_am_done` composes verify + submit-qa. Branches are auto-created on `i_will_work_on`; do not checkout by hand — every root task branches from the project's env-ladder **head rung**, not a hardcoded `default_branch`/`master` string (see `CLAUDE.md` "Env-branches ladder"; a project with no declared ladder resolves this identically to its `default_branch`, so nothing changes unless the project opted in). @@ -48,11 +48,11 @@ The callable MCP tool names are `pass` / `fail` (`pass`/`fail` are reserved word ## Documenter flow ```python -give_me_work() # returns an awaiting_documentation task -claim_doc_task(task_id) # claim the doc phase -commit(message, files) # commit the doc files you write +give_me_work() # returns an awaiting_documentation task +claim_doc_task(task_id) # claim the doc phase +commit(message, files) # commit the doc files you write i_documented(task_id, notes, files) - # awaiting_documentation -> awaiting_pm_review +# awaiting_documentation -> awaiting_pm_review ``` Documentation tasks are **not** delegated — the lifecycle auto-creates the doc phase after a code task passes QA. @@ -60,33 +60,42 @@ Documentation tasks are **not** delegated — the lifecycle auto-creates the doc ## Cell PM flow ```python -triage() # list actionable tasks in your cell +triage() # list actionable tasks in your cell i_will_plan(task_id, plan, approach) - # claim + plan + start a parent task -delegate(parent_task_id, title, description, assigned_to, team, task_type, - nature, estimated_complexity, acceptance_criteria, - covers_parent_criteria=[...]) - # create a subtask; covers_parent_criteria maps - # it to the parent ACs it is responsible for — - # REQUIRED whenever the parent has any acceptance - # criteria (a ref that matches neither an AC id - # nor exact text is rejected, naming the valid - # criteria); omit only when the parent has none +# claim + plan + start a parent task +delegate( + parent_task_id, + title, + description, + assigned_to, + team, + task_type, + nature, + estimated_complexity, + acceptance_criteria, + covers_parent_criteria=[...], +) +# create a subtask; covers_parent_criteria maps +# it to the parent ACs it is responsible for — +# REQUIRED whenever the parent has any acceptance +# criteria (a ref that matches neither an AC id +# nor exact text is rejected, naming the valid +# criteria); omit only when the parent has none reassign(task_id, assigned_to) # move a subtask to a different agent -unblock(task_id, reason) # blocked -> in_progress (PM only); reason is - # recorded as your journal:decision (no separate - # note needed) +unblock(task_id, reason) # blocked -> in_progress (PM only); reason is +# recorded as your journal:decision (no separate +# note needed) submit_up(task_id, notes, resolved_findings=None) - # open cell->root PR; -> awaiting_pr_review - # (the cell PR reviewer gates it; after pr_pass - # the same Cell PM completes + merges); a re-submit - # after pr_fail must resolve every open finding first -complete(task_id, notes) # awaiting_pm_review -> completed (merges leaf PR) +# open cell->root PR; -> awaiting_pr_review +# (the cell PR reviewer gates it; after pr_pass +# the same Cell PM completes + merges); a re-submit +# after pr_fail must resolve every open finding first +complete(task_id, notes) # awaiting_pm_review -> completed (merges leaf PR) request_changes(task_id, findings=[...]) - # reject a subtask's merge review -> needs_revision, - # routed to whoever owns the revision; structured - # findings persist to the ledger + render into pm_notes -escalate_up(task_id, reason) # escalate to your escalation target +# reject a subtask's merge review -> needs_revision, +# routed to whoever owns the revision; structured +# findings persist to the ledger + render into pm_notes +escalate_up(task_id, reason) # escalate to your escalation target ``` After `i_will_plan` and each `delegate`, the envelope includes a coverage view of the parent — `parent_ac_coverage` (per-criterion `id` / `text` / `claimed` / `verified`) and `unclaimed_parent_acs` (criteria no subtask covers yet). A parent cannot idle with unclaimed criteria, nor `complete` / `submit_up` / `escalate_to_ceo` until every criterion traces to a child that passed QA. `delegate` refusing a child with no `covers_parent_criteria` (above) is what puts every parent with acceptance criteria under this coverage discipline from its first subtask on — a decomposition can no longer opt out by never declaring. A rejection now includes a copy-pasteable corrected `delegate(...)` skeleton with the parent's real criteria inlined (an id when the parent has one, its exact quoted text otherwise) — retry with that shape verbatim rather than re-deriving the field's syntax. `i_will_plan`'s planning briefing also carries `collision_context` (in `context_briefing`, not `evidence`) surfacing any same-parent siblings that already collide on file globs or migrations, so you can sequence your delegation before you commit to it. See `docs/rag/workflows/task-planning.md`. @@ -98,15 +107,15 @@ After `i_will_plan` and each `delegate`, the envelope includes a coverage view o The Main PM shares most Cell PM verbs (`i_will_plan`, `delegate`, `complete`, `request_changes`, `unblock`, `triage`, `escalate_up`), **adds** the verbs below, and — unlike a Cell PM — has **no** `submit_up` or `reassign`. Its bubble-up verb is `submit_root` (the root analogue of the Cell PM's `submit_up`): ```python -triage_all() # list actionable tasks across all teams +triage_all() # list actionable tasks across all teams submit_root(task_id, notes, resolved_findings=None) - # open root->master PR; -> awaiting_pr_review - # (the main PR reviewer gates it; after pr_pass, - # complete escalates to the CEO); a re-submit - # after pr_fail must resolve every open finding first +# open root->master PR; -> awaiting_pr_review +# (the main PR reviewer gates it; after pr_pass, +# complete escalates to the CEO); a re-submit +# after pr_fail must resolve every open finding first escalate_to_ceo(task_id, reason) - # awaiting_pm_review -> awaiting_ceo_approval -give_me_work() # Main PM may also pull work directly +# awaiting_pm_review -> awaiting_ceo_approval +give_me_work() # Main PM may also pull work directly ``` For a code root the Main PM **must** `submit_root` first — that opens the root→master PR and enters the in-path gate (`awaiting_pr_review`); only after the main reviewer `pr_pass`es it does `complete` escalate to the CEO. A branchless coordination root (product fan-out, no repo) skips the gate and is completed/escalated directly. The Main PM never merges to `master` — `complete` escalates and only the CEO merges the root→master PR. @@ -114,7 +123,7 @@ For a code root the Main PM **must** `submit_root` first — that opens the root ## Board flow (Product Owner / Head of Marketing) ```python -triage() # list actionable tasks in scope +triage() # list actionable tasks in scope escalate_to_ceo(task_id, reason) i_am_idle() ``` @@ -126,7 +135,7 @@ The Product Owner additionally has `propose_roadmap(cycle_goal, items)` — a ** ## Auditor flow ```python -triage() # read-only list of actionable tasks +triage() # read-only list of actionable tasks i_am_idle() ``` @@ -135,10 +144,10 @@ The Auditor is a silent observer: read-only `triage`, no `notify`, no claim/comp ## PR Reviewer flow ```python -give_me_work() # returns an inbound-PR review task -claim_pr_review(task_id) # claim it (planless, branchless — read-only) -post_pr_review(task_id, ...) # posts one change-request on the PR; task -> completed -unclaim(task_id) # release a claimed inbound or gate review back to the pool +give_me_work() # returns an inbound-PR review task +claim_pr_review(task_id) # claim it (planless, branchless — read-only) +post_pr_review(task_id, ...) # posts one change-request on the PR; task -> completed +unclaim(task_id) # release a claimed inbound or gate review back to the pool i_am_idle() ``` @@ -147,13 +156,13 @@ The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs The same role also runs the **in-path PR-review gate** on the org's own assembled delivery PRs — the merge-level review before the PM merges: ```python -claim_gate_review(task_id) # claim an awaiting_pr_review task; returns the assembled - # diff + collision_context (colliding siblings, if any) + - # (on round >=2) prior_findings, the full ledger -pr_pass(task_id, notes) # assembled PR is correct -> awaiting_pm_review (the PM merges) +claim_gate_review(task_id) # claim an awaiting_pr_review task; returns the assembled +# diff + collision_context (colliding siblings, if any) + +# (on round >=2) prior_findings, the full ledger +pr_pass(task_id, notes) # assembled PR is correct -> awaiting_pm_review (the PM merges) pr_fail(task_id, findings=[...]) - # send it back -> needs_revision, like a QA fail; - # the deprecated issues=[str] shim still works this release +# send it back -> needs_revision, like a QA fail; +# the deprecated issues=[str] shim still works this release ``` Both verdicts are also posted on the assembled PR itself as a review (server-side, bot account) so the decision is visible on the PR the PM merges: `pr_pass` → APPROVE, `pr_fail` → REQUEST_CHANGES — except the root→master PR, which only ever gets a plain COMMENT (only the CEO acts on `master`). On a GitLab-backed project `pr_fail` posts as a plain MR note instead (GitLab has no request-changes review primitive) — the task still goes to `needs_revision` normally regardless of forge. diff --git a/docs/rag/troubleshooting/common-issues.md b/docs/rag/troubleshooting/common-issues.md index c6dacdd0..e254bb72 100644 --- a/docs/rag/troubleshooting/common-issues.md +++ b/docs/rag/troubleshooting/common-issues.md @@ -94,13 +94,15 @@ Fix all issues before submitting. **Solution**: Use `roboco_docs_write()` - system handles paths automatically ```python -roboco_docs_write({ - "task_id": "your-task-uuid", - "filename": "feature.md", - "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design - "title": "Feature Documentation", - "content": "..." -}) +roboco_docs_write( + { + "task_id": "your-task-uuid", + "filename": "feature.md", + "doc_type": "api", # api, qa, guide, readme, changelog, architecture, design + "title": "Feature Documentation", + "content": "...", + } +) ``` - Team folder: Determined from your agent ID diff --git a/docs/rag/workflows/a2a-collaboration.md b/docs/rag/workflows/a2a-collaboration.md index bf7ad0ae..60f2f2dc 100644 --- a/docs/rag/workflows/a2a-collaboration.md +++ b/docs/rag/workflows/a2a-collaboration.md @@ -36,7 +36,7 @@ CEO-initiated conversations may arrive and are replied to in-thread like any oth Your claim briefing surfaces incoming A2A under `unread_a2a` — each entry shows the sender and a preview of their latest message. To read the full bodies (and clear them): ```python -read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]} +read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]} ``` `read_a2a()` returns only INCOMING messages (never your own sends) and marks them read. It also clears `i_am_idle()`'s unread-A2A soft-block. diff --git a/docs/rag/workflows/kb-search.md b/docs/rag/workflows/kb-search.md index 2dcf2d47..d33aa101 100644 --- a/docs/rag/workflows/kb-search.md +++ b/docs/rag/workflows/kb-search.md @@ -25,9 +25,9 @@ It searches ALL knowledge sources and supports follow-up questions. ```python roboco_kb_search( query="rate limiting redis implementation", - top_k=5, # Results to return - project="roboco-api", # Optional project filter - index_types=["code", "docs"] # Filter by type + top_k=5, # Results to return + project="roboco-api", # Optional project filter + index_types=["code", "docs"], # Filter by type ) ``` @@ -36,10 +36,7 @@ Returns similar content - not just keyword matches. ## RAG Query (AI Answer) ```python -roboco_rag_query( - query="How does authentication work in this codebase?", - top_k=5 -) +roboco_rag_query(query="How does authentication work in this codebase?", top_k=5) ``` Returns AI-synthesized answer with citations. @@ -54,14 +51,12 @@ Good for: ```python # First question response = roboco_ask_mentor( - question="How do I handle authentication?", - domain="coding" + question="How do I handle authentication?", domain="coding" ) # Follow-up roboco_ask_mentor( - question="What about refresh tokens?", - conversation_id=response["conversation_id"] + question="What about refresh tokens?", conversation_id=response["conversation_id"] ) ``` diff --git a/docs/rag/workflows/pr-creation.md b/docs/rag/workflows/pr-creation.md index cc809d54..c64fd5de 100644 --- a/docs/rag/workflows/pr-creation.md +++ b/docs/rag/workflows/pr-creation.md @@ -12,8 +12,10 @@ You do **not** call any tool to create a PR. There is no `roboco_git_create_pr` ```python # 1. Make commits as you work (auto-pushes, no separate push step) -commit(message="feat(api): add Redis rate limiter", - files=["roboco/api/routes/rate.py", "tests/integration/test_rate.py"]) +commit( + message="feat(api): add Redis rate limiter", + files=["roboco/api/routes/rate.py", "tests/integration/test_rate.py"], +) # 2. Once acceptance criteria are implemented + tested, hand off to QA. # The choreographer opens the PR here, sets pr_number/pr_url on the diff --git a/docs/rag/workflows/qa-review.md b/docs/rag/workflows/qa-review.md index 865a9cfd..413b2247 100644 --- a/docs/rag/workflows/qa-review.md +++ b/docs/rag/workflows/qa-review.md @@ -27,10 +27,12 @@ roboco_git_log(branch="") # Frontend: pnpm test && pnpm lint && pnpm typecheck # 5. Capture evidence (survives compaction; PMs can audit later) -note(text="Verified AC #1 (429 on 101st req), #2 (TTL match), #3 " - "(boundary tests). pytest 1635 passed; ruff clean; mypy clean.", - scope="evidence", - task_id="") +note( + text="Verified AC #1 (429 on 101st req), #2 (TTL match), #3 " + "(boundary tests). pytest 1635 passed; ruff clean; mypy clean.", + scope="evidence", + task_id="", +) ``` There is no `roboco_task_claim / _start / _qa_pass / _qa_fail` and no `roboco_git_checkout`. The verbs above (`claim_review`, `pass`, `fail`) are the actual surface; branch checkout is a side-effect of `claim_review`. diff --git a/docs/rag/workflows/task-claiming.md b/docs/rag/workflows/task-claiming.md index 665f034d..6de27dd9 100644 --- a/docs/rag/workflows/task-claiming.md +++ b/docs/rag/workflows/task-claiming.md @@ -16,9 +16,9 @@ give_me_work() # 2. Claim it. The claim verb is role-specific: -i_will_work_on(task_id) # Developer — claims + auto-creates the branch -claim_review(task_id) # QA — claims + auto-checks-out the dev's branch -claim_doc_task(task_id) # Documenter +i_will_work_on(task_id) # Developer — claims + auto-creates the branch +claim_review(task_id) # QA — claims + auto-checks-out the dev's branch +claim_doc_task(task_id) # Documenter # Result: # - status: claimed (then in_progress) diff --git a/roboco/api/deps.py b/roboco/api/deps.py index 00236d0a..596178e3 100644 --- a/roboco/api/deps.py +++ b/roboco/api/deps.py @@ -136,6 +136,7 @@ OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)] async def get_current_agent_id( + *, db: DbSession, response: Response, x_agent_id: Annotated[str | None, Header()] = None, @@ -150,13 +151,13 @@ async def get_current_agent_id( spoof and is rejected (see _cloud_auth_agent_context).""" if settings.cloud_auth_enabled: ctx = await _cloud_auth_agent_context( - db, - response, - x_agent_id, - x_agent_role, - x_agent_team, - x_agent_token, - roboco_session, + db=db, + response=response, + x_agent_id=x_agent_id, + x_agent_role=x_agent_role, + x_agent_team=x_agent_team, + x_agent_token=x_agent_token, + session_cookie=roboco_session, ) return ctx.agent_id if not x_agent_id: @@ -172,6 +173,7 @@ CurrentAgentId = Annotated[UUID, Depends(get_current_agent_id)] async def get_current_agent_slug( + *, db: DbSession, response: Response, x_agent_id: Annotated[str | None, Header()] = None, @@ -185,13 +187,13 @@ async def get_current_agent_slug( slug; a CEO cookie resolves to 'ceo').""" if settings.cloud_auth_enabled: ctx = await _cloud_auth_agent_context( - db, - response, - x_agent_id, - x_agent_role, - x_agent_team, - x_agent_token, - roboco_session, + db=db, + response=response, + x_agent_id=x_agent_id, + x_agent_role=x_agent_role, + x_agent_team=x_agent_team, + x_agent_token=x_agent_token, + session_cookie=roboco_session, ) assert ctx.slug is not None # cloud-auth ctx always carries a slug return ctx.slug @@ -483,6 +485,7 @@ def _should_remint(token: str) -> bool: async def _cloud_auth_agent_context( + *, db: AsyncSession, response: Response, x_agent_id: str | None, @@ -545,6 +548,7 @@ async def _cloud_auth_agent_context( async def get_agent_context( + *, db: DbSession, response: Response, x_agent_id: Annotated[str | None, Header()] = None, @@ -574,13 +578,13 @@ async def get_agent_context( db, x_agent_id, x_agent_role, x_agent_team, x_agent_token ) return await _cloud_auth_agent_context( - db, - response, - x_agent_id, - x_agent_role, - x_agent_team, - x_agent_token, - roboco_session, + db=db, + response=response, + x_agent_id=x_agent_id, + x_agent_role=x_agent_role, + x_agent_team=x_agent_team, + x_agent_token=x_agent_token, + session_cookie=roboco_session, ) diff --git a/roboco/api/routes/git.py b/roboco/api/routes/git.py index f2d68594..0cae2be5 100644 --- a/roboco/api/routes/git.py +++ b/roboco/api/routes/git.py @@ -418,6 +418,7 @@ async def get_git_diff( @router.get("/file", response_model=GitFileContentResponse) async def get_git_file( + *, db: DbSession, agent: CurrentAgentContext, branch: str = Query(..., description="Task branch holding the file"), diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index a1ede854..4dde923b 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -728,6 +728,7 @@ async def list_tasks( @router.get("/summary", response_model=list[TaskSummaryResponse]) async def list_tasks_summary( + *, db: DbSession, agent: CurrentAgentContext, team: Team | None = None, diff --git a/roboco/api/routes/v1/_role_dep.py b/roboco/api/routes/v1/_role_dep.py index c9f7bc8f..36254e11 100644 --- a/roboco/api/routes/v1/_role_dep.py +++ b/roboco/api/routes/v1/_role_dep.py @@ -9,10 +9,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Annotated, Any, cast +import structlog from fastapi import Depends, Header, HTTPException, params, status from roboco.foundation.identity import Role +logger = structlog.get_logger(__name__) + if TYPE_CHECKING: from fastapi import Request @@ -108,4 +111,32 @@ def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]: cid = getattr(request.state, "correlation_id", None) if cid is not None and env.correlation_id is None: env.correlation_id = cid - return env.as_dict() + payload = env.as_dict() + _log_rejection(payload, request) + return payload + + +def _log_rejection(payload: dict[str, Any], request: Request) -> None: + """Log a rejected envelope's reason at the single wire chokepoint. + + Rejections used to leave NO server-side trace: the access log records + ``POST /api/v1/do/ 200`` (an error envelope is still a 200), the + envelope body is never logged, and there is no trace table — so a verb + an agent could not satisfy was indistinguishable in the logs from one + that succeeded. Four Board Programs died that way on 2026-07-25 and the + reason was unrecoverable after the fact. + """ + error = payload.get("error") + if not error: + return + logger.warning( + "verb rejected", + verb=request.url.path.rsplit("/", 1)[-1], + error=error, + detail=payload.get("message"), + remediate=payload.get("remediate"), + missing=payload.get("missing"), + agent_id=request.headers.get("X-Agent-ID"), + agent_role=request.headers.get("X-Agent-Role"), + task_id=payload.get("task_id"), + ) diff --git a/roboco/api/routes/v1/flow_dev.py b/roboco/api/routes/v1/flow_dev.py index 6edb8425..11a34b6f 100644 --- a/roboco/api/routes/v1/flow_dev.py +++ b/roboco/api/routes/v1/flow_dev.py @@ -62,9 +62,9 @@ async def i_will_work_on( choreographer: _ChoreographerDep, ) -> dict: env = await choreographer.i_will_work_on( - x_agent_id, - body.task_id, - body.plan, + agent_id=x_agent_id, + task_id=body.task_id, + plan=body.plan, steps=body.steps, technical_considerations=body.technical_considerations, risks=body.risks, diff --git a/roboco/eval/runner.py b/roboco/eval/runner.py index b54c02dd..d24a41ca 100644 --- a/roboco/eval/runner.py +++ b/roboco/eval/runner.py @@ -434,6 +434,7 @@ def _fast_forward_branch(stack: E2EStack, branch: str, *, onto: str) -> None: def _create_bench_task( + *, stack: E2EStack, project_id: UUID, dev_slug: str, @@ -1001,7 +1002,12 @@ class EvalRunner: _fast_forward_branch(env.stack, env.cell_branch, onto="master") started_at = datetime.now(UTC) task_id = _create_bench_task( - env.stack, env.project_id, dev_slug, fixture, env.team, env.cell_id + stack=env.stack, + project_id=env.project_id, + dev_slug=dev_slug, + fixture=fixture, + team=env.team, + parent_task_id=env.cell_id, ) spawner = self._make_spawner(env.stack) diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index f972a754..b844db0a 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -466,6 +466,7 @@ def commit(message: str, files: list[str] | None = None) -> dict[str, Any]: def note( + *, text: str, scope: str = "note", task_id: str | None = None, @@ -691,6 +692,7 @@ def propose_friction_fixes(items: list[dict[str, Any]]) -> dict[str, Any]: def propose_feature_spotlight( + *, feature_slug: str = "", feature_title: str = "", body: str = "", diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index 5a14082a..c9318573 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -538,6 +538,7 @@ def give_me_work() -> dict[str, Any]: def i_will_work_on( + *, task_id: str, plan: str | None = None, steps: list[dict[str, str]] | None = None, @@ -909,6 +910,7 @@ def waive_finding(finding_id: str, note: str) -> dict[str, Any]: def i_will_plan( + *, task_id: str, plan: str, approach: str = "", @@ -949,6 +951,7 @@ def i_will_plan( def delegate( + *, parent_task_id: str, title: str, description: str, diff --git a/roboco/models/README.md b/roboco/models/README.md index c43fb881..5984e518 100644 --- a/roboco/models/README.md +++ b/roboco/models/README.md @@ -95,67 +95,67 @@ in_progress ──► awaiting_pr_review ### TaskStatus (from `base.py`) ```python -BACKLOG = "backlog" # PM setup phase -PENDING = "pending" # Ready for work -CLAIMED = "claimed" # Agent claimed, not started -IN_PROGRESS = "in_progress" # Active work -BLOCKED = "blocked" # External blocker -PAUSED = "paused" # Temporary pause -VERIFYING = "verifying" # Self-verification -NEEDS_REVISION = "needs_revision" # QA/PM requested changes -AWAITING_QA = "awaiting_qa" # Ready for QA review +BACKLOG = "backlog" # PM setup phase +PENDING = "pending" # Ready for work +CLAIMED = "claimed" # Agent claimed, not started +IN_PROGRESS = "in_progress" # Active work +BLOCKED = "blocked" # External blocker +PAUSED = "paused" # Temporary pause +VERIFYING = "verifying" # Self-verification +NEEDS_REVISION = "needs_revision" # QA/PM requested changes +AWAITING_QA = "awaiting_qa" # Ready for QA review AWAITING_DOCUMENTATION = "awaiting_documentation" # Ready for docs -AWAITING_PM_REVIEW = "awaiting_pm_review" # Ready for PM review -AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # Major task, CEO decides -COMPLETED = "completed" # Done -CANCELLED = "cancelled" # Cancelled +AWAITING_PM_REVIEW = "awaiting_pm_review" # Ready for PM review +AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" # Major task, CEO decides +COMPLETED = "completed" # Done +CANCELLED = "cancelled" # Cancelled ``` ### AgentRole ```python -CEO = "ceo" # Executive (Human) -PRODUCT_OWNER = "product_owner" # Board -HEAD_MARKETING = "head_marketing" # Board -AUDITOR = "auditor" # Board (Silent observer) -MAIN_PM = "main_pm" # Management (coordinates all cells) -CELL_PM = "cell_pm" # Cell management -DEVELOPER = "developer" # Cell member -QA = "qa" # Cell member -DOCUMENTER = "documenter" # Cell member +CEO = "ceo" # Executive (Human) +PRODUCT_OWNER = "product_owner" # Board +HEAD_MARKETING = "head_marketing" # Board +AUDITOR = "auditor" # Board (Silent observer) +MAIN_PM = "main_pm" # Management (coordinates all cells) +CELL_PM = "cell_pm" # Cell management +DEVELOPER = "developer" # Cell member +QA = "qa" # Cell member +DOCUMENTER = "documenter" # Cell member ``` ### Team ```python -BACKEND = "backend" # Backend cell -FRONTEND = "frontend" # Frontend cell -UX_UI = "ux_ui" # UX/UI cell -BOARD = "board" # Board level (no cell) +BACKEND = "backend" # Backend cell +FRONTEND = "frontend" # Frontend cell +UX_UI = "ux_ui" # UX/UI cell +BOARD = "board" # Board level (no cell) ``` ### WorkSessionStatus (from `work_session.py`) ```python -ACTIVE = "active" # Work in progress -COMPLETED = "completed" # PR merged -ABANDONED = "abandoned" # Session cancelled +ACTIVE = "active" # Work in progress +COMPLETED = "completed" # PR merged +ABANDONED = "abandoned" # Session cancelled ``` ### TaskType (from `base.py`) ```python -CODE = "code" # Source code changes -DOCUMENTATION = "documentation" # Documentation updates -RESEARCH = "research" # Research findings committed as notes -PLANNING = "planning" # Plans/architecture committed as docs -DESIGN = "design" # Designs/specs committed as assets -ADMINISTRATIVE = "administrative" # Process docs committed +CODE = "code" # Source code changes +DOCUMENTATION = "documentation" # Documentation updates +RESEARCH = "research" # Research findings committed as notes +PLANNING = "planning" # Plans/architecture committed as docs +DESIGN = "design" # Designs/specs committed as assets +ADMINISTRATIVE = "administrative" # Process docs committed ``` ### BranchReason (from `project.py`) ```python -FEATURE = "feature" # New functionality -BUG = "bug" # Bug fixes -CHORE = "chore" # Maintenance -DOCS = "docs" # Documentation -HOTFIX = "hotfix" # Emergency fixes +FEATURE = "feature" # New functionality +BUG = "bug" # Bug fixes +CHORE = "chore" # Maintenance +DOCS = "docs" # Documentation +HOTFIX = "hotfix" # Emergency fixes ``` ## Key Models @@ -173,7 +173,9 @@ class Task: assigned_to: UUID | None # Task Type - task_type: TaskType # code, documentation, research, planning, design, administrative + task_type: ( + TaskType # code, documentation, research, planning, design, administrative + ) # Project & Branch (all tasks follow git workflow, branch auto-created on claim) project_id: UUID @@ -181,33 +183,33 @@ class Task: work_session_id: UUID | None # PR Tracking (set during AWAITING_DOCUMENTATION parallel phase) - pr_number: int | None # GitHub/GitLab PR number - pr_url: str | None # Full URL to PR + pr_number: int | None # GitHub/GitLab PR number + pr_url: str | None # Full URL to PR # Parallel Execution Tracking (for AWAITING_DOCUMENTATION phase) - docs_complete: bool # Documenter has finished - pr_created: bool # Developer has created PR + docs_complete: bool # Documenter has finished + pr_created: bool # Developer has created PR # PM Approval Tracking - pm_approvals: dict[str, bool] # {'main_pm': True, 'cell_pm': True} + pm_approvals: dict[str, bool] # {'main_pm': True, 'cell_pm': True} # Planning plan: TaskPlan | None estimated_complexity: Complexity # Execution tracking - commits: list[CommitRef] # Linked git commits - checkpoints: list[Checkpoint] # Recovery points + commits: list[CommitRef] # Linked git commits + checkpoints: list[Checkpoint] # Recovery points progress_updates: list[ProgressUpdate] # Documentation Notes - dev_notes: str | None # Journey notes from developer - qa_notes: str | None # QA feedback - auditor_notes: str | None # Auditor observations - quick_context: str | None # 2-3 sentences for quick context restoration + dev_notes: str | None # Journey notes from developer + qa_notes: str | None # QA feedback + auditor_notes: str | None # Auditor observations + quick_context: str | None # 2-3 sentences for quick context restoration # Proactive Knowledge Context (injected when task is claimed) - proactive_context: dict | None # RAG context: similar tasks, learnings, patterns + proactive_context: dict | None # RAG context: similar tasks, learnings, patterns ``` ### Project (`project.py`) @@ -215,24 +217,24 @@ class Task: class Project: id: UUID name: str - slug: str # URL-safe identifier (e.g., 'roboco', 'roboco-panel') - git_url: str # Git repository URL - default_branch: str # e.g., "main" + slug: str # URL-safe identifier (e.g., 'roboco', 'roboco-panel') + git_url: str # Git repository URL + default_branch: str # e.g., "main" protected_branches: list[str] # Cannot push directly # CI/CD commands - test_command: str | None # e.g., 'uv run pytest' - lint_command: str | None # e.g., 'uv run ruff check .' - format_command: str | None # e.g., 'uv run ruff format .' + test_command: str | None # e.g., 'uv run pytest' + lint_command: str | None # e.g., 'uv run ruff check .' + format_command: str | None # e.g., 'uv run ruff format .' typecheck_command: str | None # e.g., 'uv run mypy src/' - build_command: str | None # e.g., 'pnpm build' + build_command: str | None # e.g., 'pnpm build' # Access control assigned_cell: Team allowed_agents: list[UUID] | None # None = all agents in cell # Runtime State (managed by workspace service) - workspace_path: str | None # Legacy: now use WorkspaceService + workspace_path: str | None # Legacy: now use WorkspaceService last_synced_at: datetime | None head_commit: str | None @@ -255,13 +257,13 @@ class WorkSession: target_branch: str # Audit trail - commits: list[str] # Commit SHAs - files_modified: list[str] # Changed files + commits: list[str] # Commit SHAs + files_modified: list[str] # Changed files # PR tracking pr_number: int | None pr_url: str | None - pr_status: str | None # open, merged, closed + pr_status: str | None # open, merged, closed pr_created_at: datetime | None pr_merged_at: datetime | None merged_by: UUID | None diff --git a/roboco/services/board_programs.py b/roboco/services/board_programs.py index 4c2674c9..041a9e1d 100644 --- a/roboco/services/board_programs.py +++ b/roboco/services/board_programs.py @@ -24,7 +24,7 @@ replaces. from __future__ import annotations from datetime import UTC, datetime -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import func, select @@ -253,6 +253,25 @@ _METRIC_PREDICATES: dict[str, Callable[[AsyncSession], Awaitable[bool]]] = { } +def learn_ref(item: dict[str, Any], limit: int = 80) -> str: + """The ``item_ref`` a per-item approve/reject records for LEARN. + + The item's own title, because the ref's only consumer is + ``_render_cycle``, whose output goes into the NEXT cycle's exploration + prompt. Passing the stored ``id`` instead (``item-0``/``item-1``, a + per-cycle index — see ``_normalize_roadmap_item``) rendered + "rejected: item-1 — ": the CEO's reason survived, but nothing + said which proposal it was about, and the index means something + different in every cycle. Falls back to the id when a title is missing. + + ``target_task_title`` covers Scales, whose items name the live task they + mutate rather than carrying a draft title of their own. + """ + title = str(item.get("title") or item.get("target_task_title") or "").strip() + ref = title or str(item.get("id") or "") + return ref[:limit].rstrip() if len(ref) > limit else ref + + def _legacy_enabled(key: str) -> bool: """The pre-registry flag(s) each program aliases while both exist.""" if key == "roadmap": diff --git a/roboco/services/dogfood_service.py b/roboco/services/dogfood_service.py index 8032512a..c727373e 100644 --- a/roboco/services/dogfood_service.py +++ b/roboco/services/dogfood_service.py @@ -23,6 +23,7 @@ from roboco.foundation.policy.board_programs import PROGRAMS, project_participat from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import DOGFOOD_ITEM_SOURCE, DOGFOOD_SOURCE if TYPE_CHECKING: @@ -101,7 +102,7 @@ class DogfoodService(BaseService): item["materialized_task_id"] = str(new_task.id) markers.set_friction_fixes(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return FrictionFixItemResult( status="approved", @@ -140,7 +141,7 @@ class DogfoodService(BaseService): item["reject_reason"] = reason markers.set_friction_fixes(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return FrictionFixItemResult( status="rejected", @@ -230,7 +231,7 @@ class DogfoodService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors ``SpackleService._record_learn``.""" @@ -239,7 +240,7 @@ class DogfoodService(BaseService): await get_board_program_engine(self.session).record_decision( "dogfood", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/roboco/services/forge/gitlab.py b/roboco/services/forge/gitlab.py index 5e2770c9..83bd4242 100644 --- a/roboco/services/forge/gitlab.py +++ b/roboco/services/forge/gitlab.py @@ -641,7 +641,7 @@ class GitLabProvider(GitProvider): headers: dict[str, str], client: httpx.AsyncClient | None, timeout: float | None, - ) -> int | None | httpx.Response | ShapedResponse: + ) -> int | httpx.Response | ShapedResponse | None: """The group's numeric id, ``None`` for a personal (404) namespace, or the raw error response for anything else.""" group_resp = await self._send( diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 184913ba..8923954d 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -511,6 +511,7 @@ class Choreographer: async def _handle_pm_reentry( self, + *, ctx: _ClaimPlanStartContext, t: Any, pm_agent_id: UUID, @@ -1707,6 +1708,7 @@ class Choreographer: async def i_will_work_on( self, + *, agent_id: UUID, task_id: UUID, plan: str | None = None, @@ -1784,24 +1786,30 @@ class Choreographer: verb_name="i_will_work_on", ) if reentry := await self._dev_reentry( - ctx, t, agent_id, task_id, role_str, briefing + ctx=ctx, + t=t, + agent_id=agent_id, + task_id=task_id, + role_str=role_str, + briefing=briefing, ): return reentry return await self._fresh_dev_claim( - ctx, - role, - spec_ctx, - agent, - rich_plan, - role_str, - t, - agent_id, - task_id, - briefing, + ctx=ctx, + role=role, + spec_ctx=spec_ctx, + agent=agent, + rich_plan=rich_plan, + role_str=role_str, + t=t, + agent_id=agent_id, + task_id=task_id, + briefing=briefing, ) async def _dev_reentry( self, + *, ctx: _ClaimPlanStartContext, t: Any, agent_id: UUID, @@ -1838,6 +1846,7 @@ class Choreographer: async def _fresh_dev_claim( self, + *, ctx: _ClaimPlanStartContext, role: Any, spec_ctx: Any, @@ -2019,7 +2028,12 @@ class Choreographer: original_developer_slug=_extract_original_developer(t), ) if rejection := self._open_pr_preflight_rejection( - agent_id, task_id, t, role_str, briefing, spec_ctx + agent_id=agent_id, + task_id=task_id, + t=t, + role_str=role_str, + briefing=briefing, + spec_ctx=spec_ctx, ): return await self._emit_rejection( rejection, agent_id=agent_id, task_id=task_id, verb="open_pr" @@ -2041,6 +2055,7 @@ class Choreographer: def _open_pr_preflight_rejection( self, + *, agent_id: UUID, task_id: UUID, t: Any, @@ -3811,6 +3826,7 @@ class Choreographer: async def _run_i_am_blocked_intent( self, + *, agent_id: UUID, task_id: UUID, t: Any, @@ -3877,6 +3893,7 @@ class Choreographer: async def _handle_rate_limited_parking( self, + *, agent_id: UUID, task_id: UUID, t: Any, @@ -4065,7 +4082,13 @@ class Choreographer: ) t, rejection = await self._run_i_am_blocked_intent( - agent_id, task_id, t, agent, spec_ctx, role_str, briefing + agent_id=agent_id, + task_id=task_id, + t=t, + agent=agent, + spec_ctx=spec_ctx, + role_str=role_str, + briefing=briefing, ) if rejection is not None: return rejection @@ -4614,7 +4637,12 @@ class Choreographer: # protected-base. Returns the rejection (None when all clear) AND the # resolved base_branch the git op needs (empty when rejected). rejection, base_branch = await self._sync_branch_preflight_rejection( - agent_id, task_id, t, agent, role_str, briefing + agent_id=agent_id, + task_id=task_id, + t=t, + agent=agent, + role_str=role_str, + briefing=briefing, ) if rejection is not None: return await self._emit_rejection( @@ -4707,6 +4735,7 @@ class Choreographer: async def _sync_branch_preflight_rejection( self, + *, agent_id: UUID, task_id: UUID, t: Any, @@ -5279,7 +5308,12 @@ class Choreographer: # Re-entry check runs first — a respawned PM with thin args ("resume", # no sub_tasks) must short-circuit here before any gate. if reentry := await self._handle_pm_reentry( - ctx, t, pm_agent_id, task_id, role_str, briefing + ctx=ctx, + t=t, + pm_agent_id=pm_agent_id, + task_id=task_id, + role_str=role_str, + briefing=briefing, ): return reentry # Lifecycle spec gate runs BEFORE the sub_tasks gate so wrong-state @@ -7485,7 +7519,12 @@ class Choreographer: # instead of letting the failure re-block the task and # respawn the PM forever. return await self._resolve_merge_conflict_on_complete( - pm_agent_id, task_id, t, target, notes, exc + pm_agent_id=pm_agent_id, + task_id=task_id, + t=t, + target=target, + notes=notes, + exc=exc, ) return await self._finalize_cell_complete( pm_agent_id, task_id, t, notes, merge_commit @@ -7563,6 +7602,7 @@ class Choreographer: async def _resolve_merge_conflict_on_complete( self, + *, pm_agent_id: UUID, task_id: UUID, t: Any, @@ -8466,7 +8506,13 @@ class Choreographer: # check — the ledger ids don't exist until after insert, below. notes = findings_lib.render_findings_summary([(None, f) for f in validated]) rejection, spec_gate = await self._request_changes_spec_gate( - pm_agent_id, task_id, t, agent, role_str, notes, [] + pm_agent_id=pm_agent_id, + task_id=task_id, + t=t, + agent=agent, + role_str=role_str, + notes=notes, + issues=[], ) if rejection is not None: return rejection @@ -8510,6 +8556,7 @@ class Choreographer: async def _request_changes_spec_gate( self, + *, pm_agent_id: UUID, task_id: UUID, t: Any, diff --git a/roboco/services/gateway/choreographer/doc.py b/roboco/services/gateway/choreographer/doc.py index dfef9c03..2eef8157 100644 --- a/roboco/services/gateway/choreographer/doc.py +++ b/roboco/services/gateway/choreographer/doc.py @@ -484,18 +484,19 @@ class DocMixin(_Base): return push_rejection return await self._finalize_documented( - doc_agent_id, - task_id, - files, - owned_task, - agent, - role_str, - spec_ctx, - briefing, + doc_agent_id=doc_agent_id, + task_id=task_id, + files=files, + owned_task=owned_task, + agent=agent, + role_str=role_str, + spec_ctx=spec_ctx, + briefing=briefing, ) async def _finalize_documented( self, + *, doc_agent_id: UUID, task_id: UUID, files: list[str], diff --git a/roboco/services/gateway/choreographer/pr_gate.py b/roboco/services/gateway/choreographer/pr_gate.py index fdf0d720..6ab3b8e9 100644 --- a/roboco/services/gateway/choreographer/pr_gate.py +++ b/roboco/services/gateway/choreographer/pr_gate.py @@ -118,7 +118,12 @@ class PRGateMixin(_Base): role_str = self._role_str_for_agent(agent) briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True) role = await self._gate_role_or_rejection( - t, role_str, briefing, reviewer_agent_id, task_id, "claim_gate_review" + t=t, + role_str=role_str, + briefing=briefing, + agent_id=reviewer_agent_id, + task_id=task_id, + verb="claim_gate_review", ) if isinstance(role, Envelope): return role @@ -242,7 +247,12 @@ class PRGateMixin(_Base): role_str = self._role_str_for_agent(agent) briefing = await self._briefing_for(reviewer_agent_id, task_id) role = await self._gate_role_or_rejection( - t, role_str, briefing, reviewer_agent_id, task_id, verb + t=t, + role_str=role_str, + briefing=briefing, + agent_id=reviewer_agent_id, + task_id=task_id, + verb=verb, ) if isinstance(role, Envelope): return role @@ -1095,6 +1105,7 @@ class PRGateMixin(_Base): async def _gate_role_or_rejection( self, + *, t: Any, role_str: str, briefing: dict[str, Any], diff --git a/roboco/services/gateway/choreographer/pr_review.py b/roboco/services/gateway/choreographer/pr_review.py index f7e9fdb2..9663b70a 100644 --- a/roboco/services/gateway/choreographer/pr_review.py +++ b/roboco/services/gateway/choreographer/pr_review.py @@ -66,7 +66,12 @@ class PRReviewerMixin(_Base): role_str = str(agent.role) if agent is not None else "pr_reviewer" briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True) role_or_rejection = await self._resolve_role( - t, role_str, briefing, reviewer_agent_id, task_id, "claim_pr_review" + t=t, + role_str=role_str, + briefing=briefing, + agent_id=reviewer_agent_id, + task_id=task_id, + verb="claim_pr_review", ) if isinstance(role_or_rejection, Envelope): return role_or_rejection @@ -187,6 +192,7 @@ class PRReviewerMixin(_Base): async def _post_review_side_effects( self, + *, t: Any, slug: str | None, pr_number: int | None, @@ -280,12 +286,23 @@ class PRReviewerMixin(_Base): t = await runner.run_intent("post_pr_review", t, agent, spec_ctx) except Exception as exc: return await self._runner_failure( - exc, t, role_str, briefing, reviewer_agent_id, task_id, "post_pr_review" + exc=exc, + t=t, + role_str=role_str, + briefing=briefing, + agent_id=reviewer_agent_id, + task_id=task_id, + verb="post_pr_review", ) # Side-effects AFTER the DB transition (a2a.send pattern), both best- # effort: post the canonical review to GitHub + surface it to the CEO. await self._post_review_side_effects( - t, slug, pr_number, post_body, event, task_id + t=t, + slug=slug, + pr_number=pr_number, + post_body=post_body, + event=event, + task_id=task_id, ) return Envelope.ok( status=str(t.status), @@ -322,7 +339,12 @@ class PRReviewerMixin(_Base): verb="post_pr_review", ) role_or_rejection = await self._resolve_role( - t, role_str, briefing, reviewer_agent_id, task_id, "post_pr_review" + t=t, + role_str=role_str, + briefing=briefing, + agent_id=reviewer_agent_id, + task_id=task_id, + verb="post_pr_review", ) if isinstance(role_or_rejection, Envelope): return role_or_rejection @@ -451,6 +473,7 @@ class PRReviewerMixin(_Base): async def _resolve_role( self, + *, t: Any, role_str: str, briefing: dict[str, Any], @@ -475,6 +498,7 @@ class PRReviewerMixin(_Base): async def _runner_failure( self, + *, exc: Exception, t: Any, role_str: str, diff --git a/roboco/services/gateway/evidence_builder.py b/roboco/services/gateway/evidence_builder.py index 6fa9458a..132757eb 100644 --- a/roboco/services/gateway/evidence_builder.py +++ b/roboco/services/gateway/evidence_builder.py @@ -216,6 +216,7 @@ def _typed(value: Any, expected: type | tuple[type, ...], default: Any) -> Any: def _has_prior_work( + *, commits: list, acceptance: list, highlights: list, @@ -292,18 +293,18 @@ def build_task_handoff( pm_review = _extract_pm_review(notes_structured) open_findings = list(open_findings or []) if not _has_prior_work( - commits, - acceptance, - highlights, - pr_number, - dev_summary, - completed_deps, - pr_review, - qa_review, - pm_review, - open_findings, - description, - parent_context, + commits=commits, + acceptance=acceptance, + highlights=highlights, + pr_number=pr_number, + dev_summary=dev_summary, + completed_deps=completed_deps, + pr_review=pr_review, + qa_review=qa_review, + pm_review=pm_review, + open_findings=open_findings, + description=description, + parent_context=parent_context, ): return None handoff: dict[str, Any] = { diff --git a/roboco/services/mirror_service.py b/roboco/services/mirror_service.py index 84d6233a..896c3888 100644 --- a/roboco/services/mirror_service.py +++ b/roboco/services/mirror_service.py @@ -24,6 +24,7 @@ from roboco.foundation.policy.board_programs import PROGRAMS, project_participat from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import MIRROR_ITEM_SOURCE, MIRROR_SOURCE if TYPE_CHECKING: @@ -102,7 +103,7 @@ class MirrorService(BaseService): item["materialized_task_id"] = str(new_task.id) markers.set_messaging_fixes(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return MessagingFixItemResult( status="approved", @@ -141,7 +142,7 @@ class MirrorService(BaseService): item["reject_reason"] = reason markers.set_messaging_fixes(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return MessagingFixItemResult( status="rejected", @@ -231,7 +232,7 @@ class MirrorService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors ``SpackleService._record_learn``.""" @@ -240,7 +241,7 @@ class MirrorService(BaseService): await get_board_program_engine(self.session).record_decision( "mirror", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/roboco/services/notification.py b/roboco/services/notification.py index 2d786e8f..ac92fcf8 100644 --- a/roboco/services/notification.py +++ b/roboco/services/notification.py @@ -170,6 +170,7 @@ class NotificationService: async def send_oscillation_blocked_notification( self, + *, task_id: str, strikes: int, escalator: str | UUID | None, @@ -422,6 +423,7 @@ class NotificationService: async def send_external_pr_reviewed_notification( self, + *, task_id: str, pr_number: int, pr_url: str, @@ -466,6 +468,7 @@ class NotificationService: async def send_reassignment_notification( self, + *, task_id: str, previous_assignee: str | None, new_assignee: str | None, @@ -513,6 +516,7 @@ class NotificationService: async def send_collision_sequencing_notification( self, + *, held_back_task_id: str, blocking_task_id: str, held_back_assignee: str | None, @@ -557,6 +561,7 @@ class NotificationService: async def send_unblock_notification( self, + *, task_id: str, restored_owner: str | None, from_agent: str | None = None, @@ -600,6 +605,7 @@ class NotificationService: async def send_dependency_revival_notification( self, + *, task_id: str, assignee: str | None, completed_dependency_id: str, @@ -654,6 +660,7 @@ class NotificationService: async def send_stale_claim_reaped_notification( self, + *, task_id: str, reaped_agent: str | None, last_heartbeat: str | None = None, diff --git a/roboco/services/pest_control_service.py b/roboco/services/pest_control_service.py index 41752510..02baef16 100644 --- a/roboco/services/pest_control_service.py +++ b/roboco/services/pest_control_service.py @@ -25,6 +25,7 @@ from roboco.foundation.policy.board_programs import PROGRAMS, project_participat from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import PEST_CONTROL_ITEM_SOURCE, PEST_CONTROL_SOURCE if TYPE_CHECKING: @@ -103,7 +104,7 @@ class PestControlService(BaseService): item["materialized_task_id"] = str(new_task.id) markers.set_pest_hunt(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return PestHuntItemResult( status="approved", @@ -142,7 +143,7 @@ class PestControlService(BaseService): item["reject_reason"] = reason markers.set_pest_hunt(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return PestHuntItemResult( status="rejected", @@ -231,7 +232,7 @@ class PestControlService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors ``RoadmapService._record_learn``.""" @@ -240,7 +241,7 @@ class PestControlService(BaseService): await get_board_program_engine(self.session).record_decision( "pest_control", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/roboco/services/prompter.py b/roboco/services/prompter.py index 459d8863..e9501b69 100644 --- a/roboco/services/prompter.py +++ b/roboco/services/prompter.py @@ -957,6 +957,7 @@ class PrompterService: async def _rewrite_batch_children( self, + *, umbrella: TaskTable, drafts: list[dict[str, Any]], children: list[TaskTable], @@ -1063,7 +1064,12 @@ class PrompterService: plan = self._sequence_drafts(drafts) wave_of = {idx: w for w, wave in enumerate(plan.waves) for idx in wave} task_of = await self._rewrite_batch_children( - umbrella, drafts, children, wave_of, agent_id, agent_role + umbrella=umbrella, + drafts=drafts, + children=children, + wave_of=wave_of, + agent_id=agent_id, + agent_role=agent_role, ) for a, b in plan.edges: await task_service.add_dependency(task_of[b], task_of[a]) diff --git a/roboco/services/roadmap_service.py b/roboco/services/roadmap_service.py index c1888d40..fcee2999 100644 --- a/roboco/services/roadmap_service.py +++ b/roboco/services/roadmap_service.py @@ -22,6 +22,7 @@ from roboco.foundation.policy.board_programs import PROGRAMS, project_participat from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import ROADMAP_ITEM_SOURCE, ROADMAP_SOURCE, get_task_service if TYPE_CHECKING: @@ -98,7 +99,7 @@ class RoadmapService(BaseService): item["materialized_task_id"] = str(new_task.id) markers.set_roadmap_cycle(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return RoadmapItemResult( status="approved", @@ -137,7 +138,7 @@ class RoadmapService(BaseService): item["reject_reason"] = reason markers.set_roadmap_cycle(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return RoadmapItemResult( status="rejected", @@ -228,7 +229,7 @@ class RoadmapService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors the vault-writer best-effort seams. @@ -242,7 +243,7 @@ class RoadmapService(BaseService): await get_board_program_engine(self.session).record_decision( "roadmap", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/roboco/services/scales_service.py b/roboco/services/scales_service.py index 8acb6019..27818b9c 100644 --- a/roboco/services/scales_service.py +++ b/roboco/services/scales_service.py @@ -31,6 +31,7 @@ from uuid import UUID from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import SCALES_SOURCE if TYPE_CHECKING: @@ -107,7 +108,7 @@ class ScalesService(BaseService): item["executed_detail"] = executed_detail markers.set_rebalance_plan(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return RebalanceItemResult( status="approved", @@ -146,7 +147,7 @@ class ScalesService(BaseService): item["reject_reason"] = reason markers.set_rebalance_plan(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return RebalanceItemResult( status="rejected", @@ -263,7 +264,7 @@ class ScalesService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors ``PestControlService._record_learn``.""" @@ -272,7 +273,7 @@ class ScalesService(BaseService): await get_board_program_engine(self.session).record_decision( "scales", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/roboco/services/spackle_service.py b/roboco/services/spackle_service.py index b19f20a7..bd52bace 100644 --- a/roboco/services/spackle_service.py +++ b/roboco/services/spackle_service.py @@ -23,6 +23,7 @@ from roboco.foundation.policy.board_programs import PROGRAMS, project_participat from roboco.foundation.policy.content import markers from roboco.models.base import TaskStatus from roboco.services.base import BaseService +from roboco.services.board_programs import learn_ref from roboco.services.task import SPACKLE_ITEM_SOURCE, SPACKLE_SOURCE if TYPE_CHECKING: @@ -101,7 +102,7 @@ class SpackleService(BaseService): item["materialized_task_id"] = str(new_task.id) markers.set_gap_fill(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "approved") + await self._record_learn(task, learn_ref(item), "approved") await self.session.flush() return GapFillItemResult( status="approved", @@ -140,7 +141,7 @@ class SpackleService(BaseService): item["reject_reason"] = reason markers.set_gap_fill(task, payload) self._maybe_complete_cycle(task, payload) - await self._record_learn(task, item_id, "rejected", reason) + await self._record_learn(task, learn_ref(item), "rejected", reason) await self.session.flush() return GapFillItemResult( status="rejected", @@ -229,7 +230,7 @@ class SpackleService(BaseService): ) async def _record_learn( - self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None + self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None ) -> None: """Best-effort LEARN: a record_decision failure must never break the CEO's approve/reject — mirrors ``PestControlService._record_learn``.""" @@ -238,7 +239,7 @@ class SpackleService(BaseService): await get_board_program_engine(self.session).record_decision( "spackle", - item_id, + item_ref, verdict, reason, exploration_task_id=cast("UUID", task.id), diff --git a/tests/foundation/test_lifecycle_consumer_parity.py b/tests/foundation/test_lifecycle_consumer_parity.py index dababc48..23930ad2 100644 --- a/tests/foundation/test_lifecycle_consumer_parity.py +++ b/tests/foundation/test_lifecycle_consumer_parity.py @@ -127,7 +127,7 @@ async def test_i_will_work_on_matches_spec( # Per-role claim authority (CLAIM_RULES) is now enforced inside # spec.can_invoke_action when action == "claim", dispatched by # can_invoke_intent. The verb's single gate is can_invoke_intent. - env = await c.i_will_work_on(agent_id, task_id, plan="my plan") + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, plan="my plan") body = env.as_dict() if expected.allowed: # Verb may still fail downstream of the gate (e.g. claim() returns diff --git a/tests/integration/test_full_lifecycle_real_db.py b/tests/integration/test_full_lifecycle_real_db.py index 292c3724..9e21948a 100644 --- a/tests/integration/test_full_lifecycle_real_db.py +++ b/tests/integration/test_full_lifecycle_real_db.py @@ -352,8 +352,8 @@ async def test_dev_can_claim_pending_task_via_gateway( c = Choreographer(deps) env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -401,8 +401,8 @@ async def test_dev_full_chain_through_awaiting_qa( # 1. Claim env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -480,8 +480,8 @@ async def test_full_chain_through_doc_handoff( # Drive the dev side first (same as test_dev_full_chain_through_awaiting_qa). await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/integration/test_lifecycle_real_db.py b/tests/integration/test_lifecycle_real_db.py index 36779fbf..d871666d 100644 --- a/tests/integration/test_lifecycle_real_db.py +++ b/tests/integration/test_lifecycle_real_db.py @@ -420,8 +420,8 @@ async def test_dev_full_chain_through_awaiting_qa( c = Choreographer(deps) env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -906,8 +906,8 @@ async def test_block_then_unblock_restore( # Drive into in_progress via the real claim+start sequence. env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -969,8 +969,8 @@ async def test_pause_then_resume( c = _build_choreographer(db_session, task, task_service) env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/integration/test_revision_findings_real_db.py b/tests/integration/test_revision_findings_real_db.py index d69f5961..c44484cc 100644 --- a/tests/integration/test_revision_findings_real_db.py +++ b/tests/integration/test_revision_findings_real_db.py @@ -723,8 +723,8 @@ async def test_i_am_done_full_chain_blocks_then_resolves( # --- Round 1: no findings exist yet — i_am_done must pass untouched. --- env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -784,8 +784,8 @@ async def test_i_am_done_full_chain_blocks_then_resolves( # re-enforces the rich-plan gate for a developer — so the full plan is # supplied again here, same as the very first claim. --- env = await c.i_will_work_on( - dev_agent.id, - task.id, + agent_id=dev_agent.id, + task_id=task.id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/integration/v1/test_full_pending_to_completed.py b/tests/integration/v1/test_full_pending_to_completed.py index 47ee43f5..4cc7a70f 100644 --- a/tests/integration/v1/test_full_pending_to_completed.py +++ b/tests/integration/v1/test_full_pending_to_completed.py @@ -57,13 +57,7 @@ class _MockChoreographer: next="claim it", ) - async def i_will_work_on( - self, - _agent_id: object, - _task_id: object, - _plan: object = None, - **_kwargs: object, - ) -> Envelope: + async def i_will_work_on(self, **_kwargs: object) -> Envelope: self._state["task_status"] = "in_progress" return Envelope.ok( status="in_progress", diff --git a/tests/unit/api/routes/v1/test_flow_dev.py b/tests/unit/api/routes/v1/test_flow_dev.py index 051cf30b..2f2df1cc 100644 --- a/tests/unit/api/routes/v1/test_flow_dev.py +++ b/tests/unit/api/routes/v1/test_flow_dev.py @@ -77,9 +77,9 @@ async def test_i_will_work_on_dispatches_task_id() -> None: assert body["status"] == "in_progress" mock_chore.i_will_work_on.assert_awaited_once() call_args = mock_chore.i_will_work_on.call_args - # second positional arg is task_id (UUID), third is plan - assert str(call_args.args[1]) == _TASK_ID - assert call_args.args[2] == "implement the feature" + # the verb's params are keyword-only past agent_id, so read the kwargs + assert str(call_args.kwargs["task_id"]) == _TASK_ID + assert call_args.kwargs["plan"] == "implement the feature" @pytest.mark.asyncio diff --git a/tests/unit/api/routes/v1/test_verb_rejection_logging.py b/tests/unit/api/routes/v1/test_verb_rejection_logging.py new file mode 100644 index 00000000..523f3433 --- /dev/null +++ b/tests/unit/api/routes/v1/test_verb_rejection_logging.py @@ -0,0 +1,60 @@ +"""A rejected envelope must leave a server-side trace. + +An error envelope rides a 200, so the access log cannot distinguish a verb an +agent could not satisfy from one that worked. Four Board Programs died that +way on 2026-07-25 with no recoverable reason. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from roboco.api.routes.v1._role_dep import envelope_to_response +from roboco.services.gateway.envelope import Envelope +from structlog.testing import capture_logs + + +def _request(path: str) -> MagicMock: + request = MagicMock() + request.url.path = path + request.state.correlation_id = "cid-1" + request.headers = {"X-Agent-ID": "agent-7", "X-Agent-Role": "head_marketing"} + return request + + +def _captured(env: Envelope, path: str) -> list[Any]: + """``capture_logs`` rather than a hand-rolled processor swap — it is + independent of whatever global structlog config the rest of the suite has + already installed, which made the swap pass alone and fail in-suite.""" + with capture_logs() as entries: + envelope_to_response(env=env, request=_request(path)) + return [e for e in entries if e.get("event") == "verb rejected"] + + +def test_rejection_logs_reason_and_remediation() -> None: + env = Envelope.invalid_state( + message="finding 0 is missing 'source_url' — an uncited market claim is noise", + remediate="provide the http(s) source URL finding 0's claim came from", + context_briefing={}, + ) + entries = _captured(env, "/api/v1/do/propose_market_brief") + + assert len(entries) == 1, entries + logged = entries[0] + assert logged["verb"] == "propose_market_brief" + assert logged["error"] + assert "source_url" in str(logged["detail"]) + assert "http(s) source URL" in str(logged["remediate"]) + assert logged["agent_id"] == "agent-7" + assert logged["agent_role"] == "head_marketing" + + +def test_success_envelope_logs_nothing() -> None: + env = Envelope.ok( + status="market_brief_proposed", + task_id="t-1", + next="i_am_idle()", + context_briefing={}, + ) + assert _captured(env, "/api/v1/do/propose_market_brief") == [] diff --git a/tests/unit/api/test_cloud_auth.py b/tests/unit/api/test_cloud_auth.py index c36b4e3a..9141b3e4 100644 --- a/tests/unit/api/test_cloud_auth.py +++ b/tests/unit/api/test_cloud_auth.py @@ -68,8 +68,8 @@ async def test_off_mode_ceo_header_spoof_still_works( new=AsyncMock(return_value=(aid, "ceo")), ): ctx = await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id=CEO_AGENT_ID, x_agent_role="ceo", ) @@ -89,8 +89,8 @@ async def test_off_mode_developer_header_trust_unchanged( new=AsyncMock(return_value=(aid, "be-dev-1")), ): ctx = await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id="be-dev-1", x_agent_role="developer", x_agent_team="backend", @@ -111,8 +111,8 @@ async def test_on_mode_spoofed_ceo_header_without_token_or_session_401( monkeypatch.setattr(settings, "cloud_auth_enabled", True) with pytest.raises(HTTPException) as exc: await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id=CEO_AGENT_ID, x_agent_role="ceo", ) @@ -127,7 +127,7 @@ async def test_on_mode_no_headers_no_cookie_401( 401s without a valid session — never silently falls back to anything.""" monkeypatch.setattr(settings, "cloud_auth_enabled", True) with pytest.raises(HTTPException) as exc: - await get_agent_context(MagicMock(), MagicMock()) + await get_agent_context(db=MagicMock(), response=MagicMock()) assert exc.value.status_code == _HTTP_401 @@ -154,9 +154,7 @@ async def test_on_mode_valid_session_yields_ceo_context_and_slides_cookie( response = Response() ctx = await get_agent_context( - db_session, - response, - roboco_session=token, + db=db_session, response=response, roboco_session=token ) assert ctx.role == AgentRole.CEO @@ -174,7 +172,9 @@ async def test_on_mode_invalid_session_cookie_401( ) -> None: monkeypatch.setattr(settings, "cloud_auth_enabled", True) with pytest.raises(HTTPException) as exc: - await get_agent_context(db_session, Response(), roboco_session="not-a-real-jwt") + await get_agent_context( + db=db_session, response=Response(), roboco_session="not-a-real-jwt" + ) assert exc.value.status_code == _HTTP_401 @@ -192,8 +192,8 @@ async def test_on_mode_agent_hmac_still_works( new=AsyncMock(return_value=(aid, "be-dev-1")), ): ctx = await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id=str(aid), x_agent_role="developer", x_agent_team="backend", @@ -212,8 +212,8 @@ async def test_on_mode_system_self_patch_still_works( monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET) token = issue_agent_token(_SYSTEM_AGENT_ID, "system", "") ctx = await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id=_SYSTEM_AGENT_ID, x_agent_role="system", x_agent_token=token, @@ -228,8 +228,8 @@ async def test_on_mode_forged_agent_token_401(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET) with pytest.raises(HTTPException) as exc: await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id="be-dev-1", x_agent_role="developer", x_agent_token="forged", @@ -248,8 +248,8 @@ async def test_on_mode_spoofed_non_ceo_role_without_token_401( monkeypatch.setattr(settings, "cloud_auth_enabled", True) with pytest.raises(HTTPException) as exc: await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id="main-pm", x_agent_role="main_pm", ) diff --git a/tests/unit/api/test_correlation_id.py b/tests/unit/api/test_correlation_id.py index 69f4909c..c304de41 100644 --- a/tests/unit/api/test_correlation_id.py +++ b/tests/unit/api/test_correlation_id.py @@ -228,7 +228,7 @@ def test_do_server_attaches_correlation_id_header( ) -> None: fake = _fake_client({"status": "noted"}) with patch("httpx.Client", return_value=fake): - do_module.note("hi") + do_module.note(text="hi") _args, kwargs = fake.post.call_args headers = kwargs["headers"] assert headers["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001" diff --git a/tests/unit/api/test_deps.py b/tests/unit/api/test_deps.py index fb249bc0..c0201767 100644 --- a/tests/unit/api/test_deps.py +++ b/tests/unit/api/test_deps.py @@ -113,7 +113,9 @@ def test_get_orchestrator_raises_503_when_unset() -> None: @pytest.mark.asyncio async def test_get_current_agent_id_raises_when_header_missing() -> None: with pytest.raises(HTTPException) as exc: - await get_current_agent_id(MagicMock(), MagicMock(), x_agent_id=None) + await get_current_agent_id( + db=MagicMock(), response=MagicMock(), x_agent_id=None + ) assert exc.value.status_code == _HTTP_401 @@ -125,7 +127,7 @@ async def test_get_current_agent_id_returns_uuid() -> None: new=AsyncMock(return_value=expected), ): out = await get_current_agent_id( - MagicMock(), MagicMock(), x_agent_id="be-dev-1" + db=MagicMock(), response=MagicMock(), x_agent_id="be-dev-1" ) assert out == expected @@ -133,13 +135,17 @@ async def test_get_current_agent_id_returns_uuid() -> None: @pytest.mark.asyncio async def test_get_current_agent_slug_raises_when_header_missing() -> None: with pytest.raises(HTTPException) as exc: - await get_current_agent_slug(MagicMock(), MagicMock(), x_agent_id=None) + await get_current_agent_slug( + db=MagicMock(), response=MagicMock(), x_agent_id=None + ) assert exc.value.status_code == _HTTP_401 @pytest.mark.asyncio async def test_get_current_agent_slug_returns_header() -> None: - out = await get_current_agent_slug(MagicMock(), MagicMock(), x_agent_id="be-dev-1") + out = await get_current_agent_slug( + db=MagicMock(), response=MagicMock(), x_agent_id="be-dev-1" + ) assert out == "be-dev-1" @@ -494,8 +500,8 @@ def test_coerce_agent_team_empty_returns_none() -> None: async def test_get_agent_context_missing_id_raises() -> None: with pytest.raises(HTTPException) as exc: await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id=None, x_agent_role="developer", ) @@ -506,8 +512,8 @@ async def test_get_agent_context_missing_id_raises() -> None: async def test_get_agent_context_missing_role_raises() -> None: with pytest.raises(HTTPException) as exc: await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id="be-dev-1", x_agent_role=None, ) @@ -523,8 +529,8 @@ async def test_get_agent_context_happy_path(monkeypatch: pytest.MonkeyPatch) -> new=AsyncMock(return_value=(aid, "be-dev-1")), ): ctx = await get_agent_context( - MagicMock(), - MagicMock(), + db=MagicMock(), + response=MagicMock(), x_agent_id="be-dev-1", x_agent_role="developer", x_agent_team="backend", diff --git a/tests/unit/gateway/test_audit_on_rejection.py b/tests/unit/gateway/test_audit_on_rejection.py index d4518f2e..7957e61f 100644 --- a/tests/unit/gateway/test_audit_on_rejection.py +++ b/tests/unit/gateway/test_audit_on_rejection.py @@ -95,7 +95,7 @@ async def test_pm_cannot_execute_code_writes_audit_row() -> None: deps = _make_deps(task=task_svc, audit=audit_svc) c = Choreographer(deps) - env = await c.i_will_work_on(aid, tid, plan="x") + env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x") assert env.error == "not_authorized" audit_svc.log_event.assert_awaited() @@ -215,7 +215,7 @@ async def test_rejection_remediate_lands_in_audit_details() -> None: deps = _make_deps(task=task_svc, audit=audit_svc) c = Choreographer(deps) - env = await c.i_will_work_on(aid, tid, plan="x") + env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x") assert env.error == "not_authorized" assert env.remediate diff --git a/tests/unit/gateway/test_budget_guard_scoped_to_work_claims.py b/tests/unit/gateway/test_budget_guard_scoped_to_work_claims.py index d74b111b..b74dc78a 100644 --- a/tests/unit/gateway/test_budget_guard_scoped_to_work_claims.py +++ b/tests/unit/gateway/test_budget_guard_scoped_to_work_claims.py @@ -239,7 +239,9 @@ async def test_i_will_work_on_refuses_at_cap() -> None: task_svc.project_month_spend_usd = AsyncMock(return_value=999.0) c = Choreographer(_make_deps(task_svc)) - env = await c.i_will_work_on(agent_id, task_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state", body assert "10.00" in body["message"] and "999.00" in body["message"] diff --git a/tests/unit/gateway/test_choreographer_claim_guards.py b/tests/unit/gateway/test_choreographer_claim_guards.py index 7ef68436..0ee951a9 100644 --- a/tests/unit/gateway/test_choreographer_claim_guards.py +++ b/tests/unit/gateway/test_choreographer_claim_guards.py @@ -131,7 +131,9 @@ async def test_i_will_work_on_blocks_when_agent_has_in_progress_task() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, target_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=target_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state" assert str(other_id) in body["message"] or str(other_id) in body["remediate"] @@ -165,7 +167,7 @@ async def test_i_will_work_on_resumption_does_not_self_block() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, steps=_STEPS) + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, steps=_STEPS) assert env.error is None task_svc.start.assert_awaited_once_with(task_id, agent_id) @@ -195,7 +197,9 @@ async def test_i_will_work_on_blocks_when_agent_has_paused_task() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, target_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=target_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state" assert str(paused_id) in body["remediate"] @@ -226,7 +230,9 @@ async def test_cell_pm_cannot_claim_code_task_via_i_will_work_on() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(pm_id, task_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=pm_id, task_id=task_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "not_authorized" # Spec produces "role 'cell_pm' may not call 'i_will_work_on'". @@ -253,7 +259,9 @@ async def test_main_pm_cannot_claim_code_task_via_i_will_work_on() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(pm_id, task_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=pm_id, task_id=task_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "not_authorized" @@ -403,7 +411,7 @@ async def test_developer_cannot_claim_qa_status_task() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(dev_id, task_id, steps=_STEPS) + env = await c.i_will_work_on(agent_id=dev_id, task_id=task_id, steps=_STEPS) body = env.as_dict() assert body["error"] == "not_authorized" assert "developer" in body["message"] @@ -494,7 +502,9 @@ async def test_non_developer_role_cannot_claim_via_i_will_work_on() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(doc_id, task_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=doc_id, task_id=task_id, plan="x", steps=_STEPS + ) body = env.as_dict() # Role-typed claim refuses with not_authorized assert body["error"] == "not_authorized" diff --git a/tests/unit/gateway/test_choreographer_claim_lock.py b/tests/unit/gateway/test_choreographer_claim_lock.py index ae63ec79..33aa462d 100644 --- a/tests/unit/gateway/test_choreographer_claim_lock.py +++ b/tests/unit/gateway/test_choreographer_claim_lock.py @@ -149,8 +149,8 @@ async def test_dev_claim_acquires_lock_before_guard_read() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/unit/gateway/test_choreographer_dev.py b/tests/unit/gateway/test_choreographer_dev.py index b1ad3731..43cf353d 100644 --- a/tests/unit/gateway/test_choreographer_dev.py +++ b/tests/unit/gateway/test_choreographer_dev.py @@ -167,8 +167,8 @@ async def test_i_will_work_on_pending_with_plan() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -213,7 +213,7 @@ async def test_i_will_work_on_pending_no_plan_returns_tracing_gap() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan=None) + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, plan=None) body = env.as_dict() assert body["error"] == "tracing_gap" assert "plan" in body["missing"] @@ -263,8 +263,8 @@ async def test_i_will_work_on_needs_revision_re_starts() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -283,7 +283,7 @@ async def test_i_will_work_on_task_not_found_returns_not_found() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id) + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id) body = env.as_dict() assert body["error"] == "not_found" @@ -316,7 +316,7 @@ async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id) + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id) body = env.as_dict() assert body["error"] == "invalid_state" # Spec produces "task is in 'completed', 'claim' requires: ..." @@ -376,8 +376,8 @@ async def test_i_will_work_on_blocks_when_journal_note_at_claim_missing() -> Non c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/unit/gateway/test_choreographer_impl_branches.py b/tests/unit/gateway/test_choreographer_impl_branches.py index 89826e02..8c36f1ef 100644 --- a/tests/unit/gateway/test_choreographer_impl_branches.py +++ b/tests/unit/gateway/test_choreographer_impl_branches.py @@ -173,8 +173,8 @@ async def test_i_will_work_on_pending_claim_raises_returns_invalid_state() -> No deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -196,8 +196,8 @@ async def test_i_will_work_on_pending_claim_returns_none_invalid_state() -> None deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -224,7 +224,9 @@ async def test_i_will_work_on_pending_no_plan_tracing_gap() -> None: task_svc.claim.return_value = claimed_task deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan=None, steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan=None, steps=_STEPS + ) body = env.as_dict() assert body["error"] == "tracing_gap" @@ -249,8 +251,8 @@ async def test_i_will_work_on_start_returns_none_invalid_state() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -279,8 +281,8 @@ async def test_needs_revision_branch_claim_fails_invalid_state() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -302,8 +304,8 @@ async def test_needs_revision_branch_start_fails() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -329,7 +331,9 @@ async def test_claimed_branch_returns_start_failed() -> None: task_svc.start.return_value = None deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state" @@ -350,7 +354,9 @@ async def test_i_will_work_on_in_progress_assigned_to_self_idempotent() -> None: task_svc.heartbeat = AsyncMock() deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS + ) body = env.as_dict() # No error — re-entry pass. assert "error" not in body or body.get("error") is None @@ -1095,7 +1101,9 @@ async def test_claimed_branch_already_active_guard() -> None: task_svc.list_in_progress_for_agent.return_value = [in_prog] deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan="ok", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="ok", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state" @@ -1231,8 +1239,8 @@ async def test_i_will_work_on_envelope_carries_introspection_on_success() -> Non deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -1258,7 +1266,9 @@ async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> N task_svc = _wire_dev_task_svc(task_id, status="completed", assigned_to=agent_id) deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan="x", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="x", steps=_STEPS + ) body = env.as_dict() assert body["error"] == "invalid_state" assert body["current_state"] == "completed" @@ -1319,7 +1329,9 @@ async def test_i_will_work_on_missing_plan_does_not_claim_pending_task() -> None task_svc = _wire_dev_task_svc(task_id, status="pending") deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan=None, steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan=None, steps=_STEPS + ) body = env.as_dict() assert body["error"] == "tracing_gap" assert "plan" in body["missing"] @@ -1354,7 +1366,9 @@ async def test_i_will_work_on_claimed_with_no_plan_accepts_recovery_plan() -> No task_svc.start.return_value = started deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, plan="recovery plan", steps=_STEPS) + env = await c.i_will_work_on( + agent_id=agent_id, task_id=task_id, plan="recovery plan", steps=_STEPS + ) body = env.as_dict() assert body["error"] is None, f"expected success, got {body}" task_svc.set_plan.assert_awaited_once() diff --git a/tests/unit/gateway/test_claim_arg_order.py b/tests/unit/gateway/test_claim_arg_order.py index 3a65b836..b3ac0aad 100644 --- a/tests/unit/gateway/test_claim_arg_order.py +++ b/tests/unit/gateway/test_claim_arg_order.py @@ -148,8 +148,8 @@ async def test_i_will_work_on_pending_calls_claim_with_task_id_first() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -207,8 +207,8 @@ async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() -> c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -261,7 +261,7 @@ async def test_i_will_work_on_claimed_resumption_calls_start_with_task_id_first( deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(agent_id, task_id, steps=_STEPS) + env = await c.i_will_work_on(agent_id=agent_id, task_id=task_id, steps=_STEPS) task_svc.start.assert_awaited_once_with(task_id, agent_id) assert env.error is None diff --git a/tests/unit/gateway/test_dev_steps_gate.py b/tests/unit/gateway/test_dev_steps_gate.py index ea6e8e1f..498d8ba8 100644 --- a/tests/unit/gateway/test_dev_steps_gate.py +++ b/tests/unit/gateway/test_dev_steps_gate.py @@ -115,7 +115,7 @@ async def test_dev_fresh_claim_without_steps_is_rejected() -> None: task_id = uuid4() c = Choreographer(_make_deps(task=_dev_task_svc(task_id))) - env = await c.i_will_work_on(dev_id, task_id, plan="do the thing") + env = await c.i_will_work_on(agent_id=dev_id, task_id=task_id, plan="do the thing") body = env.as_dict() assert body["error"] == "incomplete_input", body assert "steps" in (body.get("missing") or []), body @@ -128,8 +128,8 @@ async def test_dev_thin_step_description_is_rejected() -> None: c = Choreographer(_make_deps(task=_dev_task_svc(task_id))) env = await c.i_will_work_on( - dev_id, - task_id, + agent_id=dev_id, + task_id=task_id, plan="do the thing", steps=[{"title": "Edit README", "description": "edit it"}], ) @@ -162,7 +162,9 @@ async def test_dev_with_substantive_steps_passes_gate_and_persists_checklist() - {"title": "Edit README", "description": _GOOD_STEP_DESC}, {"title": "Commit + open PR", "description": _GOOD_STEP_DESC}, ] - env = await c.i_will_work_on(dev_id, task_id, **_full_plan_kwargs(steps_in)) + env = await c.i_will_work_on( + agent_id=dev_id, task_id=task_id, **_full_plan_kwargs(steps_in) + ) body = env.as_dict() assert body.get("error") != "incomplete_input", body # The full rich plan was layered into the panel-shaped dict and persisted, @@ -188,8 +190,8 @@ async def test_dev_fresh_claim_missing_considerations_and_risks_rejected() -> No c = Choreographer(_make_deps(task=_dev_task_svc(task_id))) env = await c.i_will_work_on( - dev_id, - task_id, + agent_id=dev_id, + task_id=task_id, plan=_GOOD_PLAN, steps=[{"title": "Edit README", "description": _GOOD_STEP_DESC}], ) @@ -210,7 +212,9 @@ async def test_dev_reentry_in_progress_short_circuits_before_steps_gate() -> Non svc.get.return_value.assigned_to = dev_id c = Choreographer(_make_deps(task=svc)) - env = await c.i_will_work_on(dev_id, task_id, plan="resume: keep going") + env = await c.i_will_work_on( + agent_id=dev_id, task_id=task_id, plan="resume: keep going" + ) body = env.as_dict() assert body.get("error") is None, body assert body.get("status") == "in_progress", body diff --git a/tests/unit/gateway/test_heartbeat_on_rejection.py b/tests/unit/gateway/test_heartbeat_on_rejection.py index 2e11f99d..bd6a8609 100644 --- a/tests/unit/gateway/test_heartbeat_on_rejection.py +++ b/tests/unit/gateway/test_heartbeat_on_rejection.py @@ -95,7 +95,7 @@ async def test_heartbeat_fires_on_rejection_not_authorized() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.i_will_work_on(aid, tid, plan="x") + env = await c.i_will_work_on(agent_id=aid, task_id=tid, plan="x") assert env.error == "not_authorized" task_svc.heartbeat.assert_awaited_with(tid) diff --git a/tests/unit/gateway/test_heartbeat_wired.py b/tests/unit/gateway/test_heartbeat_wired.py index 12c2ed89..83823097 100644 --- a/tests/unit/gateway/test_heartbeat_wired.py +++ b/tests/unit/gateway/test_heartbeat_wired.py @@ -86,7 +86,7 @@ async def test_i_will_work_on_calls_heartbeat() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - await c.i_will_work_on(aid, tid, plan="go") + await c.i_will_work_on(agent_id=aid, task_id=tid, plan="go") task_svc.heartbeat.assert_awaited_with(tid) diff --git a/tests/unit/gateway/test_merge_conflict_resolver.py b/tests/unit/gateway/test_merge_conflict_resolver.py index 5ec8b9cf..612e867f 100644 --- a/tests/unit/gateway/test_merge_conflict_resolver.py +++ b/tests/unit/gateway/test_merge_conflict_resolver.py @@ -67,7 +67,12 @@ async def test_superseded_closes_pr_and_completes_without_merge( ) env = await choreo._resolve_merge_conflict_on_complete( - uuid4(), uuid4(), t, "feature/frontend/root--cell", "notes", _EXC + pm_agent_id=uuid4(), + task_id=uuid4(), + t=t, + target="feature/frontend/root--cell", + notes="notes", + exc=_EXC, ) git.close_pull_request.assert_awaited_once() @@ -100,7 +105,12 @@ async def test_rebased_retries_merge_and_completes( ) await choreo._resolve_merge_conflict_on_complete( - uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC + pm_agent_id=uuid4(), + task_id=uuid4(), + t=t, + target="feature/backend/root--cell", + notes="notes", + exc=_EXC, ) git.pr_merge.assert_awaited_once() @@ -133,7 +143,12 @@ async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop( ) env = await choreo._resolve_merge_conflict_on_complete( - uuid4(), tid, t, "feature/backend/root--cell", "notes", _EXC + pm_agent_id=uuid4(), + task_id=tid, + t=t, + target="feature/backend/root--cell", + notes="notes", + exc=_EXC, ) task.admin_set_status.assert_awaited_once() @@ -171,7 +186,12 @@ async def test_diverged_rebase_outcome_escalates_rather_than_completing( ) await choreo._resolve_merge_conflict_on_complete( - uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC + pm_agent_id=uuid4(), + task_id=uuid4(), + t=t, + target="feature/backend/root--cell", + notes="notes", + exc=_EXC, ) task.admin_set_status.assert_awaited_once() @@ -198,7 +218,12 @@ async def test_unknown_rebase_outcome_escalates_rather_than_completing( ) await choreo._resolve_merge_conflict_on_complete( - uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC + pm_agent_id=uuid4(), + task_id=uuid4(), + t=t, + target="feature/backend/root--cell", + notes="notes", + exc=_EXC, ) task.admin_set_status.assert_awaited_once() diff --git a/tests/unit/gateway/test_work_session_auto_create.py b/tests/unit/gateway/test_work_session_auto_create.py index 677d5e4d..21209373 100644 --- a/tests/unit/gateway/test_work_session_auto_create.py +++ b/tests/unit/gateway/test_work_session_auto_create.py @@ -148,8 +148,8 @@ async def test_i_will_work_on_calls_ensure_work_session() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, @@ -290,8 +290,8 @@ async def test_ensure_work_session_not_called_when_start_fails() -> None: c = Choreographer(deps) env = await c.i_will_work_on( - agent_id, - task_id, + agent_id=agent_id, + task_id=task_id, plan=_GOOD_PLAN, steps=_STEPS, technical_considerations=_GOOD_TC, diff --git a/tests/unit/mcp_servers/test_do_server.py b/tests/unit/mcp_servers/test_do_server.py index d8b91d63..0bd2d22b 100644 --- a/tests/unit/mcp_servers/test_do_server.py +++ b/tests/unit/mcp_servers/test_do_server.py @@ -74,7 +74,7 @@ def test_note_default_scope_note(do_module: Any) -> None: fake_client.post.return_value = fake_response with patch("httpx.Client", return_value=fake_client): - do_module.note("hello world") + do_module.note(text="hello world") _args, kwargs = fake_client.post.call_args assert kwargs["json"]["scope"] == "note" @@ -88,7 +88,7 @@ def test_note_with_scope_reflect(do_module: Any) -> None: fake_client.post.return_value = fake_response with patch("httpx.Client", return_value=fake_client): - do_module.note("did x", scope="reflect") + do_module.note(text="did x", scope="reflect") _args, kwargs = fake_client.post.call_args assert kwargs["json"]["scope"] == "reflect" diff --git a/tests/unit/mcp_servers/test_envelope_on_4xx.py b/tests/unit/mcp_servers/test_envelope_on_4xx.py index b42e20ae..2f17e40e 100644 --- a/tests/unit/mcp_servers/test_envelope_on_4xx.py +++ b/tests/unit/mcp_servers/test_envelope_on_4xx.py @@ -105,7 +105,7 @@ def test_flow_post_returns_envelope_on_400(flow_module: types.ModuleType) -> Non } client = _fake_client_with(400, body) with patch("httpx.Client", return_value=client): - result = flow_module.i_will_work_on("task-id", plan="x") + result = flow_module.i_will_work_on(task_id="task-id", plan="x") assert result["error"] == "not_authorized" diff --git a/tests/unit/mcp_servers/test_flow_server.py b/tests/unit/mcp_servers/test_flow_server.py index bd342f66..9cc66adf 100644 --- a/tests/unit/mcp_servers/test_flow_server.py +++ b/tests/unit/mcp_servers/test_flow_server.py @@ -192,7 +192,7 @@ def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None: fake_client = _make_fake_client({"status": "in_progress"}) with patch("httpx.Client", return_value=fake_client): - flow_module.i_will_work_on("task-uuid", plan="my plan") + flow_module.i_will_work_on(task_id="task-uuid", plan="my plan") args, kwargs = fake_client.post.call_args assert kwargs["json"] == { @@ -210,7 +210,7 @@ def test_i_will_work_on_plan_defaults_to_none(flow_module: types.ModuleType) -> fake_client = _make_fake_client({"status": "in_progress"}) with patch("httpx.Client", return_value=fake_client): - flow_module.i_will_work_on("task-uuid") + flow_module.i_will_work_on(task_id="task-uuid") _, kwargs = fake_client.post.call_args assert kwargs["json"] == { @@ -234,7 +234,7 @@ def test_i_will_work_on_passes_steps(flow_module: types.ModuleType) -> None: ] with patch("httpx.Client", return_value=fake_client): - flow_module.i_will_work_on("task-uuid", plan="p", steps=steps) + flow_module.i_will_work_on(task_id="task-uuid", plan="p", steps=steps) _, kwargs = fake_client.post.call_args assert kwargs["json"] == { diff --git a/tests/unit/services/test_board_program_engine.py b/tests/unit/services/test_board_program_engine.py index a646bbee..cdfb31ab 100644 --- a/tests/unit/services/test_board_program_engine.py +++ b/tests/unit/services/test_board_program_engine.py @@ -471,6 +471,23 @@ async def test_prior_cycle_context_renders_rejections_with_reasons( assert "item-2 — too risky" in context +def test_learn_ref_names_the_item_not_its_per_cycle_index() -> None: + """The ref reaches the next cycle's prompt, so it must say WHAT was + decided — ``item-1`` means something different in every cycle.""" + assert bp_module.learn_ref({"id": "item-1", "title": "Fix the CLA gate"}) == ( + "Fix the CLA gate" + ) + # Scales items name the live task they mutate instead of a draft title. + assert bp_module.learn_ref( + {"id": "item-0", "target_task_title": "Panel UX wave"} + ) == ("Panel UX wave") + # A title-less item degrades to the old behaviour rather than an empty ref. + assert bp_module.learn_ref({"id": "item-2"}) == "item-2" + assert bp_module.learn_ref({"id": "item-3", "title": " "}) == "item-3" + cap = 80 + assert len(bp_module.learn_ref({"id": "item-4", "title": "x" * 200})) == cap + + def test_originators_cover_exactly_the_registry() -> None: assert set(bp_module._ORIGINATORS) == set(PROGRAMS) diff --git a/tests/unit/services/test_dogfood_service.py b/tests/unit/services/test_dogfood_service.py index d29ac3c9..1acf21b1 100644 --- a/tests/unit/services/test_dogfood_service.py +++ b/tests/unit/services/test_dogfood_service.py @@ -418,8 +418,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "approved", "reason": None, } in row.decisions @@ -442,8 +445,11 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/tests/unit/services/test_mirror_service.py b/tests/unit/services/test_mirror_service.py index 4074a804..0add1dbb 100644 --- a/tests/unit/services/test_mirror_service.py +++ b/tests/unit/services/test_mirror_service.py @@ -421,8 +421,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "approved", "reason": None, } in row.decisions @@ -445,8 +448,11 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/tests/unit/services/test_pest_control_service.py b/tests/unit/services/test_pest_control_service.py index ebc37be0..39582f9e 100644 --- a/tests/unit/services/test_pest_control_service.py +++ b/tests/unit/services/test_pest_control_service.py @@ -415,8 +415,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "approved", "reason": None, } in row.decisions @@ -439,8 +442,11 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/tests/unit/services/test_roadmap_service.py b/tests/unit/services/test_roadmap_service.py index 9dc4a632..30542cb6 100644 --- a/tests/unit/services/test_roadmap_service.py +++ b/tests/unit/services/test_roadmap_service.py @@ -413,8 +413,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "approved", "reason": None, } in row.decisions @@ -437,8 +440,10 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # Title, not index — the reject reason is only useful to the next cycle + # if it says which proposal it was about. assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/tests/unit/services/test_scales_service.py b/tests/unit/services/test_scales_service.py index f13cb9c9..d4fa5e6b 100644 --- a/tests/unit/services/test_scales_service.py +++ b/tests/unit/services/test_scales_service.py @@ -528,8 +528,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Stale task", "verdict": "approved", "reason": None, } in row.decisions @@ -553,8 +556,11 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Stale task", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/tests/unit/services/test_spackle_service.py b/tests/unit/services/test_spackle_service.py index f0f6bdcc..bb18d703 100644 --- a/tests/unit/services/test_spackle_service.py +++ b/tests/unit/services/test_spackle_service.py @@ -418,8 +418,11 @@ async def test_approve_records_learn_decision(db_session: AsyncSession) -> None: ) ).scalar_one() assert row.items_approved == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "approved", "reason": None, } in row.decisions @@ -442,8 +445,11 @@ async def test_reject_records_learn_decision_with_reason( ) ).scalar_one() assert row.items_rejected == ONE + # The ref is the item's TITLE, not its per-cycle index: this row is + # rendered into the next cycle's exploration prompt, where "item-0" + # names nothing (see BoardProgramEngine.learn_ref). assert { - "item_ref": "item-0", + "item_ref": "Item 0", "verdict": "rejected", "reason": "not a priority", } in row.decisions diff --git a/uv.lock b/uv.lock index 317ea972..da63406e 100644 --- a/uv.lock +++ b/uv.lock @@ -138,16 +138,16 @@ wheels = [ [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] name = "anthropic" -version = "0.118.0" +version = "0.120.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -159,9 +159,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/ff/bbad57650babf07ae9761f1d974fcee73430387b5d3aa0ddb395234906e8/anthropic-0.118.0.tar.gz", hash = "sha256:acb3c43b7e7592cf635fa21930e67a99d32641d68ccd53f625b52f71d7870d55", size = 994705, upload-time = "2026-07-22T16:43:57.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/5c/3331da4fc009d448008a50c78d86cc929e8c937cd1442245ce3f80561c4e/anthropic-0.120.0.tar.gz", hash = "sha256:6ba6007dc9b00365b20f6101a6618f5196ac1ceef81512e4b5cc0e7436d4975d", size = 1008042, upload-time = "2026-07-24T16:32:52.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/9f/d46e77054d7efb61c85ef5e8c7e95927cc89988b6491b46d22af60d63bda/anthropic-0.118.0-py3-none-any.whl", hash = "sha256:524d835869b8e374510b3bcc552e28dbdafc035a61d63fc0088c4035286ef8fe", size = 1010922, upload-time = "2026-07-22T16:43:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/8522bdf809e1f95f0d9c936540987a3f6afba01d2921a2bf488dedf836a8/anthropic-0.120.0-py3-none-any.whl", hash = "sha256:591bd531563ec7b63a1e138f5c11f14cb94edda99623b349c2ce2ece8e08b8a5", size = 1022602, upload-time = "2026-07-24T16:32:50.506Z" }, ] [[package]] @@ -420,11 +420,11 @@ filecache = [ [[package]] name = "cachetools" -version = "7.1.5" +version = "7.1.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/87/23/635f22bbd6478b02672432656a5f46775768e24b2715c2e8658b3d210602/cachetools-7.1.5.tar.gz", hash = "sha256:0def7134eba79e59448edaf5d2e3cc8e49978ab2fd56189c1efd19856b134ab5", size = 40500, upload-time = "2026-07-22T23:51:31.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/9b/8bd3cf22c5559a39e905b4884f773768f849091d91e374212d5a9ab66cd7/cachetools-7.1.5-py3-none-any.whl", hash = "sha256:900cda61ff34fafca2b7f78cfd2a91e2d4f9655c19309ef542ce0a04f91e6cd9", size = 16963, upload-time = "2026-07-22T23:51:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, ] [[package]] @@ -535,20 +535,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.126" +version = "0.2.128" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/33/30d51e1c53bb834fc4d51a39560c2e0cee0627767b2cc3b2001eb3b4ddb4/claude_agent_sdk-0.2.126.tar.gz", hash = "sha256:b0077f8da92f402032ec91b27499eb896aa97a9d78150ccd4a7840d39e70b9de", size = 304715, upload-time = "2026-07-22T21:40:34.678Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e8/3a9622b31f9ee22274e13a620e5e75ac38454d14538391b2fe3bc7eb76dc/claude_agent_sdk-0.2.128.tar.gz", hash = "sha256:2ac7b2b3bc56ae9037fd284c8690d3dafab9493ecd28d8974bba79a418e1b800", size = 309369, upload-time = "2026-07-25T01:48:25.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/27/2624f73d65c24e2f2ebf8f4ef95f98a71bf382f3f622c439f096af8a66e8/claude_agent_sdk-0.2.126-py3-none-macosx_11_0_arm64.whl", hash = "sha256:edac88522b6fca74f1a84aa0d0f29caad4adda8aed04c3f08b86836e06c6c271", size = 74592742, upload-time = "2026-07-22T21:40:38.469Z" }, - { url = "https://files.pythonhosted.org/packages/f8/38/d774167d45c238f69a49734154236d1632ef7204153eafd13c126add32a3/claude_agent_sdk-0.2.126-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:8dd73c1a193afa133241d7346e89e5b60ebf45f6a8f2cc38c84c8aad794af61e", size = 79619741, upload-time = "2026-07-22T21:40:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/786a8c67b53677af27fdbe3b3a80ee57c38ca18f95d44ec03567f97a7495/claude_agent_sdk-0.2.126-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:239e6073486488ce78fe88681c05e819562f5c5e567f02574492c3be3d35684b", size = 84165405, upload-time = "2026-07-22T21:40:46.331Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/76603fcd11ab51713ec0138c21ffbb82f5f1afcab9f73729e7e23a19e055/claude_agent_sdk-0.2.126-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:79caa03c6612e268bee93e95170d6b9ffdfa4bb21373a2e9ba7a439d3fb2e8e8", size = 85204584, upload-time = "2026-07-22T21:40:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/01/95/2d6685e502a7708473f03710f34f2ad6d7b3968fdd6cc90091e4f341c34d/claude_agent_sdk-0.2.126-py3-none-win_amd64.whl", hash = "sha256:201299609bb045e7ca8c7d3ff5e8d5900da2dcf3faad742990efe659e4c215ae", size = 85114596, upload-time = "2026-07-22T21:40:55.532Z" }, + { url = "https://files.pythonhosted.org/packages/45/ff/03613a38a84285cd85f114fc26d4431f545d2b3b344eb8f69f9b0f18f3c6/claude_agent_sdk-0.2.128-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e47ee95be68cb07612fd5288a40f3307763da5a5adf66d6e03ea49dc0495c9c", size = 75183825, upload-time = "2026-07-25T01:48:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/a3/df/2adbd3077f1a1cada39cd1508990d4008a8ac9ec74c801b24b1b302348b0/claude_agent_sdk-0.2.128-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:55e8fa28918af5620f391692efe80527ad9f2294cec14dadeea93c5161dbefc4", size = 80305667, upload-time = "2026-07-25T01:48:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/776a41af67a53d794b420dcd2f1075b585ed3725faa9827164f4a2e945dd/claude_agent_sdk-0.2.128-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6c10cc1c5403b2b0b3d8deb79ad5271a8e49b9db6460193599b91f49f16b4cf7", size = 84617823, upload-time = "2026-07-25T01:48:39.297Z" }, + { url = "https://files.pythonhosted.org/packages/17/1c/37044abbddf2141c4eeb6cc8e15a486491549a27283029d73db2c4f3c3b7/claude_agent_sdk-0.2.128-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cc4a0f20337e227fe16f00edf7cbe109349707adb440fe51ca6c44f9f8d7d21a", size = 85663462, upload-time = "2026-07-25T01:48:43.982Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/ffbd8113080f87a4cd7bbfc8b00a974ea7189e3f666b5d7a2903dec7b74f/claude_agent_sdk-0.2.128-py3-none-win_amd64.whl", hash = "sha256:37b87c8e75daa2a6dc74da6d788e0981a2e5e3a6be80cfa4c287abce2bf70e0b", size = 85566882, upload-time = "2026-07-25T01:48:48.635Z" }, ] [[package]] @@ -804,19 +804,19 @@ wheels = [ [[package]] name = "faker" -version = "40.35.0" +version = "40.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/a1/79278f517de8437a51af4cbce9cb86b0085fc22554e7ebffc6cb43bcb957/faker-40.35.0.tar.gz", hash = "sha256:13495348ec6f80d22c8c1654906ffeae336d485fea476544642ea24b120d9e13", size = 2025535, upload-time = "2026-07-22T17:18:42.589Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/c4/d217ea24d07fbdb00c81c83325fc814ee72ed93c70978ea5d4150604c305/faker-40.35.0-py3-none-any.whl", hash = "sha256:804224bc4ea6644256dd2ef5c951563bea318cb19be3505461c9c424c90181ec", size = 2062734, upload-time = "2026-07-22T17:18:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, ] [[package]] name = "fastapi" -version = "0.139.2" +version = "0.140.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -825,9 +825,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/fb/fd7671137d9fa3df1d93a2f5111eb982709201724b29f211e4beb2d58688/fastapi-0.140.0.tar.gz", hash = "sha256:f338951b82fd74ca8f843163aec43ea1a1ce84d515415a50fa98fa25572a5544", size = 420968, upload-time = "2026-07-24T21:16:41.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, + { url = "https://files.pythonhosted.org/packages/eb/76/6d9e25ad88da9d3ff744bcdbec4736e38c2288611d43f673a5d9bfa27c07/fastapi-0.140.0-py3-none-any.whl", hash = "sha256:e951c0a0d9540bf5d9a2a9e078fd415da2ab7e312d435139e7d9e2e7fe9f0b23", size = 130863, upload-time = "2026-07-24T21:16:42.89Z" }, ] [[package]] @@ -1781,7 +1781,7 @@ wheels = [ [[package]] name = "openai" -version = "2.47.0" +version = "2.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1793,9 +1793,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/61/9aeef14de759306e85175126d3d6d56ee4f5072a9512c6c171d58d02a62d/openai-2.47.0.tar.gz", hash = "sha256:4e205548acd4304f235b86202269912e55bc88270b15d2a051fa2b53b90343a6", size = 1089906, upload-time = "2026-07-22T17:47:29.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427, upload-time = "2026-07-23T20:15:50.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/69/26b032059273ad798d18fbcdbe369e871181841fd8bcb5caee32b7510039/openai-2.47.0-py3-none-any.whl", hash = "sha256:b3a1a7ad974092427ccb46d89f8852bdb67866680bcabeecc3ff5a3fdd71b15b", size = 1639987, upload-time = "2026-07-22T17:47:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520, upload-time = "2026-07-23T20:15:48.052Z" }, ] [[package]] @@ -2725,27 +2725,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] @@ -2967,14 +2967,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.69.0" +version = "4.69.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046, upload-time = "2026-07-24T14:22:02.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, ] [[package]] @@ -3052,11 +3052,11 @@ wheels = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20260518" +version = "6.0.12.20260724" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, ] [[package]]