docs(map): refresh 15 slices for forge/Mini-App/train deltas; re-sync the concat (#597)

Slice updates cover the forge package + provider parity, Telegram cockpit
V4/V5 surfaces, panel perf (virtualized kanban, scorecards batch route),
close_task_pr_best_effort + dependents guard, NO_COMMS_ROLES + CEO A2A
refusal at conversation creation, notification ack-TTL, docs-site config,
pr_labels base-branch signature, taste-skill prompt layers, prompter
history exclusions, live forge e2e suites, and migrations 075/076.
_complete_map.md is regenerated from the slices — the committed concat
had drifted ~8KB behind its own sources.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 19:00:52 +02:00
committed by GitHub
co-authored by Renn F
parent 862c0b27cd
commit 39ad09febc
16 changed files with 5366 additions and 5163 deletions
+5218 -5113
View File
File diff suppressed because it is too large Load Diff
@@ -278,6 +278,7 @@ a2a-audit-journal-permissions
> - **da563487** `Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)` — a2a.py grows by ~250 lines: adds the CEO admin/live-view surface (`get_conversation_admin`, `list_conversations_admin`, `get_messages_admin`, `_enforce_ceo_reply_budget`, `_get_conversation_for_reply_to_ceo`) and the `A2A_MESSAGE_SENT` publish (`_publish_a2a_message_sent`, called from `send`) for the operator's org-wide watch view; `roboco/models/events.py` adds `EventType.A2A_MESSAGE_SENT`; `websocket_bridge.py` adds `_handle_a2a_message_event` forwarding it to `/ws/system` as an `a2a.message` frame. `routes/a2a.py` adds the CEO-gated `/chat/admin/conversations`, `/chat/admin/conversations/{id}/messages`, `/chat/admin/conversations/{id}/reply` routes (`_require_ceo`). > - **da563487** `Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)` — a2a.py grows by ~250 lines: adds the CEO admin/live-view surface (`get_conversation_admin`, `list_conversations_admin`, `get_messages_admin`, `_enforce_ceo_reply_budget`, `_get_conversation_for_reply_to_ceo`) and the `A2A_MESSAGE_SENT` publish (`_publish_a2a_message_sent`, called from `send`) for the operator's org-wide watch view; `roboco/models/events.py` adds `EventType.A2A_MESSAGE_SENT`; `websocket_bridge.py` adds `_handle_a2a_message_event` forwarding it to `/ws/system` as an `a2a.message` frame. `routes/a2a.py` adds the CEO-gated `/chat/admin/conversations`, `/chat/admin/conversations/{id}/messages`, `/chat/admin/conversations/{id}/reply` routes (`_require_ceo`).
> - **876e19b3** `A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)` — a2a.py adds `list_admin_pairs` (the switchboard's one-bulk-query pair+conversation join over `agents_config.A2A_ALLOWED_PAIRS`); `routes/a2a.py` adds the CEO-gated `/chat/admin/pairs` route. This commit also tightened `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, out of this slice) and gave `SecretaryService` its `edit` directive action — see `docs/map/intake-secretary.md`. > - **876e19b3** `A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)` — a2a.py adds `list_admin_pairs` (the switchboard's one-bulk-query pair+conversation join over `agents_config.A2A_ALLOWED_PAIRS`); `routes/a2a.py` adds the CEO-gated `/chat/admin/pairs` route. This commit also tightened `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, out of this slice) and gave `SecretaryService` its `edit` directive action — see `docs/map/intake-secretary.md`.
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO-authored A2A DMs (panel "New DM" composer or `interject_as_ceo`) now wake an offline recipient: `_maybe_wake_ceo_recipient` (new) + `_ack_pending_wake_notifications` (new), gated to `read_a2a`-capable roles, reusing the legacy `a2a_request` NotificationTable row with a new per-row `requires_ack=True` override so it's finally visible to `_dispatch_a2a_work`'s `pending_ack_only` poll (previously structurally dead for every `a2a_request` row — see Gotchas). `send_a2a_notification` gains `requires_ack: bool = False` and an optional (`str | None`) `task_id`; `CreateNotificationParams` gains `requires_ack: bool | None = None`. Agent-to-agent `dm` is unaffected — still pull-only, no wake. Companion panel work (New-DM dialog, CEO direct-thread composer) is in `docs/map/panel.md`; the docs scrub that removed CEO-DM teaching from `docs/rag`/`agents/prompts` landed on the same branch (`ee620cf3`). > - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO-authored A2A DMs (panel "New DM" composer or `interject_as_ceo`) now wake an offline recipient: `_maybe_wake_ceo_recipient` (new) + `_ack_pending_wake_notifications` (new), gated to `read_a2a`-capable roles, reusing the legacy `a2a_request` NotificationTable row with a new per-row `requires_ack=True` override so it's finally visible to `_dispatch_a2a_work`'s `pending_ack_only` poll (previously structurally dead for every `a2a_request` row — see Gotchas). `send_a2a_notification` gains `requires_ack: bool = False` and an optional (`str | None`) `task_id`; `CreateNotificationParams` gains `requires_ack: bool | None = None`. Agent-to-agent `dm` is unaffected — still pull-only, no wake. Companion panel work (New-DM dialog, CEO direct-thread composer) is in `docs/map/panel.md`; the docs scrub that removed CEO-DM teaching from `docs/rag`/`agents/prompts` landed on the same branch (`ee620cf3`).
> - **`56b6693e`** ("security-hygiene-sweep") — zero code change in this slice's own files, but a behavior change flows through it: `agents_config.can_a2a_direct`'s CEO branch now consults `_check_ceo_a2a` (refuses `NO_COMMS_ROLES` targets — see `docs/map/foundation-policy-misc.md` / `docs/map/prompts-roles-taxonomy.md`), and `get_or_create_conversation`'s existing `validate_a2a_access(from_agent, to_agent)` call (line 784, unchanged) already delegates straight into `can_a2a_direct`. So a CEO DM to `auditor`/`pr_reviewer`/`prompter`/`secretary` now raises `A2AAccessDeniedError` at conversation-CREATION time — superseding the previous symptom-level mitigation, which let the conversation exist and only suppressed `_maybe_wake_ceo_recipient`'s wake notification (that independent manifest check still no-ops too, now unreachable via the normal send path but exercised directly by its own test as defense-in-depth).
## Health ## Health
This slice is mature and internally consistent: the six services have clear separation of concerns (A2A transport/conversation, audit forensics, journal CRUD+RAG, note persistence chokepoint, stream extraction, RBAC), and the gateway/HTTP/orchestrator entry points map cleanly onto them. The code is defensive in the right places — audit._persist is best-effort with its own session, journal RAG indexing is fire-and-forget with a strong-ref guard, content_notes validates before mutating, and A2A conversation dedup prevents respawn re-emit storms. The main integrity concerns are cross-layer, not in-slice: (1) the new 60s Redis loop-prone notification re-fire guard (3aff6e04) sits between A2A's create_a2a_notification and delivery and can silently drop legitimate A2A notifications; (2) the legacy A2A-protocol path (dev_notes 'A2A Request' marker, TASK_ASSIGNED re-spawn) is undocumented in CLAUDE.md and coexists with the gateway conversation path, a known source of future confusion. No in-slice file changed since the fd10cc86 baseline, so there is no direct regression surface; the risks above are all dependency-mediated. Recommend a regression test that an A2A notification fired twice within 60s for genuinely different reasons still delivers. This slice is mature and internally consistent: the six services have clear separation of concerns (A2A transport/conversation, audit forensics, journal CRUD+RAG, note persistence chokepoint, stream extraction, RBAC), and the gateway/HTTP/orchestrator entry points map cleanly onto them. The code is defensive in the right places — audit._persist is best-effort with its own session, journal RAG indexing is fire-and-forget with a strong-ref guard, content_notes validates before mutating, and A2A conversation dedup prevents respawn re-emit storms. The main integrity concerns are cross-layer, not in-slice: (1) the new 60s Redis loop-prone notification re-fire guard (3aff6e04) sits between A2A's create_a2a_notification and delivery and can silently drop legitimate A2A notifications; (2) the legacy A2A-protocol path (dev_notes 'A2A Request' marker, TASK_ASSIGNED re-spawn) is undocumented in CLAUDE.md and coexists with the gateway conversation path, a known source of future confusion. No in-slice file changed since the fd10cc86 baseline, so there is no direct regression surface; the risks above are all dependency-mediated. Recommend a regression test that an A2A notification fired twice within 60s for genuinely different reasons still delivers.
+6 -2
View File
@@ -60,7 +60,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
|--------|------|----------------|-----------| |--------|------|----------------|-----------|
| GET | /api/health, /api/ready | health.py | none | | GET | /api/health, /api/ready | health.py | none |
| GET | /api/dashboard/ceo | dashboard.py | agent context | | GET | /api/dashboard/ceo | dashboard.py | agent context |
| GET | /api/dashboard/metrics/{cycle-time,bottlenecks,rework,scorecard/*} | dashboard.py | agent context | | GET | /api/dashboard/metrics/{cycle-time,bottlenecks,rework,scorecard/*,member/{id},member/ceo,members} | dashboard.py | agent context `members` (no `{id}`) is the batch scorecard fetch replacing N per-agent calls |
| GET/POST/PATCH/DELETE | /api/tasks, /api/tasks/{id}/{claim,start,verify,submit-qa,pass-qa,fail-qa,complete,cancel,escalate-to-ceo} | tasks.py | agent context + `require_task_action` | | GET/POST/PATCH/DELETE | /api/tasks, /api/tasks/{id}/{claim,start,verify,submit-qa,pass-qa,fail-qa,complete,cancel,escalate-to-ceo} | tasks.py | agent context + `require_task_action` |
| GET | /api/tasks/summary?q= (list_tasks_summary) | tasks.py | agent context — trimmed list-view rows; server-side title/description/id-prefix search via `TaskService.search_tasks` when `q` is set (wave 1, `d1cf6ecb`) | | GET | /api/tasks/summary?q= (list_tasks_summary) | tasks.py | agent context — trimmed list-view rows; server-side title/description/id-prefix search via `TaskService.search_tasks` when `q` is set (wave 1, `d1cf6ecb`) |
| GET/POST | /api/orchestrator/{status,agents/{id},waiting} ; /spawn,/stop,/resolve-wait,/mark-waiting | orchestrator.py | `_require_ceo` (HMAC) | | GET/POST | /api/orchestrator/{status,agents/{id},waiting} ; /spawn,/stop,/resolve-wait,/mark-waiting | orchestrator.py | `_require_ceo` (HMAC) |
@@ -76,6 +76,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) | | GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) | | GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) |
| POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` | | POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` |
| GET | /api/telegram/today | telegram.py | `require_ceo_role` (agent context) + 30/60s rate limit — Mini App V4's "Today" brief, backed by `TgCockpitService.today()` (one DB round trip, see `docs/map/notification.md`) |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login | | GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
| GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context | | GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context |
| POST | /api/git/branches/cleanup | git.py | agent context, PM/CEO role-gated like `/rebase`; rate-limit 5/60 — cursor-resumable stale-branch sweep, `GitBranchCleanupRequest`/`Response` (wave 2, open PR #548) | | POST | /api/git/branches/cleanup | git.py | agent context, PM/CEO role-gated like `/rebase`; rate-limit 5/60 — cursor-resumable stale-branch sweep, `GitBranchCleanupRequest`/`Response` (wave 2, open PR #548) |
@@ -246,8 +247,11 @@ roboco/api/
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO. > - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass. > - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses. > - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) adds `POST /api/git/branches/cleanup` (PM/CEO role-gated like `/rebase`, rate-limit 5/60) + `GitBranchCleanupRequest`/`GitBranchCleanupResponse` schemas — cursor-resumable sweep of terminal tasks' remote+local branches, backing a confirm-dialog button on the panel Git page. > - `496c24d1` (PR #548, "git hygiene", 2026-07-17) adds `POST /api/git/branches/cleanup` (PM/CEO role-gated like `/rebase`, rate-limit 5/60) + `GitBranchCleanupRequest`/`GitBranchCleanupResponse` schemas — cursor-resumable sweep of terminal tasks' remote+local branches, backing a confirm-dialog button on the panel Git page.
> - `82642bea`+`e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) adds `POST /api/telegram/webapp-auth` (`webapp_auth_router`, mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` via `mount_telegram_miniapp_auth`) + `TelegramWebAppAuthRequest` schema, exchanging a validated Telegram `initData` payload for the same cloud-auth session cookie `/api/auth/login` mints (binds to the CEO's stored `chat_id`, audits `telegram.webapp.login`); generalizes `LoginRateLimiter` from a single `prefix` to a `paths: tuple[str, ...]` set (`roboco/api/auth/login_limit.py`) so `/webapp-auth` gets its own unconditional per-IP bucket, independent of the guard middleware's `rate_limit` decorator; the fix commit also rejects a far-future `initData.auth_date` (only ±60s clock-skew tolerated) and anchors the panel's `/tg` matcher exclusion. > - `82642bea`+`e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) adds `POST /api/telegram/webapp-auth` (`webapp_auth_router`, mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` via `mount_telegram_miniapp_auth`) + `TelegramWebAppAuthRequest` schema, exchanging a validated Telegram `initData` payload for the same cloud-auth session cookie `/api/auth/login` mints (binds to the CEO's stored `chat_id`, audits `telegram.webapp.login`); generalizes `LoginRateLimiter` from a single `prefix` to a `paths: tuple[str, ...]` set (`roboco/api/auth/login_limit.py`) so `/webapp-auth` gets its own unconditional per-IP bucket, independent of the guard middleware's `rate_limit` decorator; the fix commit also rejects a far-future `initData.auth_date` (only ±60s clock-skew tolerated) and anchors the panel's `/tg` matcher exclusion.
> - `baa87d58` (2026-07-19, PR #576, Telegram Mini App V4) adds `GET /api/telegram/today` (`require_ceo_role` + 30/60s rate limit) backed by new `TgCockpitService` + the `TelegramTodayResponse`/`TodayNeedsYou`/`TodayFleet`/`TodaySpend`/`TodayVelocity`/`TodayShip` schema family in `api/schemas/telegram.py` — see `docs/map/notification.md` for the service, `docs/map/panel.md` for the cockpit's Today tab.
> - `461a6e1a`+`96401f4c`+`5f32d876` (2026-07-18/19, forge Phases 1-4, #571/#575/#581) — no new HTTP routes (the forge routing is internal to `GitService`), but `roboco/api/schemas/project.py`/`project_fields.py` gain `git_provider` (project CRUD schemas) and the shared `task_project_fields` helper the X/video routes now call — see `docs/map/worksession-git.md` and `docs/map/product-strategy-research-pitch.md`.
> - ("panel-perf-p3-p4") adds `GET /api/dashboard/metrics/members` (batch scorecard fetch) — see `docs/map/metrics-observability.md`.
## Regression Risks ## Regression Risks
+5 -1
View File
@@ -113,6 +113,8 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
| 062-072 | *(not yet reflected in this table — pre-existing gap, out of scope for this pass)* | Vault V1/V2, revision-findings ledger, sandbox extensions, and other slices landed migrations in this range; see `alembic/versions/` directly until this table is backfilled. | | 062-072 | *(not yet reflected in this table — pre-existing gap, out of scope for this pass)* | Vault V1/V2, revision-findings ledger, sandbox extensions, and other slices landed migrations in this range; see `alembic/versions/` directly until this table is backfilled. |
| 073 | 073_project_environments.py | `projects.environments` (nullable JSONB) — the per-project ordered environment ladder (`list[{name, branch}]`, index 0 = head rung, index -1 = prod rung) that replaces `default_branch` as the source of truth for a project's PR target and release target. Additive: a null value falls back to a degenerate single-branch ladder synthesized from `default_branch` at read time (`roboco/models/env_branches.py`), so existing projects are unaffected until the CEO declares a real ladder. | | 073 | 073_project_environments.py | `projects.environments` (nullable JSONB) — the per-project ordered environment ladder (`list[{name, branch}]`, index 0 = head rung, index -1 = prod rung) that replaces `default_branch` as the source of truth for a project's PR target and release target. Additive: a null value falls back to a degenerate single-branch ladder synthesized from `default_branch` at read time (`roboco/models/env_branches.py`), so existing projects are unaffected until the CEO declares a real ladder. |
| 074 | 074_telegram_credentials.py | `telegram_credentials` (singleton Fernet-encrypted `bot_token_encrypted` + `chat_id_encrypted`, mirrors `x_credentials`) — the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, default off). | | 074 | 074_telegram_credentials.py | `telegram_credentials` (singleton Fernet-encrypted `bot_token_encrypted` + `chat_id_encrypted`, mirrors `x_credentials`) — the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, default off). |
| 075 | 075_company_goals_company_name.py | `company_goals.company_name` (Text, `server_default=""`) — CEO-authored product/company name, mirroring `brand_voice`. Feeds `CompanyGoalsService.resolve_product_name` (project name → this field → the "RoboCo" literal fallback), which `XEngine`/`VideoEngine` both call so release posts/videos stop hardcoding "RoboCo". Additive and inert until the CEO sets it in the Business → Goals editor. |
| 076 | 076_project_git_provider.py | `projects.git_provider` (nullable `String(16)`, not a pg enum — validated at the service layer by `roboco.foundation.policy.forge.validate_project_forge`) — Phase 0 of the forge-providers spec (GitHub + Gitea + GitLab). Null = auto-detect from the `git_url` host (github.com → github; anything else is a registration-time rejection unless the operator sets this column explicitly — the GitHub Enterprise / self-hosted escape hatch). Additive: every existing project keeps resolving to GitHub behavior until GitLab/Gitea providers are set. See `docs/map/worksession-git.md`. |
## Data Flow ## Data Flow
On boot, `init_db()` probes for application tables and `alembic_version`; if a pre-Alembic DB exists it stamps it at revision 001, then always runs `run_migrations()``alembic upgrade head` (in a thread via `asyncio.to_thread`). `env.py` imports `roboco.db.tables` so `Base.metadata` is fully populated, overrides `sqlalchemy.url` from `settings.database_url`, and runs online with an async NullPool engine. `compare_type` + `compare_server_default` are on so autogenerate drift is detectable. `tables.py` classes are the ORM mapping the migrations build; the domain layer reads them through `roboco/models/` dataclasses, not the tables directly. On boot, `init_db()` probes for application tables and `alembic_version`; if a pre-Alembic DB exists it stamps it at revision 001, then always runs `run_migrations()``alembic upgrade head` (in a thread via `asyncio.to_thread`). `env.py` imports `roboco.db.tables` so `Base.metadata` is fully populated, overrides `sqlalchemy.url` from `settings.database_url`, and runs online with an async NullPool engine. `compare_type` + `compare_server_default` are on so autogenerate drift is detectable. `tables.py` classes are the ORM mapping the migrations build; the domain layer reads them through `roboco/models/` dataclasses, not the tables directly.
@@ -273,6 +275,8 @@ Migration chain 001..059
> Delta 2026-07-03 (v0.17.0, 5 features): `055_spawn_session_turns_tool_calls` (`agent_spawn_sessions.turns`/`.tool_calls`) + `056_member_perf_daily` (`member_performance_daily`) predate this wave but were never appended to this doc; `057_project_sandbox_services` adds `projects.sandbox_services` (sandboxed dev DB/Redis/Mongo, `ROBOCO_SANDBOX_DB_ENABLED`); `058_cloud_auth_users` adds `users` (`UserTable`, cloud auth, `ROBOCO_CLOUD_AUTH_ENABLED`); `059_x_credentials` adds `x_credentials` (`XCredentialsTable`) + `x_seen_mentions` (`XSeenMentionTable`) (X engine, `ROBOCO_X_ENGINE_ENABLED`). Chain head is now 059. Mongo rides existing 057 (no new migration) — it's just another entry in the `SANDBOX_ENGINES` registry. > Delta 2026-07-03 (v0.17.0, 5 features): `055_spawn_session_turns_tool_calls` (`agent_spawn_sessions.turns`/`.tool_calls`) + `056_member_perf_daily` (`member_performance_daily`) predate this wave but were never appended to this doc; `057_project_sandbox_services` adds `projects.sandbox_services` (sandboxed dev DB/Redis/Mongo, `ROBOCO_SANDBOX_DB_ENABLED`); `058_cloud_auth_users` adds `users` (`UserTable`, cloud auth, `ROBOCO_CLOUD_AUTH_ENABLED`); `059_x_credentials` adds `x_credentials` (`XCredentialsTable`) + `x_seen_mentions` (`XSeenMentionTable`) (X engine, `ROBOCO_X_ENGINE_ENABLED`). Chain head is now 059. Mongo rides existing 057 (no new migration) — it's just another entry in the `SANDBOX_ENGINES` registry.
> >
> Delta 2026-07-04 (v0.18.0): `060_drop_messaging` (the comms-teardown migration — drops `messages`/`session_tasks`/`sessions`/`groups`/`channels` + 4 enum types + `journal_entries.session_id`; A2A is now the sole directed-message channel; one-way, `downgrade()` raises `NotImplementedError`) had already landed on master but was never appended to this doc; `061_x_feature_spotlight` adds `x_seen_features` (`XSeenFeatureTable`) + `company_goals.brand_voice` (X feature-spotlight, `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`, sub-switch of `x_engine_enabled`). Chain head is now 061. ORM table count is now 38 (verified via `grep -c '^class .*Table' roboco/db/tables.py`), up from this doc's previously-stated 37 (that figure predates 055-061 and was never recomputed). > Delta 2026-07-04 (v0.18.0): `060_drop_messaging` (the comms-teardown migration — drops `messages`/`session_tasks`/`sessions`/`groups`/`channels` + 4 enum types + `journal_entries.session_id`; A2A is now the sole directed-message channel; one-way, `downgrade()` raises `NotImplementedError`) had already landed on master but was never appended to this doc; `061_x_feature_spotlight` adds `x_seen_features` (`XSeenFeatureTable`) + `company_goals.brand_voice` (X feature-spotlight, `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`, sub-switch of `x_engine_enabled`). Chain head is now 061. ORM table count is now 38 (verified via `grep -c '^class .*Table' roboco/db/tables.py`), up from this doc's previously-stated 37 (that figure predates 055-061 and was never recomputed).
>
> Delta 2026-07-18/19: `075_company_goals_company_name` adds `company_goals.company_name` (X/video product-branding fallback) and `076_project_git_provider` adds `projects.git_provider` (forge-providers Phase 0 — GitHub/Gitea/GitLab). Chain head is now 076 (062-072 remain the pre-existing table gap noted above — this delta only closes 073-076).
## Regression Risks ## Regression Risks
@@ -289,4 +293,4 @@ Migration chain 001..059
| Migration 060 is a one-way removal with no downgrade | alembic/versions/060_drop_messaging.py:57-61 | `downgrade()` raises `NotImplementedError` — recreating channels/groups/sessions/session_tasks/messages + 4 enum types would need the full original schema. Any rollback plan for a bad deploy past 060 must restore from a pre-060 DB backup, not `alembic downgrade`. | Low | | Migration 060 is a one-way removal with no downgrade | alembic/versions/060_drop_messaging.py:57-61 | `downgrade()` raises `NotImplementedError` — recreating channels/groups/sessions/session_tasks/messages + 4 enum types would need the full original schema. Any rollback plan for a bad deploy past 060 must restore from a pre-060 DB backup, not `alembic downgrade`. | Low |
## Health ## Health
The chain is linear and complete (001→061), with `init_db` running `upgrade head` on every boot so deployed schemas stay current. The two structural risks are the `sa.Enum(create_type=False)` no-op in 001 (latent on clean re-applies) and the enum-parity gate's dependence on a populated migrated DB. New migrations consistently use the `postgresql.ENUM(create_type=False)` pattern and `ALTER TYPE ... ADD VALUE IF NOT EXISTS` for enum widening, so recent additions are safe. The chain is linear and complete (001→076), with `init_db` running `upgrade head` on every boot so deployed schemas stay current. The two structural risks are the `sa.Enum(create_type=False)` no-op in 001 (latent on clean re-applies) and the enum-parity gate's dependence on a populated migrated DB. New migrations consistently use the `postgresql.ENUM(create_type=False)` pattern and `ALTER TYPE ... ADD VALUE IF NOT EXISTS` for enum widening, so recent additions are safe.
+5 -2
View File
@@ -22,7 +22,8 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
|---|---|---|---| |---|---|---|---|
| parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL | | parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL |
| NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) | | NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) |
| ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:66 | NotificationType→requires_ack mapping (action-required vs informational) | | NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (AUDITOR, PR_REVIEWER, PROMPTER, SECRETARY) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart |
| ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) |
| Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) | | Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) |
| SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping | | SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping |
| ReadTier | enum | roboco/foundation/policy/journaling.py:41 | Journal read-breadth StrEnum (own/cell/cell_and_pms/all_cells/all) | | ReadTier | enum | roboco/foundation/policy/journaling.py:41 | Journal read-breadth StrEnum (own/cell/cell_and_pms/all_cells/all) |
@@ -116,7 +117,7 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
| _extract_strs_from_dict | function | roboco/foundation/policy/content/validators.py:128 | Resolve dict element to strings via first recognized text key else bare string values | | _extract_strs_from_dict | function | roboco/foundation/policy/content/validators.py:128 | Resolve dict element to strings via first recognized text key else bare string values |
## Data Flow ## Data Flow
This slice is pure policy/data: no I/O, no DB, no async. Control flows IN from the gateway/services layer at call sites. (1) communications.py: notification_delivery.py / notification.py import ACK_REQUIRED_BY_TYPE; agents_config.py / services/permissions.py / gateway/content_actions.py import NOTIFY_SENDER_ROLES for the notify-sender allowlist; a2a.py imports parse_priority to resolve A2A urgency tristate. (2) journaling.py: JournalService + enforcement/journal_perms.py import SCOPE_TO_TYPE, ROLE_READ_TIERS, PROTECTED_JOURNALS; gateway content_actions imports Scope for the note scope validation. (3) task_completeness.py: TaskService.create / PrompterService.create_task_from_draft / choreographer._impl._create_subtask_from_inputs call check(TASK_AT_CREATE, task) and the fill_* helpers; on failure raise TaskCompletenessError → gateway returns Envelope.incomplete_input with field_hints. (4) tracing.py: choreographer/_impl.py, qa.py, doc.py, pr_gate.py, pr_review.py call requirements_for(verb) then check_requirements(task=..., requirements=..., ctx=GateContext(...)) before allowing a state transition; failure → Envelope.tracing_gap with missing list. The parity test test_every_intent_verb_has_a_tracing_decision asserts every intent verb is in either VERB_REQUIREMENTS or VERBS_WITHOUT_TRACING. (5) agent_loop.py: agent_sdk/server.py reads DEFAULT_BUDGET thresholds (env-overridable) and retry_limit_for(verb); orchestrator.py reads pm_respawn_max_unproductive + pm_respawn_max_tracing_resets for the respawn circuit breaker. (6) content/: gateway content_actions / choreographer / prompter / intake_driver / flow_server call validate_content(content_type, payload) → model instance → render_markdown() written to Task note columns + PR comment bodies; pr_review_conflict guards the GitHub review event against findings; markers.py accessors are called across task.py, orchestrator, self_heal_engine, release_proposal, release_manager_engine to read/write orchestration_markers. coerce_str_list is applied at the intake/flow boundary (intake_driver, flow_server, flow schemas, prompter) BEFORE payloads reach the models, so models only need coerce_to_list as a backstop. This slice is pure policy/data: no I/O, no DB, no async. Control flows IN from the gateway/services layer at call sites. (1) communications.py: notification_delivery.py / notification.py import ACK_REQUIRED_BY_TYPE; agents_config.py / services/permissions.py / gateway/content_actions.py import NOTIFY_SENDER_ROLES for the notify-sender allowlist; a2a.py imports parse_priority to resolve A2A urgency tristate; `agents_config.py`'s `_check_ceo_a2a` (consulted from `can_a2a_direct`'s CEO branch) and `gateway/content_actions.py`'s `_NO_COMMS_ROLES` (the `dm()` sender-side guard, now `frozenset(r.value for r in NO_COMMS_ROLES)` instead of an independently hand-maintained literal) both import NO_COMMS_ROLES. (2) journaling.py: JournalService + enforcement/journal_perms.py import SCOPE_TO_TYPE, ROLE_READ_TIERS, PROTECTED_JOURNALS; gateway content_actions imports Scope for the note scope validation. (3) task_completeness.py: TaskService.create / PrompterService.create_task_from_draft / choreographer._impl._create_subtask_from_inputs call check(TASK_AT_CREATE, task) and the fill_* helpers; on failure raise TaskCompletenessError → gateway returns Envelope.incomplete_input with field_hints. (4) tracing.py: choreographer/_impl.py, qa.py, doc.py, pr_gate.py, pr_review.py call requirements_for(verb) then check_requirements(task=..., requirements=..., ctx=GateContext(...)) before allowing a state transition; failure → Envelope.tracing_gap with missing list. The parity test test_every_intent_verb_has_a_tracing_decision asserts every intent verb is in either VERB_REQUIREMENTS or VERBS_WITHOUT_TRACING. (5) agent_loop.py: agent_sdk/server.py reads DEFAULT_BUDGET thresholds (env-overridable) and retry_limit_for(verb); orchestrator.py reads pm_respawn_max_unproductive + pm_respawn_max_tracing_resets for the respawn circuit breaker. (6) content/: gateway content_actions / choreographer / prompter / intake_driver / flow_server call validate_content(content_type, payload) → model instance → render_markdown() written to Task note columns + PR comment bodies; pr_review_conflict guards the GitHub review event against findings; markers.py accessors are called across task.py, orchestrator, self_heal_engine, release_proposal, release_manager_engine to read/write orchestration_markers. coerce_str_list is applied at the intake/flow boundary (intake_driver, flow_server, flow schemas, prompter) BEFORE payloads reach the models, so models only need coerce_to_list as a backstop.
## Mermaid ## Mermaid
```mermaid ```mermaid
@@ -261,6 +262,8 @@ foundation/policy (misc slice)
| 3a4a3fe5 | reduce xenon C-rank blocks to A (behavior-preserving) | Extracted _extract_strs_from_dict helper from inline logic in validators._extract_strs — pure move-and-call refactor, no control flow / return values / side effects changed | | 3a4a3fe5 | reduce xenon C-rank blocks to A (behavior-preserving) | Extracted _extract_strs_from_dict helper from inline logic in validators._extract_strs — pure move-and-call refactor, no control flow / return values / side effects changed |
> Post-snapshot updates (since 2026-06-29): a squash-merge commit landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING). No other symbols in this slice changed. > Post-snapshot updates (since 2026-06-29): a squash-merge commit landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING). No other symbols in this slice changed.
>
> `56b6693e` ("security-hygiene-sweep"): adds `NO_COMMS_ROLES` (communications.py:66) — the four no-agent-comms-surface roles (auditor/pr_reviewer/prompter/secretary), extracted as the single canonical set two previously-independent hand-maintained literals now both derive from: `content_actions.py`'s `_NO_COMMS_ROLES` (the `dm()` sender-side guard) and a new `agents_config.py` helper, `_check_ceo_a2a` (called from `can_a2a_direct`'s CEO branch — previously an unconditional `True` for any CEO-initiated target; the "one asymmetric rule" now itself refuses a no-comms target). A downstream consequence with no separate code edit: `agents_config.py`'s statically-derived `A2A_ALLOWED_PAIRS` shrank 93→88 (`ceo` group 23→18).
## Regression Risks ## Regression Risks
+2
View File
@@ -335,6 +335,8 @@ gateway-support
> Post-snapshot updates (since 2026-06-29): commit 536bbb64 ("Chore/all/logical gaps sweep #286") touched content_actions.py and rate_limit_tracker.py in this slice. (1) rate_limit_tracker: activate() is now a Lua atomic merge (_ACTIVATE_RATE_LIMIT) that carries over the previous probe_failures count — the increment-vs-activate race is closed. (2) content_actions: _curate_playbook wraps the gating session.commit() in try/except PendingRollbackError; a poisoned session now returns a clean Envelope.invalid_state instead of 500-ing, and an uncommitted playbook cannot fall through to the RAG index. > Post-snapshot updates (since 2026-06-29): commit 536bbb64 ("Chore/all/logical gaps sweep #286") touched content_actions.py and rate_limit_tracker.py in this slice. (1) rate_limit_tracker: activate() is now a Lua atomic merge (_ACTIVATE_RATE_LIMIT) that carries over the previous probe_failures count — the increment-vs-activate race is closed. (2) content_actions: _curate_playbook wraps the gating session.commit() in try/except PendingRollbackError; a poisoned session now returns a clean Envelope.invalid_state instead of 500-ing, and an uncommitted playbook cannot fall through to the RAG index.
> >
> **v0.18.0** (2026-07-04): X feature-spotlight adds `ContentActions.propose_feature_spotlight` (content_actions.py:1238) + `_FEATURE_SPOTLIGHT_ROLES = frozenset({"head_marketing"})` (line 313), mirroring `pitch`'s Board-gated-verb shape. **Gap found (static read, not live-verified):** this verb has no wrapper function or `_TOOLS` entry in `roboco/mcp/do_server.py` (unlike `propose_roadmap`, which has both), so `_register_tools()`'s manifest∩`_TOOLS` intersection never exposes it to a spawned Head-of-Marketing agent despite the role-config grant — see mcp-servers.md Regression Risks. > **v0.18.0** (2026-07-04): X feature-spotlight adds `ContentActions.propose_feature_spotlight` (content_actions.py:1238) + `_FEATURE_SPOTLIGHT_ROLES = frozenset({"head_marketing"})` (line 313), mirroring `pitch`'s Board-gated-verb shape. **Gap found (static read, not live-verified):** this verb has no wrapper function or `_TOOLS` entry in `roboco/mcp/do_server.py` (unlike `propose_roadmap`, which has both), so `_register_tools()`'s manifest∩`_TOOLS` intersection never exposes it to a spawned Head-of-Marketing agent despite the role-config grant — see mcp-servers.md Regression Risks.
>
> **`56b6693e`** ("security-hygiene-sweep"): `content_actions.py:113`'s `_NO_COMMS_ROLES` (the `dm()` sender-side guard listed in the frozenset table above) changes from an independently hand-maintained literal to `frozenset(r.value for r in _comms.NO_COMMS_ROLES)` — derived from the new canonical `roboco.foundation.policy.communications.NO_COMMS_ROLES` (see `docs/map/foundation-policy-misc.md`), the SAME set `agents_config.py`'s CEO-target A2A check now also consumes, so the two enforcement points can't drift apart. No behavior change (the role set — auditor/pr_reviewer/prompter/secretary — is identical); this is a single-source-of-truth refactor.
## Regression Risks ## Regression Risks
+3 -1
View File
@@ -90,7 +90,7 @@ The CEO-facing intake and chief-of-staff slice. PrompterService turns a confirme
| project_history_digest | function | roboco/services/prompter.py:1236 | One project's rendered history digest via `TaskService.list_recent_for_project`; None if the project has no tasks | | project_history_digest | function | roboco/services/prompter.py:1236 | One project's rendered history digest via `TaskService.list_recent_for_project`; None if the project has no tasks |
| history_digest_layer | function | roboco/services/prompter.py:1253 | Ambient task-history-digest block for the in-scope project(s), one sub-block per project (headed by slug when >1 — the MegaTask case); None when no in-scope project has any tasks (no empty-header noise) | | history_digest_layer | function | roboco/services/prompter.py:1253 | Ambient task-history-digest block for the in-scope project(s), one sub-block per project (headed by slug when >1 — the MegaTask case); None when no in-scope project has any tasks (no empty-header noise) |
| compact_task_rows | function | roboco/services/prompter.py:1286 | Render TaskTable rows into the compact id/title/status/team/priority dicts returned by the intake `search_past_tasks` HTTP route | | compact_task_rows | function | roboco/services/prompter.py:1286 | Render TaskTable rows into the compact id/title/status/team/priority dicts returned by the intake `search_past_tasks` HTTP route |
| TaskService.list_recent_for_project | method | roboco/services/task.py:6361 | Recent tasks for a project ordered by coalesce(completed_at, updated_at, created_at) desc — backs the prompter's per-project history digest so a just-touched task surfaces ahead of an old completed one | | TaskService.list_recent_for_project | method | roboco/services/task.py:6361 | Recent non-cancelled tasks for a project ordered by coalesce(completed_at, updated_at, created_at) desc — backs the prompter's per-project history digest so a just-touched task surfaces ahead of an old completed one; CANCELLED tasks are excluded (abandoned work is not precedent) |
| TaskService.search_tasks | method | roboco/services/task.py:6384 | Case-insensitive ILIKE search over title/description + id-prefix match; backs the panel's task search bar (GET /tasks/summary?q=), the Secretary's task-by-name lookup (GET /secretary/tasks?q=), and the intake `search_past_tasks` tool | | TaskService.search_tasks | method | roboco/services/task.py:6384 | Case-insensitive ILIKE search over title/description + id-prefix match; backs the panel's task search bar (GET /tasks/summary?q=), the Secretary's task-by-name lookup (GET /secretary/tasks?q=), and the intake `search_past_tasks` tool |
| search_past_tasks (route) | route | roboco/api/routes/prompter_live.py:376 | GET /live/{session}/search-tasks: session-aliveness-gated (mirrors /events' trust boundary — the intake container has no agent identity) bounded compact search calling TaskService.search_tasks + compact_task_rows | | search_past_tasks (route) | route | roboco/api/routes/prompter_live.py:376 | GET /live/{session}/search-tasks: session-aliveness-gated (mirrors /events' trust boundary — the intake container has no agent identity) bounded compact search calling TaskService.search_tasks + compact_task_rows |
| query_past_tasks / format_search_results | function | roboco/mcp/intake_server.py:108,145 | Shared HTTP-call + bounding + rendering logic for `search_past_tasks`, module-level so both the grok MCP tool and the Claude SDK in-process tool call the exact same implementation | | query_past_tasks / format_search_results | function | roboco/mcp/intake_server.py:108,145 | Shared HTTP-call + bounding + rendering logic for `search_past_tasks`, module-level so both the grok MCP tool and the Claude SDK in-process tool call the exact same implementation |
@@ -248,6 +248,8 @@ intake-secretary
> `d1cf6ecb` Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 23 (#295) — secretary.py gains the full `edit` action (`_EDITABLE_TASK_FIELDS`, `_edit_task`, `_reassign_task`, `_resolve_assignee`) on `_control_task`; prompter.py gains the prompter-memory digest builders (`build_history_digest`, `project_history_digest`, `history_digest_layer`, `compact_task_rows`) plus the `TaskService.list_recent_for_project` / `search_tasks` backing queries; adds the `GET /live/{session}/search-tasks` route and the `search_past_tasks` MCP tool + Claude-SDK in-process parity tool. First commit to touch secretary.py since baseline. > `d1cf6ecb` Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 23 (#295) — secretary.py gains the full `edit` action (`_EDITABLE_TASK_FIELDS`, `_edit_task`, `_reassign_task`, `_resolve_assignee`) on `_control_task`; prompter.py gains the prompter-memory digest builders (`build_history_digest`, `project_history_digest`, `history_digest_layer`, `compact_task_rows`) plus the `TaskService.list_recent_for_project` / `search_tasks` backing queries; adds the `GET /live/{session}/search-tasks` route and the `search_past_tasks` MCP tool + Claude-SDK in-process parity tool. First commit to touch secretary.py since baseline.
> >
> `da563487` Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297) / `876e19b3` A2A switchboard + Secretary/PM task access + closed over-permission hole (#298) — no further changes to prompter.py/prompter_live.py/secretary.py beyond wave 1 above; these two commits' Secretary/PM-access work landed in `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, closing the PM-role unrestricted-admin hole — out of this slice, see `docs/map/api-routes-schemas.md`) and their A2A work is entirely in `docs/map/a2a-audit-journal-permissions.md`. > `da563487` Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297) / `876e19b3` A2A switchboard + Secretary/PM task access + closed over-permission hole (#298) — no further changes to prompter.py/prompter_live.py/secretary.py beyond wave 1 above; these two commits' Secretary/PM-access work landed in `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, closing the PM-role unrestricted-admin hole — out of this slice, see `docs/map/api-routes-schemas.md`) and their A2A work is entirely in `docs/map/a2a-audit-journal-permissions.md`.
>
> **"prompter-memory" (wave 2 tweak)**: `TaskService.list_recent_for_project` (task.py:6361) now excludes `TaskStatus.CANCELLED` — abandoned work is no longer precedent an intake agent's history digest treats as shipped or in-flight. Companion prompt guidance: `agents/prompts/roles/prompter.md` gains a `## Task history — don't propose what's already been done` section instructing the agent to check the ambient `## Task History` digest and the `search_past_tasks` tool before drafting — avoid duplicates (name a shipped/in-flight precedent by short id instead of quietly re-drafting it), cite precedent in a follow-up task's `notes`/`what_this_builds`, and let MegaTask sequencing be informed by (not overridden by) observed staging patterns. Informational only — the sequencing analyzer (`SequencingService`) keeps ownership of actual ordering.
## Regression Risks ## Regression Risks
+6
View File
@@ -17,6 +17,7 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
| `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, pitch, propose_roadmap, dm, notify, evidence, progress, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 976 | | `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, pitch, propose_roadmap, dm, notify, evidence, progress, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 976 |
| `roboco/mcp/optimal_server.py` | `roboco-optimal` MCP server — RAG / KB / mentor / error / decision / standards / learnings / index-mgmt / proactive-context. Factory `create_optimal_mcp_server(agent_id)`; calls `/optimal/*`. | 1102 | | `roboco/mcp/optimal_server.py` | `roboco-optimal` MCP server — RAG / KB / mentor / error / decision / standards / learnings / index-mgmt / proactive-context. Factory `create_optimal_mcp_server(agent_id)`; calls `/optimal/*`. | 1102 |
| `roboco/mcp/docs_server.py` | `roboco-docs` MCP server — docs write/read/list/delete via `/docs/*`. Factory `create_docs_mcp_server(agent_id)`. RAG-based dedup on write. | 251 | | `roboco/mcp/docs_server.py` | `roboco-docs` MCP server — docs write/read/list/delete via `/docs/*`. Factory `create_docs_mcp_server(agent_id)`. RAG-based dedup on write. | 251 |
| `roboco/services/docs.py` | `DocsService` — the `/docs/*` route handlers' backing service; refuses `write_doc(doc_type="user_facing")` with a message naming the deployer's docs-site project/URL | 785 |
| `roboco/mcp/git_readonly.py` | `roboco-git-readonly` MCP server — four read-only git views (status/log/diff/branch list) via `/api/git/*`. No breaker, no manifest. | 123 | | `roboco/mcp/git_readonly.py` | `roboco-git-readonly` MCP server — four read-only git views (status/log/diff/branch list) via `/api/git/*`. No breaker, no manifest. | 123 |
| `roboco/mcp/intake_server.py` | `roboco-intake` MCP server — grok intake path only: `propose_draft` / `propose_batch` POST directly to the prompter-live relay. | 215 | | `roboco/mcp/intake_server.py` | `roboco-intake` MCP server — grok intake path only: `propose_draft` / `propose_batch` POST directly to the prompter-live relay. | 215 |
| `roboco/mcp/secretary_server.py` | `roboco-secretary` MCP server — grok secretary path only: `read_company_state` / `read_task` / `submit_directive`, delegating to `agent_sdk.secretary_driver`. | 64 | | `roboco/mcp/secretary_server.py` | `roboco-secretary` MCP server — grok secretary path only: `read_company_state` / `read_task` / `submit_directive`, delegating to `agent_sdk.secretary_driver`. | 64 |
@@ -75,6 +76,8 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
| `roboco_get_proactive_context` | tool | `optimal_server.py:1000` | Stored-then-fresh proactive context for a task. | | `roboco_get_proactive_context` | tool | `optimal_server.py:1000` | Stored-then-fresh proactive context for a task. |
| `normalize_index_types` | func | `optimal_server.py:53` | Map legacy `docs` alias → `documentation` before route's `IndexType(...)` conversion. | | `normalize_index_types` | func | `optimal_server.py:53` | Map legacy `docs` alias → `documentation` before route's `IndexType(...)` conversion. |
| `create_docs_mcp_server` | factory | `docs_server.py:160` | Build `roboco-docs-{agent_id}` server (write/read/list/delete). | | `create_docs_mcp_server` | factory | `docs_server.py:160` | Build `roboco-docs-{agent_id}` server (write/read/list/delete). |
| `DocsService.write_doc` | method | `services/docs.py:192` | Refuses `doc_type="user_facing"` writes — normal tasks in the deployer's docs-site project (MDX) are the right home instead |
| `_refused_doc_types` | func | `services/docs.py:84` | Builds the refusal-message dict fresh on every call from `settings.docs_site_project_slug`/`docs_site_public_url` (was a fixed `REFUSED_DOC_TYPES` module constant hardcoding "roboco-website"/"docs.roboco.tech"); falls back to generic "your docs-site project" / "ship on your docs site" text when either setting is left blank |
| `WriteDocInput` | pydantic | `schemas/__init__.py:12` | Docs write input (task_id, filename, doc_type, title, content). | | `WriteDocInput` | pydantic | `schemas/__init__.py:12` | Docs write input (task_id, filename, doc_type, title, content). |
| `roboco_git_status` / `roboco_git_log` / `roboco_git_diff` / `roboco_git_branch_list` | tools | `git_readonly.py:45119` | Read-only git views via `/api/git/*`. | | `roboco_git_status` / `roboco_git_log` / `roboco_git_diff` / `roboco_git_branch_list` | tools | `git_readonly.py:45119` | Read-only git views via `/api/git/*`. |
| `propose_draft` / `propose_batch` | tools | `intake_server.py:104215` | Grok intake: POST draft/batch to prompter-live relay; `propose_batch` drops malformed (no string `title` or `name`) entries via `_normalize_batch_drafts` and refuses empty batches. | | `propose_draft` / `propose_batch` | tools | `intake_server.py:104215` | Grok intake: POST draft/batch to prompter-live relay; `propose_batch` drops malformed (no string `title` or `name`) entries via `_normalize_batch_drafts` and refuses empty batches. |
@@ -257,6 +260,8 @@ Env vars read in this slice (all `ROBOCO_*`):
| `ROBOCO_PROJECT_SLUG` / `ROBOCO_BRANCH` | set by orchestrator into `mcp_env` (consumed indirectly by `/api/git/*`) | Git context. | | `ROBOCO_PROJECT_SLUG` / `ROBOCO_BRANCH` | set by orchestrator into `mcp_env` (consumed indirectly by `/api/git/*`) | Git context. |
| `settings.research_enabled` | orchestrator mount gate for `roboco-search` (not read inside the slice) | Web-research server armed only when true AND role in research_roles. | | `settings.research_enabled` | orchestrator mount gate for `roboco-search` (not read inside the slice) | Web-research server armed only when true AND role in research_roles. |
| `settings.internal_api_url` | utils `ApiClient.base_url` | Base URL for optimal/docs/search async calls. | | `settings.internal_api_url` | utils `ApiClient.base_url` | Base URL for optimal/docs/search async calls. |
| `ROBOCO_DOCS_SITE_PROJECT_SLUG` (default `"roboco-website"`) | `services/docs.py` `_refused_doc_types` | Deployer-configurable project slug named in the `write_doc(doc_type="user_facing")` refusal message — distinct from `docs_sync_*` (which stays RoboCo-only by design for the docs-divergence sync engine, see `docs/map/engine-docs-sync.md`); defaults to RoboCo's own value so behavior is unchanged until overridden. |
| `ROBOCO_DOCS_SITE_PUBLIC_URL` (default `"docs.roboco.tech"`) | `services/docs.py` `_refused_doc_types` | Deployer-configurable public docs URL, same refusal message. |
| `ROBOCO_ALLOW_FULL_TOOLSET` | flow `_register_tools`, do `_register_tools` | Dev/test escape hatch: when set, a missing manifest registers the full tool set instead of raising `RuntimeError`. Never set in production — the full-toolset path was the original bug this policy replaced. | | `ROBOCO_ALLOW_FULL_TOOLSET` | flow `_register_tools`, do `_register_tools` | Dev/test escape hatch: when set, a missing manifest registers the full tool set instead of raising `RuntimeError`. Never set in production — the full-toolset path was the original bug this policy replaced. |
No default-off feature flag is armed *inside* this slice; the only flag-gated server here is `roboco-search` (gated upstream by `ROBOCO_RESEARCH_ENABLED` in the orchestrator mount). No default-off feature flag is armed *inside* this slice; the only flag-gated server here is `roboco-search` (gated upstream by `ROBOCO_RESEARCH_ENABLED` in the orchestrator mount).
@@ -326,6 +331,7 @@ No other commits in this slice since baseline.
> - **`intake_server.py`**: `_post_event` captures relay response body under `detail` on non-success so the grok intake agent sees the real failure reason instead of an opaque `http_422` token (#57). > - **`intake_server.py`**: `_post_event` captures relay response body under `detail` on non-success so the grok intake agent sees the real failure reason instead of an opaque `http_422` token (#57).
> >
> - **v0.18.0** (2026-07-04): No commit touched `roboco/mcp/` for the X feature-spotlight workstream — `do_server.py`'s `_TOOLS` dict (21 content tools) is unchanged, which is itself the finding: `propose_feature_spotlight` was wired at the role-config + content-actions layers but never registered here. See Regression Risks below. > - **v0.18.0** (2026-07-04): No commit touched `roboco/mcp/` for the X feature-spotlight workstream — `do_server.py`'s `_TOOLS` dict (21 content tools) is unchanged, which is itself the finding: `propose_feature_spotlight` was wired at the role-config + content-actions layers but never registered here. See Regression Risks below.
> - **`a0baf94b`** ("agnosticism-residue", audit item B8): `services/docs.py`'s `write_doc(doc_type="user_facing")` refusal message stops hardcoding "roboco-website"/"docs.roboco.tech" — the module-level `REFUSED_DOC_TYPES` constant becomes `_refused_doc_types()`, a function reading the new `ROBOCO_DOCS_SITE_PROJECT_SLUG`/`ROBOCO_DOCS_SITE_PUBLIC_URL` settings live on every call (both default to RoboCo's own values, so behavior is unchanged for this deployment). This `services/docs.py` file was previously undocumented in this slice's Files table — added above alongside the fix since it's the `docs_server.py` MCP server's sole backing service.
## Regression Risks ## Regression Risks
+9 -2
View File
@@ -8,7 +8,7 @@ The metrics & observability slice is the read-only measurement layer of RoboCo:
| Path | Role | approx LOC | | Path | Role | approx LOC |
|---|---|---| |---|---|---|
| `roboco/services/metrics.py` | `MetricsService` — velocity, blockers, team/agent metrics, health, cycle-time/bottleneck/rework/scorecard observability | 905 | | `roboco/services/metrics.py` | `MetricsService` — velocity, blockers, team/agent metrics, health, cycle-time/bottleneck/rework/scorecard observability | 1521 |
| `roboco/services/dashboard.py` | `DashboardService` — auditor flags/reports (in-memory singleton), CEO overview, audit queue, agent status, recent activity | 457 | | `roboco/services/dashboard.py` | `DashboardService` — auditor flags/reports (in-memory singleton), CEO overview, audit queue, agent status, recent activity | 457 |
| `roboco/services/cockpit.py` | `CockpitService` — read-only CEO "is the business winning?" summary (goals+delivery+spend+signals) | 97 | | `roboco/services/cockpit.py` | `CockpitService` — read-only CEO "is the business winning?" summary (goals+delivery+spend+signals) | 97 |
| `roboco/services/usage.py` | `UsageService` — token usage summary, time-series, by-agent/team/model, projection, cache efficiency, today summary, recent sessions | 478 | | `roboco/services/usage.py` | `UsageService` — token usage summary, time-series, by-agent/team/model, projection, cache efficiency, today summary, recent sessions | 478 |
@@ -38,6 +38,11 @@ The metrics & observability slice is the read-only measurement layer of RoboCo:
| `MetricsService.get_task_metrics` | method | metrics.py:~881 | Per-task `qa_fails`/`pr_fails`/`pm_rejects`/`ceo_rejects` (one aggregated audit_log query) + `findings_open`/`findings_total` (a second query against `ReviewFindingsRepository.list_for_task`, counted in Python from the fetched rows). | | `MetricsService.get_task_metrics` | method | metrics.py:~881 | Per-task `qa_fails`/`pr_fails`/`pm_rejects`/`ceo_rejects` (one aggregated audit_log query) + `findings_open`/`findings_total` (a second query against `ReviewFindingsRepository.list_for_task`, counted in Python from the fetched rows). |
| `MetricsService._rework_cost` | method | metrics.py:720 | Sum of `estimated_cost_usd` over spawn sessions of reworked tasks | | `MetricsService._rework_cost` | method | metrics.py:720 | Sum of `estimated_cost_usd` over spawn sessions of reworked tasks |
| `MetricsService.get_scorecard` | method | metrics.py:795 | Fused per-agent or per-cell scorecard (completed, cycle, rework, tokens, cost) | | `MetricsService.get_scorecard` | method | metrics.py:795 | Fused per-agent or per-cell scorecard (completed, cycle, rework, tokens, cost) |
| `MetricsService.get_member_scorecard` | method | metrics.py:1178 | Single-agent `MemberScorecard` (FPY, effort-throughput, utilization) — 3 DB queries, now delegates the assembly logic to `_assemble_member_scorecard` |
| `MetricsService._assemble_member_scorecard` | method | metrics.py:1128 | Shared builder extracted from the old `get_member_scorecard` body so the single-agent and batch paths derive identically (no duplicated logic) |
| `MetricsService.get_all_member_scorecards` | method | metrics.py:1301 | Batch replacement for N×`get_member_scorecard` calls — the whole non-CEO/non-SYSTEM roster (optionally team-filtered) in a **fixed 3 queries total**, not 3-per-agent: (1) agent list, (2) one `GROUP BY agent_slug` rollup via `_rollup_sums_by_agent`, (3) one `GROUP BY task_id` live-overlay query via `_live_inflight_overlay_by_agent`. Closes the "~20 agents = ~40 extra queries per panel Members-tab poll" N+1 the scorecards-tab previously incurred. |
| `MetricsService._rollup_sums_by_agent` | method | metrics.py:1200 | `SELECT agent_slug, SUM(...) ... GROUP BY agent_slug` over `MemberPerformanceDailyTable` for the whole agent-id list in one query, replacing a per-agent `_rollup_sums` call |
| `MetricsService._live_inflight_overlay_by_agent` | method | metrics.py:1232 | One `TaskTable` lookup for all non-terminal tasks assigned to the agent-id list, then one grouped `AgentSpawnSessionTable` query for their open sessions, aggregated back into a per-agent dict in Python |
| `MetricsService._tokens_cost_for` | method | metrics.py:768 | Sum tokens+cost from spawn sessions for an agent slug or team | | `MetricsService._tokens_cost_for` | method | metrics.py:768 | Sum tokens+cost from spawn sessions for an agent slug or team |
| `MetricsService._avg_cycle_hours` | method | metrics.py:869 | Avg started→completed hours for completed tasks | | `MetricsService._avg_cycle_hours` | method | metrics.py:869 | Avg started→completed hours for completed tasks |
| `_as_hours` | func | metrics.py:47 | Coerce SQL epoch aggregate to rounded float (avoids Decimal→JSON string crash) | | `_as_hours` | func | metrics.py:47 | Coerce SQL epoch aggregate to rounded float (avoids Decimal→JSON string crash) |
@@ -173,7 +178,7 @@ metrics-observability
## Entry Points ## Entry Points
- **HTTP routes** (`roboco/api/routes/usage.py`): `/api/usage/{summary,time-series,by-agent,by-team,by-model,projection,cache-efficiency,sessions}``get_usage_service`. - **HTTP routes** (`roboco/api/routes/usage.py`): `/api/usage/{summary,time-series,by-agent,by-team,by-model,projection,cache-efficiency,sessions}``get_usage_service`.
- **HTTP routes** (`roboco/api/routes/dashboard.py`): `/api/dashboard/auditor*`, `/api/dashboard/ceo*`, `/api/dashboard/kanban/*`, `/api/dashboard/agents/status`, `/api/dashboard/activity/recent`, `/api/dashboard/metrics/{velocity,blockers,team/{team},communication,health,agent/{id},cycle-time,bottlenecks,rework,scorecard/agent/{id},scorecard/team/{team}}``get_dashboard_service` + `get_metrics_service`. Auditor/CEO routes gated by `_require_auditor_or_ceo`. - **HTTP routes** (`roboco/api/routes/dashboard.py`): `/api/dashboard/auditor*`, `/api/dashboard/ceo*`, `/api/dashboard/kanban/*`, `/api/dashboard/agents/status`, `/api/dashboard/activity/recent`, `/api/dashboard/metrics/{velocity,blockers,team/{team},communication,health,agent/{id},cycle-time,bottlenecks,rework,scorecard/agent/{id},scorecard/team/{team}}``get_dashboard_service` + `get_metrics_service`. Auditor/CEO routes gated by `_require_auditor_or_ceo`. `GET /api/dashboard/metrics/member/{agent_id}` (dashboard.py:617, `get_member_scorecard`) and `GET /api/dashboard/metrics/member/ceo` (dashboard.py:606, declared first — route-order matters, a literal path segment must win over the `{agent_id}` path param) back the panel Members tab's per-row card. `GET /api/dashboard/metrics/members` (dashboard.py:634, `get_all_member_scorecards`, optional `team`/`days` query params) is the new batch fetch replacing N per-row calls — see `MetricsService.get_all_member_scorecards` above.
- **HTTP routes** (`roboco/api/routes/cockpit.py`): `/api/cockpit/{summary,signals}``get_cockpit_service`. - **HTTP routes** (`roboco/api/routes/cockpit.py`): `/api/cockpit/{summary,signals}``get_cockpit_service`.
- **Orchestrator loop tick**: `runtime/orchestrator.py` token sweep (~line 5270) → `calculate_cost` + `publish_usage_snapshot`; runs per dispatch tick on active agents. - **Orchestrator loop tick**: `runtime/orchestrator.py` token sweep (~line 5270) → `calculate_cost` + `publish_usage_snapshot`; runs per dispatch tick on active agents.
- **Self-heal / CI-watch loop ticks**: `services/self_heal_engine.py` and `services/ci_watch_engine.py` construct their telemetry source and call `fetch()` each cycle; armed by their respective ROBOCO_* flags. - **Self-heal / CI-watch loop ticks**: `services/self_heal_engine.py` and `services/ci_watch_engine.py` construct their telemetry source and call `fetch()` each cycle; armed by their respective ROBOCO_* flags.
@@ -225,6 +230,8 @@ No flags live *inside* this slice's files, but the slice's behavior is gated/par
> Post-snapshot updates (since 2026-06-29): commit **536bbb64** ("Chore/all/logical gaps sweep #286") touched `billing/pricing.py`, `billing/__init__.py`, `services/metrics.py`, and `services/usage.py`. Changes: (1) `CostResult` dataclass + `calculate_cost_result` function added to `pricing.py`; `calculate_cost` refactored to a thin wrapper; `__init__.py` now re-exports all three. (2) `_blocked_since_map` helper added to `MetricsService` — reads `task.blocked` audit row as authoritative "blocked since" (#67); `get_blocker_metrics` uses it with `updated_at` fallback. (3) `get_summary` docstring corrected — no longer falsely claims rollup reads (#66). (4) `metrics._blocked_since_map` extracted as a xenon complexity refactor (no behavior change beyond the audit-row fix). > Post-snapshot updates (since 2026-06-29): commit **536bbb64** ("Chore/all/logical gaps sweep #286") touched `billing/pricing.py`, `billing/__init__.py`, `services/metrics.py`, and `services/usage.py`. Changes: (1) `CostResult` dataclass + `calculate_cost_result` function added to `pricing.py`; `calculate_cost` refactored to a thin wrapper; `__init__.py` now re-exports all three. (2) `_blocked_since_map` helper added to `MetricsService` — reads `task.blocked` audit row as authoritative "blocked since" (#67); `get_blocker_metrics` uses it with `updated_at` fallback. (3) `get_summary` docstring corrected — no longer falsely claims rollup reads (#66). (4) `metrics._blocked_since_map` extracted as a xenon complexity refactor (no behavior change beyond the audit-row fix).
> >
> (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `services/metrics.py` and `models/metrics.py` are touched for the first time since baseline — `_rework_by_agent`/`get_task_metrics` widen from 2 to 4 named rework events (`pm_rejects`/`ceo_rejects` join `qa_fails`/`pr_fails`) and `get_task_metrics` gains `findings_open`/`findings_total`; `AgentReworkRate`/`TaskMetrics` (models/metrics.py) gain the matching fields, all defaulted for back-compat. See `docs/map/review-findings.md`. > (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `services/metrics.py` and `models/metrics.py` are touched for the first time since baseline — `_rework_by_agent`/`get_task_metrics` widen from 2 to 4 named rework events (`pm_rejects`/`ceo_rejects` join `qa_fails`/`pr_fails`) and `get_task_metrics` gains `findings_open`/`findings_total`; `AgentReworkRate`/`TaskMetrics` (models/metrics.py) gain the matching fields, all defaulted for back-compat. See `docs/map/review-findings.md`.
>
> **"panel-perf-p3-p4"** (2026-07-19): closes the "scorecards N+1" gap — the panel Members tab previously fired one `GET /dashboard/metrics/member/{id}` (3 DB queries) per agent on every poll (~20 agents ≈ 40 extra queries per poll). `MetricsService.get_all_member_scorecards` (metrics.py:1301) returns the whole roster's `MemberScorecard` list in a **fixed 3 queries total**: agent list, one `GROUP BY agent_slug` rollup (`_rollup_sums_by_agent`), one `GROUP BY task_id` live-overlay (`_live_inflight_overlay_by_agent`) — both new grouped-query helpers replacing what used to be N separate `_rollup_sums`/overlay calls. The single-agent `get_member_scorecard` path is behaviorally untouched, just refactored to share `_assemble_member_scorecard` with the new batch path so the FPY/effort-throughput/utilization derivation logic can't drift between the two. New route `GET /api/dashboard/metrics/members` (dashboard.py:634, optional `team`/`days`); panel's `scorecards-tab.tsx` now calls `useAllMemberScorecards()` once instead of `useMemberScorecard` per row (`panel/src/hooks/use-observability.ts` + `lib/api/observability.ts`, both new client-side pairings) — see `docs/map/panel.md`.
## Regression Risks ## Regression Risks
+7 -2
View File
@@ -1,5 +1,5 @@
## Purpose ## Purpose
This slice implements RoboCo's formal-notification backbone: NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, PM/CEO task-handoff notifications, and the best-effort Telegram DM bridge (`_notify_telegram`), and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops. The Telegram side has grown into its own two-way bridge: V1 (outbound-only DMs on escalation/completion) plus V2's `TelegramInboundEngine` (`telegram_inbound.py`) — a poll loop that turns the CEO's Telegram replies/button-taps into the same CEO-gated service calls the HTTP routes make. V3 (the Telegram Mini App sign-in) touches none of this slice's files — it's a pure HTTP route + validator, mapped in `api-routes-schemas.md` / `support-services.md` / `panel.md`. This slice implements RoboCo's formal-notification backbone: NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, PM/CEO task-handoff notifications, and the best-effort Telegram DM bridge (`_notify_telegram`), and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops. Ack-required notifications now also carry a TTL: `_create_notification` stamps `expires_at` (`settings.notification_ack_ttl_hours`, default 48) so the previously dead-on-arrival `sweep_expired_notifications` query finally matches rows. The Telegram side has grown into a full two-way cockpit: V1 (outbound-only DMs on escalation/completion) plus V2's `TelegramInboundEngine` (`telegram_inbound.py`) — a poll loop that turns the CEO's Telegram replies/button-taps into the same CEO-gated service calls the HTTP routes make; V3 (the Mini App sign-in) is a pure HTTP route + validator, mapped in `api-routes-schemas.md` / `support-services.md` / `panel.md`; **V4** adds `TgCockpitService` (`tg_cockpit.py`, backing the Mini App's "Today" brief) and `telegram_bridge.py` (bridging `/secretary`/`/newtask` chat commands into the same live-chat runtimes the panel drives) — both are in this slice's files now.
## Files ## Files
@@ -10,7 +10,9 @@ This slice implements RoboCo's formal-notification backbone: NotificationService
| roboco/services/notification_delivery.py | Delivery (transactional-outbox deferred bus publish), ACK/read tracking, expiry sweep, PM/CEO task-handoff notifications (notify_pm_of_block, escalate_and_notify, etc.), API-facing list/CRUD, `_notify_telegram` best-effort CEO DM fan-out (V2: `actionable=True` on escalation attaches an Approve/Reject/Open inline keyboard) | 1305 | | roboco/services/notification_delivery.py | Delivery (transactional-outbox deferred bus publish), ACK/read tracking, expiry sweep, PM/CEO task-handoff notifications (notify_pm_of_block, escalate_and_notify, etc.), API-facing list/CRUD, `_notify_telegram` best-effort CEO DM fan-out (V2: `actionable=True` on escalation attaches an Approve/Reject/Open inline keyboard) | 1305 |
| roboco/services/telegram_client.py | Bot API client ABC + `NullTelegramClient` (unconfigured, never egresses) + `LiveTelegramClient`: `send_message` (reply_markup/reply_to_message_id), V2 additions `get_updates` (long-poll), `answer_callback_query`, `edit_message_reply_markup`, `edit_message_text` | 247 | | roboco/services/telegram_client.py | Bot API client ABC + `NullTelegramClient` (unconfigured, never egresses) + `LiveTelegramClient`: `send_message` (reply_markup/reply_to_message_id), V2 additions `get_updates` (long-poll), `answer_callback_query`, `edit_message_reply_markup`, `edit_message_text` | 247 |
| roboco/services/telegram_credentials.py | Singleton Fernet-encrypted `bot_token`/`chat_id` CRUD (mirrors `x_credentials.py`); decrypts server-side only, API returns `has_credentials` only | 109 | | roboco/services/telegram_credentials.py | Singleton Fernet-encrypted `bot_token`/`chat_id` CRUD (mirrors `x_credentials.py`); decrypts server-side only, API returns `has_credentials` only | 109 |
| roboco/services/telegram_inbound.py | V2: `TelegramInboundEngine` — getUpdates poll cycle (offset persisted as `telegram_last_update_id` in system_settings), chat-id AND sender-id authorization, `/status` `/queue` `/task` command router, `apv|rej:<kind>:<id8>` callback codec, force_reply reject/approve-notes state machine (in-memory `_PENDING_REPLIES`, TTL), per-kind dispatch to the SAME service methods the CEO-gated HTTP routes call (task/release/xpost/video/roadmap), `via=telegram` audit rows | 858 | | roboco/services/telegram_inbound.py | V2: `TelegramInboundEngine` — getUpdates poll cycle (offset persisted as `telegram_last_update_id` in system_settings), chat-id AND sender-id authorization, `BOT_COMMANDS` registry driving both `/help` and a once-per-process `setMyCommands` sync, `/status`/`/queue`/`/task`/`/agents`/`/usage`/`/blocked`/`/secretary`/`/newtask`/`/end` command router, `apv|rej:<kind>:<id8>` callback codec, force_reply reject/approve-notes state machine (in-memory `_PENDING_REPLIES`, TTL), per-kind dispatch to the SAME service methods the CEO-gated HTTP routes call (task/release/xpost/video/roadmap), `via=telegram` audit rows | 1295 |
| roboco/services/tg_cockpit.py | V4: `TgCockpitService` — DB-only, one-round-trip aggregate for the Mini App home screen (`today()`) and the bot's `/agents` command (`fleet()`); no live GitHub calls, no orchestrator singleton | 217 |
| roboco/services/telegram_bridge.py | V4: bridges `/secretary`/`/newtask` Telegram chat into the SAME in-process live-chat runtimes the panel drives — a per-chat consumer task drains `PrompterLiveRegistry.stream`, forwards `turn_end`/`draft`/`batch`/`error` events as Telegram messages, and routes a `draft` event's Send-to-Board confirm through `PrompterService.confirm_live_draft` + registry `park` | 291 |
## Data Flow ## Data Flow
Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event. Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event.
@@ -116,6 +118,7 @@ telegram_inbound.py (TelegramInboundEngine, V2)
## Config Flags ## Config Flags
- settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT) - settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT)
- `ROBOCO_NOTIFICATION_ACK_TTL_HOURS` (default `48`, `ge=0`) — hours until an ack-required notification's `expires_at` is stamped at creation (`_create_notification`, config.py:270); consumed by `NotificationDeliveryService.sweep_expired_notifications`'s re-escalation. `0` disables stamping entirely (`expires_at` stays `NULL`, legacy behavior — never expires). Only ack-required notifications (per `ACK_REQUIRED_BY_TYPE`) get a deadline; informational ones never do regardless of this setting.
- `telegram_enabled` (default off) — V1 master switch; `_notify_telegram` no-ops without it AND stored credentials. - `telegram_enabled` (default off) — V1 master switch; `_notify_telegram` no-ops without it AND stored credentials.
- `telegram_inbound_enabled` (default off, sub-switch on top of `telegram_enabled`) — V2: arms `TelegramInboundEngine.run_cycle` (the poll loop) and makes escalation DMs carry an actionable keyboard; with it off the bot only sends, never listens, and any inline button on an old message is inert. - `telegram_inbound_enabled` (default off, sub-switch on top of `telegram_enabled`) — V2: arms `TelegramInboundEngine.run_cycle` (the poll loop) and makes escalation DMs carry an actionable keyboard; with it off the bot only sends, never listens, and any inline button on an old message is inert.
- `telegram_poll_interval_seconds` (5.0) / `telegram_poll_timeout_seconds` (25, Bot API long-poll `timeout`) / `telegram_max_updates_per_cycle` (50) / `telegram_pending_reply_ttl_seconds` (300) — V2 poll-loop tuning. - `telegram_poll_interval_seconds` (5.0) / `telegram_poll_timeout_seconds` (25, Bot API long-poll `timeout`) / `telegram_max_updates_per_cycle` (50) / `telegram_pending_reply_ttl_seconds` (300) — V2 poll-loop tuning.
@@ -155,6 +158,8 @@ telegram_inbound.py (TelegramInboundEngine, V2)
> Post-snapshot updates (since 2026-06-29): 115061f3 fixed list_system_notifications pending_ack_only correctness: SQL limit is now dropped for that branch so newer fully-acked rows can't mask older unacked ones (see Gotcha update above). `61e00832` (PR #492) added `notify_auditor_of_rework()` and `_get_auditor_agent()` to power the reactive auditor dispatch path: HIGH-priority ALERT notifications addressed to the auditor agent are emitted when a task enters `needs_revision` via QA/PR/PM rework chokepoints. **Wave 3** (2026-07-17, PR #547): `CreateNotificationParams` gains `requires_ack: bool | None = None`, consulted in `_create_notification` ahead of the `ACK_REQUIRED_BY_TYPE` default; `send_a2a_notification` gains a `requires_ack: bool = False` kwarg (plus an `str | None` `task_id`, for a conversational DM with no task behind it) that threads through — the only caller passing True is `A2AService._maybe_wake_ceo_recipient` (docs/map/a2a-audit-journal-permissions.md), so its wake row is finally visible to the orchestrator's `_dispatch_a2a_work` `pending_ack_only` poll. > Post-snapshot updates (since 2026-06-29): 115061f3 fixed list_system_notifications pending_ack_only correctness: SQL limit is now dropped for that branch so newer fully-acked rows can't mask older unacked ones (see Gotcha update above). `61e00832` (PR #492) added `notify_auditor_of_rework()` and `_get_auditor_agent()` to power the reactive auditor dispatch path: HIGH-priority ALERT notifications addressed to the auditor agent are emitted when a task enters `needs_revision` via QA/PR/PM rework chokepoints. **Wave 3** (2026-07-17, PR #547): `CreateNotificationParams` gains `requires_ack: bool | None = None`, consulted in `_create_notification` ahead of the `ACK_REQUIRED_BY_TYPE` default; `send_a2a_notification` gains a `requires_ack: bool = False` kwarg (plus an `str | None` `task_id`, for a conversational DM with no task behind it) that threads through — the only caller passing True is `A2AService._maybe_wake_ceo_recipient` (docs/map/a2a-audit-journal-permissions.md), so its wake row is finally visible to the orchestrator's `_dispatch_a2a_work` `pending_ack_only` poll.
> `cd978d11`+fixes (2026-07-18, wave-13): Telegram sends are HTML-styled — `_esc` (text nodes) / `_esc_attr` (href attributes) escaping discipline, balance-aware `_truncate`, `parse_mode`/`disable_link_preview` on the client; new `notify_ceo_of_queue_item` pushes a styled keyboard DM at each held-draft origination (release/x/video engines + `propose_roadmap`), sharing `telegram_inbound.render_queue_item_text`. > `cd978d11`+fixes (2026-07-18, wave-13): Telegram sends are HTML-styled — `_esc` (text nodes) / `_esc_attr` (href attributes) escaping discipline, balance-aware `_truncate`, `parse_mode`/`disable_link_preview` on the client; new `notify_ceo_of_queue_item` pushes a styled keyboard DM at each held-draft origination (release/x/video engines + `propose_roadmap`), sharing `telegram_inbound.render_queue_item_text`.
> `3b9fd0e0`+`11915f36` (PR #551, Telegram V2): `3b9fd0e0` adds `telegram_inbound.py` (new file, `TelegramInboundEngine`), extends `telegram_client.py` with `get_updates`/`answer_callback_query`/`edit_message_reply_markup`/`edit_message_text`, adds `actionable=True` to `_notify_telegram` (escalation only) so the DM carries an Approve/Reject/Open keyboard, and wires the orchestrator's `_telegram_poll_loop`. `11915f36` closes a live-reproduced approve-after-reject hole reachable via a stale Telegram button (or the pre-existing HTTP routes for X/video): `ReleaseProposalService.approve()` now refuses CANCELLED (`already_rejected`) and COMPLETED (`already_published`) proposals via a new `_approve_precheck`, `.reject()` refuses COMPLETED by raising a new `TaskAlreadyCompletedError`, and `XPostService`/`VideoPostService.approve()` each add a CANCELLED pre-lock-and-under-lock guard returning `already_rejected`. Also adds `_authorized_sender` (chat-id auth is defense-in-depth'd with a sender-id check) and widens `_resolve_task`'s search limit 10→50 so a genuine id-prefix hit can't be pushed out by newer title/description matches. > `3b9fd0e0`+`11915f36` (PR #551, Telegram V2): `3b9fd0e0` adds `telegram_inbound.py` (new file, `TelegramInboundEngine`), extends `telegram_client.py` with `get_updates`/`answer_callback_query`/`edit_message_reply_markup`/`edit_message_text`, adds `actionable=True` to `_notify_telegram` (escalation only) so the DM carries an Approve/Reject/Open keyboard, and wires the orchestrator's `_telegram_poll_loop`. `11915f36` closes a live-reproduced approve-after-reject hole reachable via a stale Telegram button (or the pre-existing HTTP routes for X/video): `ReleaseProposalService.approve()` now refuses CANCELLED (`already_rejected`) and COMPLETED (`already_published`) proposals via a new `_approve_precheck`, `.reject()` refuses COMPLETED by raising a new `TaskAlreadyCompletedError`, and `XPostService`/`VideoPostService.approve()` each add a CANCELLED pre-lock-and-under-lock guard returning `already_rejected`. Also adds `_authorized_sender` (chat-id auth is defense-in-depth'd with a sender-id check) and widens `_resolve_task`'s search limit 10→50 so a genuine id-prefix hit can't be pushed out by newer title/description matches.
> `baa87d58`+`c7605b0d` (2026-07-19, PR #576 + #582, Telegram Mini App V4): new `tg_cockpit.py``TgCockpitService.today()` (tg_cockpit.py:59) assembles `needs_you`/`fleet`/`spend`/`velocity`/`ship` in one DB-only round trip backing `GET /api/telegram/today` (`api/routes/telegram.py:89`, `require_ceo_role` + 30/60s rate limit); `TgCockpitService.fleet()` (tg_cockpit.py:110) is shared verbatim by the bot's new `/agents` command. New `telegram_bridge.py``BridgeSession` (per-chat, in-memory) lifecycle via `start_secretary`/`start_intake`, a sole-consumer `_consume` task draining `PrompterLiveRegistry.stream`, `_forward_event` turning `turn_end`/`draft`/`batch`/`error` stream events into Telegram messages, and `mark_parked`/`discard_draft` routing a draft's Send-to-Board confirm through `PrompterService.confirm_live_draft(route="board")` + registry `park`; `sweep_idle()` reuses `settings.interactive_idle_reap_seconds` and skips parked sessions. `telegram_inbound.py` gains `BOT_COMMANDS` (a single registry driving both `/help`'s `_HELP_TEXT` and a once-per-process `client.set_my_commands` sync via `TelegramInboundEngine._ensure_commands_menu`, called from `run_cycle()`) plus `/agents` (`_render_agents`, calls `TgCockpitService.fleet()`), `/usage` (`_render_usage`, `UsageService.get_today_summary`), `/blocked` (`_render_blocked`, capped `awaiting_ceo_approval`+`blocked` lists with deep-linked rows), and `/secretary`/`/newtask`/`/end` (dispatch straight into `telegram_bridge.py`). `telegram_client.py` gains `set_my_commands` (`LiveTelegramClient`, best-effort `httpx.HTTPError`-suppressed) + a `NullTelegramClient` no-op. No orchestrator wiring changed — `_telegram_poll_loop`/`_run_telegram_poll_cycle` are byte-for-byte unchanged; the bridge's idle sweep and the commands sync both run *inside* the existing `run_cycle()` tick. New response schemas in `api/schemas/telegram.py`: `TodayTaskItem`/`TodayNeedsYou`/`TodayFleetAgent`/`TodayFleet`/`TodaySpend` (gains `series`/`delta_pct` in the `c7605b0d` follow-up)/`TodayVelocity` (new in `c7605b0d`)/`TodayShip`/`TelegramTodayResponse`.
> `56b6693e` ("security-hygiene-sweep"): root-causes a previously dead-on-arrival sweep — `NotificationDeliveryService.sweep_expired_notifications` already ran a real `expires_at < now()` query, but `NotificationService._create_notification` never WROTE `expires_at`, so the query always matched zero rows and every ack-required notification was effectively immortal. `_create_notification` now computes `requires_ack` up front (same derivation as before) and, when ack-required AND `settings.notification_ack_ttl_hours > 0`, stamps `expires_at = now() + timedelta(hours=notification_ack_ttl_hours)` (default 48h) on the `NotificationTable` row; `0` leaves `expires_at` `NULL` (never expires). Informational notifications never get a deadline regardless of the setting.
## Regression Risks ## Regression Risks
+25 -16
View File
@@ -1,13 +1,13 @@
# Panel — RoboCo Control Panel Map # Panel — RoboCo Control Panel Map
## Purpose ## Purpose
The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the single operator UI for the human CEO: it drives intake/prompter chats, task/kanban management, agent observability, metrics, release approval, playbook curation, and feature-flag arming. It is served internally on port 3000 behind the nginx reverse proxy and talks to the orchestrator exclusively over relative `/api` + `/ws` URLs. The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.25.0) is the single operator UI for the human CEO: it drives intake/prompter chats, task/kanban management, agent observability, metrics, release approval, playbook curation, and feature-flag arming. It is served internally on port 3000 behind the nginx reverse proxy and talks to the orchestrator exclusively over relative `/api` + `/ws` URLs.
## Files / Structure ## Files / Structure
| Path | Role | | Path | Role |
|---|---| |---|---|
| `panel/package.json` | Deps: Next 16.1.1, React 19.2, TanStack Query 5.90, Radix UI, Tailwind 4, zustand 5, recharts 3, dnd-kit 6/10, axios, react-hook-form, zod 4 | | `panel/package.json` | Deps: Next 16.1.7 (held family pin), React 19.2, TanStack Query 5.90, Radix UI, Tailwind 4, zustand 5, recharts 3, dnd-kit 6/10, `@tanstack/react-virtual` 3.14.6 (kanban column windowing), axios, react-hook-form, zod 4 |
| `panel/src/app/layout.tsx` | Root layout (providers, theme, fonts) | | `panel/src/app/layout.tsx` | Root layout (providers, theme, fonts) |
| `panel/src/app/(dashboard)/layout.tsx` | Dashboard shell: sidebar + header + connection status | | `panel/src/app/(dashboard)/layout.tsx` | Dashboard shell: sidebar + header + connection status |
| `panel/src/app/(dashboard)/overview/page.tsx` | Overview = `<CommandCenter/>` | | `panel/src/app/(dashboard)/overview/page.tsx` | Overview = `<CommandCenter/>` |
@@ -22,16 +22,17 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it | | `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{business,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages | | `panel/src/app/(dashboard)/{business,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group | | `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/app/(tg)/layout.tsx` + `(tg)/tg/page.tsx` | Telegram Mini App cockpit at `/tg`: slim shell (no sidebar/header, `next/script` loads the Telegram WebApp bridge `afterInteractive`) + bootstrap page that resolves `window.Telegram.WebApp`, POSTs its `initData` to `/telegram/webapp-auth` unconditionally, then renders the tabbed cockpit (or an "Open from Telegram" / error state) | | `panel/src/app/(tg)/layout.tsx` + `(tg)/tg/page.tsx` | Telegram Mini App cockpit at `/tg`: slim shell (`id="tg-shell"` theme-scoping hook, `--tg-viewport-stable-height` sizing, `next/font/local` Share Tech Mono loaded as `--font-share-tech`, `next/script` loads the Telegram WebApp bridge `afterInteractive`) + bootstrap page that resolves `window.Telegram.WebApp`, POSTs its `initData` to `/telegram/webapp-auth` unconditionally, then renders the tabbed cockpit (or an "Open from Telegram" wall for ANY non-dev-mock bridge with empty `initData` — closes a prod-only bug where a plain-browser visit still gets the WebApp bridge object but empty `initData`, 422ing into "Couldn't sign in") |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present; matcher excludes `tg(?:/|$)` — the Mini App authenticates via Telegram `initData`, not the password cookie, so it must never be redirected to `/login` | | `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present; matcher excludes `tg(?:/|$)` — the Mini App authenticates via Telegram `initData`, not the password cookie, so it must never be redirected to `/login` |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` | | `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity, `x-post-queue.tsx`, `video-post-queue.tsx`, `roadmap-review-queue.tsx`, `project-badge.tsx` (shared project/repo badge — renders nothing when neither slug nor name is set; used by both the X and video post-queue rows so a multi-project CEO can tell drafts apart) |
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table | | `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table, `scorecards-tab.tsx` (calls `useAllMemberScorecards()` once instead of per-row `useMemberScorecard` — see `docs/map/metrics-observability.md`) |
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban | | `panel/src/components/kanban/{core,shared,views}/` | core: `kanban-board.tsx` (`tasksByStatus` grouping memoized, `handleAction` stabilized via `useCallback` + a `tasksRef`), `kanban-card.tsx` (wrapped in `memo`), `kanban-column.tsx` (windowed via `@tanstack/react-virtual`'s `useVirtualizer`, also `memo`-wrapped — the column itself stays the dnd-kit droppable target so windowing doesn't break drag targeting) + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
| `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card | | `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card |
| `panel/src/components/a2a/` | a2a-view.tsx (`A2AView` — the Conversations tab's full body, a pure lift of the old standalone `/a2a` page; owns the `?dm=` quick-action latch, see Gotchas), a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1, or preselects a validated `?dm=` deep-link target; exports `EXCLUDE_NON_DM_ROLES` so `tg-chat-tab.tsx`'s compose picker shares the same non-DM-capable-role exclusion instead of drifting out of sync), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils | | `panel/src/components/a2a/` | a2a-view.tsx (`A2AView` — the Conversations tab's full body, a pure lift of the old standalone `/a2a` page; owns the `?dm=` quick-action latch, see Gotchas), a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1, or preselects a validated `?dm=` deep-link target; exports `EXCLUDE_NON_DM_ROLES` so `tg-chat-tab.tsx`'s compose picker shares the same non-DM-capable-role exclusion instead of drifting out of sync), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**), `mobile-task-board.tsx` (read-only, grouped-by-status phone board for the `/tg` cockpit) | | `panel/src/components/tasks/` + `tasks/task-detail/` | task-table (row/card extraction into `memo`-wrapped `TaskTableRow`/`TaskTableCard`, `toggleExpand` stabilized via `useCallback` — pagination-based, not virtualized), create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**), `mobile-task-board.tsx` (read-only, grouped-by-status phone board for the `/tg` cockpit; gained `tasks`/`onTaskPress` props for demo-mode + the task detail sheet) |
| `panel/src/components/tg/` | The `/tg` cockpit's own tabs: `tg-tab-bar.tsx` (4-tab bottom nav, page-state-controlled), `tg-approvals-tab.tsx` (stacks the existing held-artifact queue cards), `tg-inbox-tab.tsx` (notifications + ack), `tg-board-tab.tsx` (wraps `mobile-task-board.tsx`), `tg-chat-tab.tsx` (A2A conversation list / compose / polled thread, phone-scoped rebuild of the Agents hub's Conversations tab) | | `panel/src/components/tg/` | The `/tg` cockpit's tabs — now **5-tab** bottom nav (`today` default + `approvals`/`inbox`/`board`/`chat`): `tg-today-tab.tsx` (the default-opening "Today" brief — spend hero with 7-day sparkline, quick-action ring, needs-you banner, live fleet avatars, velocity bars), `tg-approvals-tab.tsx` (native card stack over `approvals/` — 7-file subdirectory: `use-approval-queue.ts`, `primary-action.tsx`, `reject-form.tsx`, `release-detail.tsx`, `x-post-detail.tsx`, `video-post-detail.tsx`, `roadmap-item-detail.tsx`), `tg-inbox-tab.tsx` (notifications + ack, `TgAvatar` sender tokens), `tg-board-tab.tsx` (wraps `mobile-task-board.tsx` + the new `tg-task-sheet.tsx` detail sheet), `tg-chat-tab.tsx` (WS-live via `/ws/system`, 10s poll fallback only while the socket is down), `ui.tsx` (shared visual-language primitives: `TgCircleAction`/`TgAvatar`/`TgSection`/`TgRow`/`TgRowIcon`/`TgStat`), `charts.tsx` (hand-rolled inline-SVG `Sparkline`/`DayBars`, no charting lib in the Mini App bundle), `tg-icons.tsx` (9-icon hand-drawn duotone set for hero surfaces, utility chrome stays lucide-react), `motion.tsx` (`useCountUp` numeral count-up hook + `TgSheet` bottom-sheet dialog wired to Telegram's native `BackButton`), `tg-task-sheet.tsx` (read-only task-detail bottom sheet: status/bounced-chip/ACs/up-to-5-open-findings/PR link) |
| `panel/src/lib/telegram/webapp.ts` | Thin typed wrapper over the global `window.Telegram.WebApp` (`ready`/`expand`/`initData`); `waitForTelegramWebApp` polls (100ms, 1.5s timeout) for the CDN script since it loads `afterInteractive` | | `panel/src/lib/telegram/webapp.ts` | Typed wrapper over the global `window.Telegram.WebApp` (`ready`/`expand`/`initData`, `TelegramThemeParams`/`MainButton`/`BackButton`/`HapticFeedback` interfaces); `waitForTelegramWebApp` polls (100ms, 1.5s timeout) for the CDN script since it loads `afterInteractive`; `createDevMockWebApp`/`isDevMockWebApp` back the `/tg?demo=1` dev-browser fallback; null-safe `haptics` const (no-ops outside Telegram) |
| `panel/src/lib/telegram/{hooks.tsx,theme.ts,demo.ts,demo-data.ts}` | `hooks.tsx`: `TgWebAppProvider`/`useTgWebApp` context + `useMainButton`/`useBackButton` (declarative wrappers over Telegram's native buttons — no consumer touches `window.Telegram` directly). `theme.ts`: `applyTelegramTheme` maps `themeParams` → shadcn CSS vars, hex-only trust boundary, scoped to `#tg-shell` only (the desktop dashboard is untouched); `startTelegramThemeSync` re-applies on the bridge's `themeChanged` event. `demo.ts`/`demo-data.ts`: `isTgDemoMode()` (dev-only, `?demo=1`, dead-code-eliminated in prod) gates dynamically-imported `DEMO_TASKS`/`DEMO_NOTIFICATIONS`/`DEMO_RELEASE`/`DEMO_X_POSTS`/`DEMO_VIDEO_POSTS`/`DEMO_ROADMAP`/`DEMO_TODAY` fixtures |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) | | `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) | | `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers; `agents/` similarly exports `agents-fleet-view.tsx` (`AgentsFleetView`) consumed by `agents/page.tsx`'s Fleet tab, plus `agent-card.tsx`'s DM quick-action button; `journals/` similarly exports `journals-view.tsx` (`JournalsView`) consumed by `agents/page.tsx`'s Journals tab); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) | | `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers; `agents/` similarly exports `agents-fleet-view.tsx` (`AgentsFleetView`) consumed by `agents/page.tsx`'s Fleet tab, plus `agent-card.tsx`'s DM quick-action button; `journals/` similarly exports `journals-view.tsx` (`JournalsView`) consumed by `agents/page.tsx`'s Journals tab); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
@@ -65,13 +66,13 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Agents — Conversations (switchboard + reply + New DM) | `app/(dashboard)/agents/page.tsx` (`?tab=conversations`) + `components/a2a/a2a-view.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed) — either from the dialog's own picker or preselected from a validated `?dm=` deep link off the Fleet tab; the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer. `/a2a` redirects here | | Agents — Conversations (switchboard + reply + New DM) | `app/(dashboard)/agents/page.tsx` (`?tab=conversations`) + `components/a2a/a2a-view.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed) — either from the dialog's own picker or preselected from a validated `?dm=` deep link off the Fleet tab; the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer. `/a2a` redirects here |
| Agents — Journals | `app/(dashboard)/agents/page.tsx` (`?tab=journals`) + `components/journals/journals-view.tsx` + `components/journals/*` | Per-agent reflection log lifted into the Agents hub's third tab: agent picker (left, local search state seeded from the last saved query) + a type/task-filterable entry list (right, `JournalView`); each task-linked entry carries a Task badge + copy-UUID button. Entry detail still lives at `/journals/{entryId}`, its back-links now pointing at `/agents?tab=journals`. `/journals` redirects here | | Agents — Journals | `app/(dashboard)/agents/page.tsx` (`?tab=journals`) + `components/journals/journals-view.tsx` + `components/journals/*` | Per-agent reflection log lifted into the Agents hub's third tab: agent picker (left, local search state seeded from the last saved query) + a type/task-filterable entry list (right, `JournalView`); each task-linked entry carries a Task badge + copy-UUID button. Entry detail still lives at `/journals/{entryId}`, its back-links now pointing at `/agents?tab=journals`. `/journals` redirects here |
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) | | Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR | | Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR. Also carries the "Forge" `<Select>` (Auto-detect/GitHub/Gitea/GitLab, `project.git_provider`) — see `docs/map/worksession-git.md` |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback | | Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` | | Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` |
| Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt | | Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt |
| Task Detail | `components/tasks/task-detail/*` | Tabbed: overview, plan, progress, commits, sessions, notes, dependencies, **findings**, AC, action dialogs | | Task Detail | `components/tasks/task-detail/*` | Tabbed: overview, plan, progress, commits, sessions, notes, dependencies, **findings**, AC, action dialogs |
| AI Providers | `app/(dashboard)/settings/ai-providers/page.tsx` + `components/settings/ai-routing-card.tsx` | Per-slug/role/global model routing | | AI Providers | `app/(dashboard)/settings/ai-providers/page.tsx` + `components/settings/ai-routing-card.tsx` | Per-slug/role/global model routing |
| Telegram Mini App | `app/(tg)/tg/page.tsx` + `components/tg/*` | The CEO's phone cockpit, outside the `(dashboard)` shell: 4 tabs (Approvals — the held-artifact queues restacked; Inbox notifications + ack; Board `mobile-task-board.tsx` read-only grouped-by-status; Chat A2A conversation list/compose/thread, polled not WS). Bootstraps via Telegram `initData``/telegram/webapp-auth`, requires both `telegram_miniapp_enabled` and `cloud_auth_enabled` armed server-side | | Telegram Mini App | `app/(tg)/tg/page.tsx` + `components/tg/*` | The CEO's phone cockpit, outside the `(dashboard)` shell: 5 tabs — Today (default: `GET /telegram/today` spend/fleet/velocity/needs-you brief + a 4-action ops ring), Approvals (native card stack over the 4 held-artifact queues, Telegram `MainButton`/`BackButton`-driven), Inbox (notifications + ack), Board (`mobile-task-board.tsx` read-only grouped-by-status + a `tg-task-sheet.tsx` detail sheet), Chat (A2A conversation list/compose/thread, now WS-live via `/ws/system`). Bootstraps via Telegram `initData``/telegram/webapp-auth`, requires both `telegram_miniapp_enabled` and `cloud_auth_enabled` armed server-side; `themeParams` scoped to `#tg-shell` only; a dev-only `?demo=1` mock bridge + fixtures make the whole surface workable in a plain browser |
## Key Symbols ## Key Symbols
@@ -89,7 +90,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle | | `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle |
| `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect | | `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the Conversations tab's invalidate-on-frame + switchboard pulses | | `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the Conversations tab's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames; `useA2AMessages` takes an optional `{ refetchInterval }` (default off — the desktop Conversations tab relies on WS invalidation) that `tg-chat-tab.tsx` sets to ~10s since the `/tg` cockpit has no WS wiring | | `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames; `useA2AMessages` takes an optional `{ refetchInterval }` (default off — the desktop Conversations tab relies on WS invalidation) that `tg-chat-tab.tsx` now uses only as a fallback (`THREAD_POLL_MS=10_000`) while `/ws/system` is down, since it also gained its own `useA2ALiveStream()` WS invalidation in the V4 premium-cockpit pass |
| `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success | | `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success |
| `A2AView` | comp | `components/a2a/a2a-view.tsx` | Conversations tab body — pure lift of the old standalone `/a2a` page (switchboard/list, transcript, reply/direct composer, New DM); owns the `?dm=` quick-action latch (`dmParam`/`prevDmParam` render-phase state, re-arms once `dm` is stripped) and the `?conversation=` selection, both now targeting `/agents` instead of `/a2a` | | `A2AView` | comp | `components/a2a/a2a-view.tsx` | Conversations tab body — pure lift of the old standalone `/a2a` page (switchboard/list, transcript, reply/direct composer, New DM); owns the `?dm=` quick-action latch (`dmParam`/`prevDmParam` render-phase state, re-arms once `dm` is stripped) and the `?conversation=` selection, both now targeting `/agents` instead of `/a2a` |
| `AgentsFleetView` | comp | `components/agents/agents-fleet-view.tsx` | Fleet tab body — pure lift of the old standalone `/agents` page (roster grids, orchestrator status, spawn/stop controls); `AgentCard`'s DM button pushes the `?dm=` deep link the Conversations tab's latch consumes | | `AgentsFleetView` | comp | `components/agents/agents-fleet-view.tsx` | Fleet tab body — pure lift of the old standalone `/agents` page (roster grids, orchestrator status, spawn/stop controls); `AgentCard`'s DM button pushes the `?dm=` deep link the Conversations tab's latch consumes |
@@ -164,8 +165,9 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── layout.tsx (root layout: providers, theme, fonts) │ ├── layout.tsx (root layout: providers, theme, fonts)
│ ├── (auth)/login/page.tsx (cloud-auth login form; gated by proxy.ts) │ ├── (auth)/login/page.tsx (cloud-auth login form; gated by proxy.ts)
│ ├── (tg)/ │ ├── (tg)/
│ │ ├── layout.tsx (slim shell, no sidebar/header; loads Telegram WebApp bridge script) │ │ ├── layout.tsx (slim shell, #tg-shell theme scoping, Share Tech Mono font, loads Telegram WebApp bridge script)
│ │ ── tg/page.tsx (bootstrap: initData → /telegram/webapp-auth, then 4-tab cockpit) │ │ ── fonts/ShareTechMono-Regular.woff2 (vendored display monospace, next/font/local)
│ │ └── tg/page.tsx (bootstrap: initData → /telegram/webapp-auth, then 5-tab cockpit incl. deep-link intent routing)
│ └── (dashboard)/ │ └── (dashboard)/
│ ├── layout.tsx (dashboard shell: sidebar + header + connection status) │ ├── layout.tsx (dashboard shell: sidebar + header + connection status)
│ ├── overview/page.tsx (→ <CommandCenter/>) │ ├── overview/page.tsx (→ <CommandCenter/>)
@@ -189,7 +191,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card) │ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card)
│ ├── a2a/ (a2a-view.tsx = Conversations tab body, pure lift of the old /a2a page; a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils) │ ├── a2a/ (a2a-view.tsx = Conversations tab body, pure lift of the old /a2a page; a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings; mobile-task-board.tsx for /tg) │ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings; mobile-task-board.tsx for /tg)
│ ├── tg/ (tg-tab-bar, tg-approvals-tab, tg-inbox-tab, tg-board-tab, tg-chat-tab — the /tg cockpit's own tabs) │ ├── tg/ (tg-today-tab, tg-approvals-tab, tg-inbox-tab, tg-board-tab, tg-chat-tab, tg-tab-bar — the /tg cockpit's 5 tabs; ui.tsx, charts.tsx, tg-icons.tsx, motion.tsx, tg-task-sheet.tsx shared primitives)
│ │ └── approvals/ (use-approval-queue, primary-action, reject-form, release-detail, x-post-detail, video-post-detail, roadmap-item-detail — the native Approvals card stack)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card) │ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health) │ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers) │ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers)
@@ -203,7 +206,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
├── src/lib/ ├── src/lib/
│ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, auth, x, roadmap, …) │ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, auth, x, roadmap, …)
│ ├── websocket/connection.ts (WebSocketConnection + getWebSocketUrl) │ ├── websocket/connection.ts (WebSocketConnection + getWebSocketUrl)
│ ├── telegram/webapp.ts (window.Telegram.WebApp wrapper + waitForTelegramWebApp poll) │ ├── telegram/ (webapp.ts wrapper + waitForTelegramWebApp poll; hooks.tsx TgWebAppProvider/useMainButton/useBackButton; theme.ts themeParams→#tg-shell CSS vars; demo.ts/demo-data.ts the ?demo=1 fixtures)
│ ├── stores/ (scroll-restoration-store only; ui-store is sole-canonical in src/store/) │ ├── stores/ (scroll-restoration-store only; ui-store is sole-canonical in src/store/)
│ └── {constants,utils,agent-definitions,agent-utils,repo-url,mock-data}.ts │ └── {constants,utils,agent-definitions,agent-utils,repo-url,mock-data}.ts
├── src/proxy.ts (Next 16 rename of middleware.ts: gates (dashboard) behind cloud auth) ├── src/proxy.ts (Next 16 rename of middleware.ts: gates (dashboard) behind cloud auth)
@@ -312,6 +315,12 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`. > - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`.
> - `431cb2ae`+`70e53bc1` (2026-07-18, branch `feature/wave-9-agents-hub`, PR #558) — Agents hub: `/agents` gains Fleet + Conversations tabs (`?tab=`, Fleet default). The entire former `/a2a` page body moves into `components/a2a/a2a-view.tsx` (`A2AView`, a pure lift — its own `?conversation=` param keeps working, every writer now targets `/agents` preserving the rest of the query string) and a new `components/agents/agents-fleet-view.tsx` (`AgentsFleetView`) holds the old `/agents` roster body; `a2a/page.tsx` becomes a redirect shim → `/agents?tab=conversations`; the sidebar's standalone A2A nav entry is removed (folded into "Agents"). `AgentCard` gains a DM quick-action button (hidden for `EXCLUDE_NON_DM_ROLES`) pushing `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches the `?dm=` param open once per distinct value and strips it from the URL. Follow-up fix (`70e53bc1`): the deep-linked target was being trusted blindly — `A2ANewDmDialog` now only preselects it once validated against the live roster + `EXCLUDE_NON_DM_ROLES`, and the latch re-arms once `dm` clears so a repeated identical deep link still fires. > - `431cb2ae`+`70e53bc1` (2026-07-18, branch `feature/wave-9-agents-hub`, PR #558) — Agents hub: `/agents` gains Fleet + Conversations tabs (`?tab=`, Fleet default). The entire former `/a2a` page body moves into `components/a2a/a2a-view.tsx` (`A2AView`, a pure lift — its own `?conversation=` param keeps working, every writer now targets `/agents` preserving the rest of the query string) and a new `components/agents/agents-fleet-view.tsx` (`AgentsFleetView`) holds the old `/agents` roster body; `a2a/page.tsx` becomes a redirect shim → `/agents?tab=conversations`; the sidebar's standalone A2A nav entry is removed (folded into "Agents"). `AgentCard` gains a DM quick-action button (hidden for `EXCLUDE_NON_DM_ROLES`) pushing `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches the `?dm=` param open once per distinct value and strips it from the URL. Follow-up fix (`70e53bc1`): the deep-linked target was being trusted blindly — `A2ANewDmDialog` now only preselects it once validated against the live roster + `EXCLUDE_NON_DM_ROLES`, and the latch re-arms once `dm` clears so a repeated identical deep link still fires.
> - `cc57b9f1` (2026-07-18, branch `feature/wave-11-journals-tab`) — Journals joins the Agents hub as its third tab: `agents/page.tsx`'s `TabDef.value` gains `"journals"`, rendering `<JournalsView/>` in a third `TabsContent`. The entire former `/journals` page body moves into `components/journals/journals-view.tsx` (`JournalsView`, mirroring the `A2AView` lift — `agent`/`type`/`task` params keep working, every writer now targets `/agents` preserving the rest of the query string); `journals/page.tsx` becomes a redirect shim → `/agents?tab=journals`; `journals/[entryId]/page.tsx`'s three back-links retarget to `/agents?tab=journals` (the detail route itself is unchanged). `sidebar.tsx`'s standalone Journals nav entry is removed (its `BookOpen` icon import dropped) and the "Agents" tip text now reads "...A2A conversations, and journals"; `quick-actions-registry.ts`'s `journals` entry's `href` retargets `/journals``/agents?tab=journals` (mirroring the `a2a` entry's wave-9 retarget). > - `cc57b9f1` (2026-07-18, branch `feature/wave-11-journals-tab`) — Journals joins the Agents hub as its third tab: `agents/page.tsx`'s `TabDef.value` gains `"journals"`, rendering `<JournalsView/>` in a third `TabsContent`. The entire former `/journals` page body moves into `components/journals/journals-view.tsx` (`JournalsView`, mirroring the `A2AView` lift — `agent`/`type`/`task` params keep working, every writer now targets `/agents` preserving the rest of the query string); `journals/page.tsx` becomes a redirect shim → `/agents?tab=journals`; `journals/[entryId]/page.tsx`'s three back-links retarget to `/agents?tab=journals` (the detail route itself is unchanged). `sidebar.tsx`'s standalone Journals nav entry is removed (its `BookOpen` icon import dropped) and the "Agents" tip text now reads "...A2A conversations, and journals"; `quick-actions-registry.ts`'s `journals` entry's `href` retargets `/journals``/agents?tab=journals` (mirroring the `a2a` entry's wave-9 retarget).
> - `7e01c0ce` (2026-07-18, PR #570, "project-branded drafts + project badges") — the X and video post-queue API responses now carry `project_slug`/`project_name` (backend `api/schemas/project_fields.py` unloaded-guard helper); both `x-post-queue.tsx` and `video-post-queue.tsx` render the new shared `ProjectBadge` (`project-badge.tsx`) next to the existing source-kind badge, so a multi-project CEO can tell drafts apart. Also lands `CompanyGoalsService.resolve_product_name` (project name → charter `company_goals.company_name` → "RoboCo" fallback) so release posts/videos stop hardcoding "RoboCo"; `business/goals-tab.tsx` gains the company-name input backing it.
> - `29b375f1` (2026-07-18, "session links target the owning task") — fixes a dead-link bug: `/work-sessions/<id>` was never a real route. `work-session-card.tsx`'s "View Details" link is deleted outright; `work-session-table.tsx`'s 4 link sites (desktop-table row, table icon-button, mobile-card branch link, mobile icon-button) all retarget `href={`/tasks/${session.task_id}`}` instead, with tooltip copy reworded to "Open the owning task — the session's branch, commits, and PR live on its detail tabs".
> - `fc6d6f64` (2026-07-18, "CEO pairs join the switchboard matrix; sections collapsible") — two independent A2A switchboard fixes. (1) The switchboard's static pair matrix previously filtered by `is_human_only_role` (a spawn-semantics check) which dropped the CEO before `can_a2a_direct` (which explicitly allows CEO→anyone) ever ran, so the matrix carried zero CEO pairs — backend `agents_config.py` now excludes only `prompter`/`secretary`/`system` (matrix size 70→93; a later fix narrows it further, see `docs/map/prompts-roles-taxonomy.md`), and `a2a-switchboard-utils.ts` gains a `"ceo"` entry at the FRONT of `SECTION_ORDER` labeled `"CEO Direct"`. (2) Every switchboard section header becomes a collapse toggle: `a2a-switchboard.tsx` wraps each section in a Radix `Collapsible` (session-local `collapsed` state per `groupKey`, default open), with the `HelpTip` moved onto the inner label span rather than the `CollapsibleTrigger asChild` button — the same asChild-clobbers-`data-state` trap noted elsewhere in this doc's Gotchas (Switch/TabsTrigger).
> - **Telegram Mini App V4** (2026-07-19, PR #576 + #582 + `a072b980` fix, "Today brief, native approvals, live data, bot tier, chat bridges" + "premium Mini App cockpit") — the cockpit gets a 5th default-opening tab and a native-app visual overhaul. `TgTabBar` gains `"today"` as the first tab. New `TgTodayTab` renders off ONE `GET /telegram/today` round trip (TanStack Query, 45s `refetchInterval` + WS-invalidate on any `USAGE_SNAPSHOT` frame): `SpendHero` (cost figure, signed delta-vs-yesterday chip, a 7-day `Sparkline`), a `TgCircleAction` quick-action ring, a `NeedsYouBanner`, a live `TgAvatar` "Fleet" block, and a "Shipped this week" `DayBars` block. New shared primitives in `ui.tsx` (`TgCircleAction`/`TgAvatar`/`TgSection`/`TgRow`/`TgRowIcon`/`TgStat`, all new). `TgApprovalsTab` becomes a genuine native card stack over the new `approvals/` subdirectory, where `PrimaryAction` drives Telegram's `MainButton` via a new `useMainButton` hook and the tab itself wires `useBackButton` for focused-card back nav — both null-safely fall back to a visible button outside Telegram. `useApprovalQueue` normalizes all 4 held-draft sources into one list and surfaces `anyFailed` so a dead queue source is never silently rendered as "queue is clear". `TgChatTab` drops polling for the desktop A2A idiom: `useA2ALiveStream()` invalidates on every `a2a.message` frame with a 10s poll fallback only while `/ws/system` is down. New Telegram-native theme adoption: `webapp.ts` gains theme/button/haptics types + `createDevMockWebApp`/`isDevMockWebApp` (the `/tg?demo=1` dev-browser fallback, `NODE_ENV==="development"`-gated); new `hooks.tsx` + `theme.ts` apply `themeParams` → shadcn CSS vars **scoped to `#tg-shell`** (the desktop dashboard is untouched); `(tg)/layout.tsx` gains the `id="tg-shell"` scoping hook + Share Tech Mono font loading; `globals.css` ships a constant dark-amber `#tg-shell` skin independent of dashboard light/dark, that Telegram's `themeParams` inline-override on top of. `a072b980` (same-day fix) closes a prod-only bug: a plain-browser `/tg` visit gets the WebApp bridge with empty `initData` (the CDN script still loads outside Telegram), which was POSTed and 422'd into "Couldn't sign in" — `tg/page.tsx` now shows the "Open from Telegram" wall for ANY non-dev-mock bridge with empty `initData`. Follow-up polish (`c7605b0d` #582): `charts.tsx` (`Sparkline`/`DayBars`, theme-driven via `currentColor`); `tg-inbox-tab.tsx`/`tg-chat-tab.tsx` adopt `TgAvatar` tokens; `TgRowIcon` gains a `tone` prop so `tg-approvals-tab.tsx`'s `KIND_META` color-codes each queue kind; backend `/telegram/today` gains `spend.series`+`delta_pct`+`velocity` — see `docs/map/notification.md` for the backend side.
> - **"feature/tg-miniapp-v5"** (2026-07-19) — a further visual/interaction polish pass on the V4 cockpit, additive-only (the one relocation: Today's standalone "Approve" button becomes a 4-item ops ring — Approvals is still reachable via the tab bar). New `tg-icons.tsx` (175 lines) — 9 SVG duotone components (`IconToday`/`IconSeal`/`IconInbox`/`IconBoard`/`IconChat`/`IconShip`/`IconAckAll`/`IconSweep`/`IconFleet`), scoped to hero surfaces only (utility chrome stays lucide-react); wired into `tg-tab-bar.tsx` (the 5 tab icons) and `tg-today-tab.tsx` (the new ops ring). New `motion.tsx` (105 lines) — `useCountUp(target, durationMs=650)` (rAF ease-out-cubic, instant under `prefers-reduced-motion`) and `TgSheet` (bottom-sheet dialog wired to Telegram's native `BackButton`, backdrop-tap-to-close, haptic on open); used by the new `tg-task-sheet.tsx` and by `tg-today-tab.tsx`'s Fleet/Sweep sheets. New `tg-task-sheet.tsx` (151 lines) — `TgTaskSheet`: read-only task-detail bottom sheet (status badge, "bounced ×N" chip, ACs, up to 5 open revision findings via `useTaskFindings` with an overflow line, "Open PR #N" link; skips the findings fetch in demo mode); rendered from `tg-board-tab.tsx` alongside `MobileTaskBoard` via a new `onTaskPress` prop. New vendored `ShareTechMono-Regular.woff2` under `app/(tg)/fonts/`, loaded via `next/font/local` as `--font-share-tech` on `#tg-shell`, applied only to the display voice (labels/numerals/wordmark) — never body text. `tg-today-tab.tsx` (largest diff) replaces the old single "Approve" action with a 4-item ops ring (Ship/Ack-all/Sweep/Fleet): `SpendHero`'s numeral gains count-up, a new `FleetSheet` shows the full roster (opened by tapping the truncated 3-agent preview), `runAckAll` bulk-acks via the existing notifications API, `runSweep` calls the existing `gitApi.cleanupBranches` per has-token project (no new backend surface), plus a blinking-cursor "ROBOCO_" wordmark header. `demo-data.ts` gains `DEMO_TASKS`/`DEMO_NOTIFICATIONS` fixtures.
> - **"panel-perf-p3-p4"** (2026-07-19) — kanban + task-table + scorecards perf pass, paired with the backend N+1 fix in `docs/map/metrics-observability.md`. `kanban-board.tsx`: `tasksByStatus` grouping wrapped in `useMemo`, `handleAction` stabilized via `useCallback` + a `tasksRef` so child memoization actually holds. `kanban-card.tsx`: wrapped in `React.memo`. `kanban-column.tsx`: genuine windowing via the new `@tanstack/react-virtual` dependency — `useVirtualizer` (132px estimated card height, overscan 6) replaces a plain `.map()`, only visible virtual rows mount, absolute-positioned + `measureElement`'d for real-height correction; the column itself stays the dnd-kit droppable target so windowing doesn't break drag targeting. `task-table.tsx` (673 changed lines, no virtualization): row/card JSX extracted into top-level `memo`-wrapped `TaskTableRow`/`TaskTableCard`, fed stable props (`toggleExpand` via `useCallback`, lookup maps passed through) — pagination (pre-existing) is the actual bound on rendered rows, not windowing. `scorecards-tab.tsx`: `MemberRow` no longer self-fetches — `ScorecardsTabContent` calls the new `useAllMemberScorecards()` once and passes each row its slice via a `useMemo`'d Map; a table-level "Failed to load member scorecards" banner replaces the old per-row failure cell.
## Regression Risks ## Regression Risks
+24 -16
View File
@@ -11,16 +11,16 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `roboco/services/project.py` | CRUD + git-token encryption + cell access control for Projects (git repos) | 604 | | `roboco/services/project.py` | CRUD + git-token encryption + cell access control for Projects (git repos) | 604 |
| `roboco/services/product.py` | Product CRUD + per-cell `project_for` routing resolver + idempotent cell-map replace | 152 | | `roboco/services/product.py` | Product CRUD + per-cell `project_for` routing resolver + idempotent cell-map replace | 152 |
| `roboco/services/kanban.py` | Role-specific kanban board views (dev/qa/documenter/pm/main-pm/board) from task data | 587 | | `roboco/services/kanban.py` | Role-specific kanban board views (dev/qa/documenter/pm/main-pm/board) from task data | 587 |
| `roboco/services/company_goals.py` | CRUD for the singleton company charter (north star + objectives + constraints + policy) | 83 | | `roboco/services/company_goals.py` | CRUD for the singleton company charter (north star + objectives + constraints + policy + brand_voice + company_name); `resolve_product_name` is the shared product-name fallback chain `XEngine`/`VideoEngine` both call | 110 |
| `roboco/services/strategy_engine.py` | Dormant "engine 2": assesses company state vs goals, notify-only to CEO | 111 | | `roboco/services/strategy_engine.py` | Dormant "engine 2": assesses company state vs goals, notify-only to CEO | 111 |
| `roboco/services/research.py` | Pluggable web-search/fetch — provider adapters (Tavily/Brave/Exa/Null) + clamping service | 431 | | `roboco/services/research.py` | Pluggable web-search/fetch — provider adapters (Tavily/Brave/Exa/Null) + clamping service | 431 |
| `roboco/services/research_quota.py` | Per-agent UTC-daily Redis quota counter for research calls (fail-open) | 78 | | `roboco/services/research_quota.py` | Per-agent UTC-daily Redis quota counter for research calls (fail-open) | 78 |
| `roboco/services/pitch.py` | Board pitch CRUD + CEO approve → provision repos/Projects(+Product) + seed Main-PM task | 274 | | `roboco/services/pitch.py` | Board pitch CRUD + CEO approve → provision repos/Projects(+Product) + seed Main-PM task | 274 |
| `roboco/services/github_provisioning.py` | The only service that CREATES GitHub repos (POST `/orgs/{org}/repos`) | 174 | | `roboco/services/github_provisioning.py` | The only service that CREATES repos for pitch provisioning — now provider-aware (GitHub/Gitea/GitLab, Phase 4 forge parity), despite the GitHub-flavored name (kept for backward compatibility) | 232 |
| `roboco/services/roadmap_engine.py` | Dormant weekly engine: originates ONE held roadmap-exploration task for the Product Owner (default off) | 111 | | `roboco/services/roadmap_engine.py` | Dormant weekly engine: originates ONE held roadmap-exploration task for the Product Owner (default off) | 111 |
| `roboco/services/roadmap_service.py` | CEO's per-item approve/reject glue over a held roadmap cycle; approve materializes a BACKLOG task | 211 | | `roboco/services/roadmap_service.py` | CEO's per-item approve/reject glue over a held roadmap cycle; approve materializes a BACKLOG task | 211 |
| `roboco/api/routes/roadmap.py` | CEO-only routes: list open cycles, approve/reject one item | 124 | | `roboco/api/routes/roadmap.py` | CEO-only routes: list open cycles, approve/reject one item | 124 |
| `roboco/services/x_engine.py` | Dormant "engine 4": drafts X (Twitter) release posts (event hook), mention replies (poll), and — new — feature-spotlight explorations (dormant interval, spawns Head of Marketing), ALL held for CEO approval (default off) | 463 | | `roboco/services/x_engine.py` | Dormant "engine 4": drafts X (Twitter) release posts (event hook), mention replies (poll), and feature-spotlight explorations (dormant interval, spawns Head of Marketing), ALL held for CEO approval (default off); prompt builders take a `product_name` param resolved via `CompanyGoalsService.resolve_product_name` instead of hardcoding "RoboCo" | 871 |
| `roboco/services/x_post_service.py` | CEO's approve/reject over a held X draft; approve posts via a Redis single-flight lock, idempotent on already-posted AND on already-rejected (CANCELLED) | 298 | | `roboco/services/x_post_service.py` | CEO's approve/reject over a held X draft; approve posts via a Redis single-flight lock, idempotent on already-posted AND on already-rejected (CANCELLED) | 298 |
| `roboco/services/x_client.py` | OAuth 1.0a HMAC-SHA1 X API client (`LiveXClient`) + `NullXClient` (no creds, never egresses) + `build_x_client` factory | 318 | | `roboco/services/x_client.py` | OAuth 1.0a HMAC-SHA1 X API client (`LiveXClient`) + `NullXClient` (no creds, never egresses) + `build_x_client` factory | 318 |
| `roboco/services/x_credentials.py` | Singleton Fernet-encrypted OAuth 1.0a credential CRUD; decrypts server-side only | 140 | | `roboco/services/x_credentials.py` | Singleton Fernet-encrypted OAuth 1.0a credential CRUD; decrypts server-side only | 140 |
@@ -77,12 +77,13 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `PitchService._register_topology` | method | pitch.py:201 | Multi-cell → Product (reuse existing by slug + refresh cell map); single-cell → seed project only | | `PitchService._register_topology` | method | pitch.py:201 | Multi-cell → Product (reuse existing by slug + refresh cell map); single-cell → seed project only |
| `PitchService._seed_main_pm_task` | method | pitch.py:234 | Creates PENDING Main-PM CODE task (`source="pitch"`, `confirmed_by_human=True`) | | `PitchService._seed_main_pm_task` | method | pitch.py:234 | Creates PENDING Main-PM CODE task (`source="pitch"`, `confirmed_by_human=True`) |
| `PitchService._proposed_or_raise` | method | pitch.py:152 | 404 if missing, 409 if not `proposed` (no re-deciding) | | `PitchService._proposed_or_raise` | method | pitch.py:152 | 404 if missing, 409 if not `proposed` (no re-deciding) |
| `GitHubProvisioningService` | class | github_provisioning.py:45 | Create private repos in configured org | | `GitHubProvisioningService` | class | github_provisioning.py:81 | Create private repos for pitch provisioning — Phase 4 forge parity: provider-dispatched via `_build_provider`, not GitHub-only despite the class name |
| `GitHubProvisioningService.enabled` | prop | github_provisioning.py:67 | True only when master switch + token + org all set | | `GitHubProvisioningService.enabled` | prop | github_provisioning.py:123 | True only when master switch + token + org all set; ALSO requires `ROBOCO_PROVISIONING_HOST` when the provider is gitlab/gitea (self-hosted needs a host, github.com doesn't) |
| `GitHubProvisioningService.create_repo` | method | github_provisioning.py:81 | POST `/orgs/{org}/repos` with `auto_init=true`; handles GitHub 422 "already exists" idempotently via `_fetch_existing_repo` (#83/#84) | | `GitHubProvisioningService.create_repo` | method | github_provisioning.py:142 | Provider-dispatched repo creation with `auto_init=true`; handles the "already exists" response idempotently across all three forges via `_fetch_existing_repo`/`_is_already_exists` (#83/#84) |
| `GitHubProvisioningService._fetch_existing_repo` | method | github_provisioning.py:140 | GET `org/name` and reconstruct `ProvisionedRepo` — called on 422 to reuse an orphaned repo from a rolled-back prior approval | | `GitHubProvisioningService._fetch_existing_repo` | method | github_provisioning.py:203 | GET the existing repo and reconstruct `ProvisionedRepo` — called on an "already exists" response to reuse an orphaned repo from a rolled-back prior approval |
| `_GITHUB_REPO_EXISTS_STATUS` | constant | github_provisioning.py:42 | `422` — GitHub's "name already exists" status sentinel | | `_build_provider` | func | github_provisioning.py:68 | Picks the concrete provider (`GitHubProvider`/`GiteaProvider`/`GitLabProvider` — a `Union`, not the `GitProvider` ABC, since provisioning needs `client=`/`timeout=` kwargs the ABC doesn't declare) by `ROBOCO_PROVISIONING_PROVIDER` |
| `ProvisionedRepo` / `ProvisioningError` / `ProvisioningDisabledError` | dataclass/exc | github_provisioning.py:32 / 23 / 27 | Result + error types | | `_is_already_exists` | func | github_provisioning.py:61 | Matches GitHub's 422, Gitea's 409/422, and GitLab's reshaped 422 "already exists"/"has already been taken" by status+phrase |
| `ProvisionedRepo` / `ProvisioningError` / `ProvisioningDisabledError` | dataclass/exc | github_provisioning.py | Result + error types |
| `RoadmapEngine` | class | roadmap_engine.py:49 | Dormant "engine 3": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape, but the artifact is a cycle the PO *authors*, not a report the engine assembles | | `RoadmapEngine` | class | roadmap_engine.py:49 | Dormant "engine 3": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape, but the artifact is a cycle the PO *authors*, not a report the engine assembles |
| `RoadmapEngine.run_cycle` | method | roadmap_engine.py:54 | No-op unless `roadmap_engine_enabled`, a cycle is already open (`list_open_roadmap_cycles`), or the RoboCo project isn't resolvable; else opens ONE held PENDING exploration task assigned to the Product Owner | | `RoadmapEngine.run_cycle` | method | roadmap_engine.py:54 | No-op unless `roadmap_engine_enabled`, a cycle is already open (`list_open_roadmap_cycles`), or the RoboCo project isn't resolvable; else opens ONE held PENDING exploration task assigned to the Product Owner |
| `RoadmapService` | class | roadmap_service.py:50 | List / approve / reject items within the open roadmap cycle(s) | | `RoadmapService` | class | roadmap_service.py:50 | List / approve / reject items within the open roadmap cycle(s) |
@@ -92,19 +93,21 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `RoadmapService._maybe_complete_cycle` | staticmethod | roadmap_service.py:202 | Completes the exploration task once every item on it is terminal (approved/rejected) | | `RoadmapService._maybe_complete_cycle` | staticmethod | roadmap_service.py:202 | Completes the exploration task once every item on it is terminal (approved/rejected) |
| `RoadmapItemResult` | dataclass | roadmap_service.py:37 | Outcome of one approve/reject call (status/item_id/materialized_task_id/detail) | | `RoadmapItemResult` | dataclass | roadmap_service.py:37 | Outcome of one approve/reject call (status/item_id/materialized_task_id/detail) |
| `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors | | `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors |
| `XEngine` | class | x_engine.py:150 | Dormant "engine 4": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape across THREE responsibilities — release posts, mention replies, feature spotlights | | `XEngine` | class | x_engine.py:230 | Dormant "engine 4": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape across THREE responsibilities — release posts, mention replies, feature spotlights |
| `XEngine._voice_guide` | method | x_engine.py:173 | Baseline house-voice constant (`_HOM_VOICE`) plus the CEO's `company_goals.brand_voice` sample when set — feeds release/reply prompts AND is the mechanism the HoM identity file points to for its own drafting | | `XEngine._voice_guide` | method | x_engine.py:259 | `_voice_guide(product_name)`: baseline house-voice constant (`_HOM_VOICE`) plus the CEO's `company_goals.brand_voice` sample when set — feeds release/reply prompts AND is the mechanism the HoM identity file points to for its own drafting; `product_name` is resolved once per call site via `CompanyGoalsService.resolve_product_name(project)` (project's own name → charter `company_name` → "RoboCo" literal), not hardcoded |
| `XEngine.draft_release_post` | method | x_engine.py:192 | Event-driven hook (called from `ReleaseProposalService.approve`'s publish-success branch); local-model-drafted, deduped per version, capped by `x_max_open_posts` | | `XEngine.draft_release_post` | method | x_engine.py:279 | Event-driven hook (called from `ReleaseProposalService.approve`'s publish-success branch); local-model-drafted, deduped per version, capped by `x_max_open_posts`; resolves and threads `product_name` from the release's own project |
| `XEngine.run_cycle` | method | x_engine.py:255 | Periodic mentions poll; no-op unless `x_engine_enabled` AND `x_replies_enabled`; filters bot-like/low-engagement mentions, dedupes by mention id (`XSeenMentionTable`); each mention's text is run through `screen_external_text` before the local-model reply prompt sees it | | `XEngine.run_cycle` | method | x_engine.py:353 | Periodic mentions poll; no-op unless `x_engine_enabled` AND `x_replies_enabled`; filters bot-like/low-engagement mentions, dedupes by mention id (`XSeenMentionTable`); each mention's text is run through `screen_external_text` before the local-model reply prompt sees it; resolves `product_name` once per cycle and threads it through `_originate_reply`/`_draft_reply_body`/`_reply_prompt` |
| `screen_external_text` | function | foundation/policy/injection_guard.py:95 | Shared screen-and-neutralize guard for unattended attacker-writable text feeds (X mentions, vault inbox notes): wraps the text in an untrusted-content envelope and flags any matched injection-pattern LINE inline — nothing is removed, so the CEO-facing draft still shows what the source really said | | `screen_external_text` | function | foundation/policy/injection_guard.py:95 | Shared screen-and-neutralize guard for unattended attacker-writable text feeds (X mentions, vault inbox notes): wraps the text in an untrusted-content envelope and flags any matched injection-pattern LINE inline — nothing is removed, so the CEO-facing draft still shows what the source really said |
| `XEngine.open_feature_spotlight_exploration` | method | x_engine.py:337 | No-ops unless `x_engine_enabled` AND `x_feature_spotlight_enabled`, no creds, a cycle already open, the open-post cap reached, or project unresolvable; else opens ONE held PENDING exploration task for the Head of Marketing (`source=x_feature_exploration`) carrying a `x_seen_features` marker snapshot | | `XEngine.open_feature_spotlight_exploration` | method | x_engine.py:487 | No-ops unless `x_engine_enabled` AND `x_feature_spotlight_enabled`, no creds, a cycle already open, the open-post cap reached, or project unresolvable; else opens ONE held PENDING exploration task for the Head of Marketing (`source=x_feature_exploration`) carrying a `x_seen_features` marker snapshot; the description is built by `_feature_exploration_description(product_name)`, no longer the fixed `_FEATURE_EXPLORATION_DESCRIPTION` string |
| `XEngine.materialize_feature_spotlight` | method | x_engine.py:433 | Called from the `propose_feature_spotlight` do-tool: marks the feature slug seen (`XSeenFeatureTable`), creates the held draft (`source=x_feature`, identical shape to a release/reply draft), completes the exploration task | | `XEngine.materialize_feature_spotlight` | method | x_engine.py:841 | Called from the `propose_feature_spotlight` do-tool: marks the feature slug seen (`XSeenFeatureTable`), creates the held draft (`source=x_feature`, identical shape to a release/reply draft), completes the exploration task |
| `XPostService.approve` | method | x_post_service.py:92 | The ONLY caller of `x_client.post_tweet`; Redis single-flight lock, re-reads task under lock, idempotent on an already-posted draft (`already_posted`); a CANCELLED draft is refused both pre-lock and re-checked under lock (`already_rejected`) — a stale approve (e.g. a queued Telegram button) can't resurrect a draft the CEO already rejected | | `XPostService.approve` | method | x_post_service.py:92 | The ONLY caller of `x_client.post_tweet`; Redis single-flight lock, re-reads task under lock, idempotent on an already-posted draft (`already_posted`); a CANCELLED draft is refused both pre-lock and re-checked under lock (`already_rejected`) — a stale approve (e.g. a queued Telegram button) can't resurrect a draft the CEO already rejected |
| `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft | | `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft |
| `XClient` / `NullXClient` / `LiveXClient` | ABC/class | x_client.py:150 / 166 / 186 | `NullXClient.configured` is False (no creds) — drafting still runs (content nobody can post is a no-op upstream), just never originates; `LiveXClient` signs OAuth 1.0a HMAC-SHA1 | | `XClient` / `NullXClient` / `LiveXClient` | ABC/class | x_client.py:150 / 166 / 186 | `NullXClient.configured` is False (no creds) — drafting still runs (content nobody can post is a no-op upstream), just never originates; `LiveXClient` signs OAuth 1.0a HMAC-SHA1 |
| `build_x_client` | factory | x_client.py:306 | Returns `LiveXClient` when credentials decrypt, else `NullXClient` | | `build_x_client` | factory | x_client.py:306 | Returns `LiveXClient` when credentials decrypt, else `NullXClient` |
| `XCredentialsService.set_credentials` / `.get_decrypted` | method | x_credentials.py:61 / 116 | All-or-nothing Fernet-encrypted singleton credential set/clear; decrypts server-side only, never exposed to agents | | `XCredentialsService.set_credentials` / `.get_decrypted` | method | x_credentials.py:61 / 116 | All-or-nothing Fernet-encrypted singleton credential set/clear; decrypts server-side only, never exposed to agents |
| `get_x_engine` | factory | x_engine.py:461 | Session-bound constructor (optional injected `XClient` for tests) | | `get_x_engine` | factory | x_engine.py:869 | Session-bound constructor (optional injected `XClient` for tests) |
| `CompanyGoalsService.resolve_product_name` | method | company_goals.py:79 | The shared product-name fallback chain: `project.name` if set, else the charter's `company_name`, else the "RoboCo" literal — single source so `XEngine`/`VideoEngine` can't drift apart on branding |
| `task_project_fields` | func | api/schemas/project_fields.py:19 | `(project_slug, project_name)` or `(None, None)` for a task response — `sa_inspect(task).unloaded` guard before touching `task.project` (a freshly-created task can have an unloaded relationship); shared by the X and video queue response builders so a multi-project CEO can tell drafts apart via the panel's `ProjectBadge` |
## Data Flow ## Data Flow
@@ -319,6 +322,8 @@ product-strategy-research-pitch
| `ROBOCO_GITHUB_API_BASE_URL` | `https://api.github.com` | config.py:327 | Override for GitHub Enterprise | | `ROBOCO_GITHUB_API_BASE_URL` | `https://api.github.com` | config.py:327 | Override for GitHub Enterprise |
| `ROBOCO_PROVISIONING_TIMEOUT_SECONDS` | `30.0` | config.py:331 | Outbound GitHub provisioning timeout | | `ROBOCO_PROVISIONING_TIMEOUT_SECONDS` | `30.0` | config.py:331 | Outbound GitHub provisioning timeout |
| `ROBOCO_PROVISIONING_REPO_PRIVATE` | `True` | config.py:336 | Whether provisioned repos are private | | `ROBOCO_PROVISIONING_REPO_PRIVATE` | `True` | config.py:336 | Whether provisioned repos are private |
| `ROBOCO_PROVISIONING_PROVIDER` | `github` | config.py:570 | Phase 4 forge parity: `github`/`gitlab`/`gitea` selects the concrete provisioning target via `_build_provider` |
| `ROBOCO_PROVISIONING_HOST` | `""` | config.py:579 | Self-hosted forge host (e.g. `gitlab.example.com`); `.enabled` additionally requires this when `provisioning_provider` is gitlab/gitea (ignored for github/gitlab.com) |
| `ROBOCO_STRATEGY_ENGINE_ENABLED` | `False` | config.py:348 | Master switch — loop never starts when off | | `ROBOCO_STRATEGY_ENGINE_ENABLED` | `False` | config.py:348 | Master switch — loop never starts when off |
| `ROBOCO_STRATEGY_ENGINE_INTERVAL_SECONDS` | `1800` | config.py:354 | Seconds between strategy assessment passes | | `ROBOCO_STRATEGY_ENGINE_INTERVAL_SECONDS` | `1800` | config.py:354 | Seconds between strategy assessment passes |
| `ROBOCO_STRATEGY_STRANDED_BLOCKED_MINUTES` | `120` | config.py:360 | Blocked-task threshold for "stranded" observation | | `ROBOCO_STRATEGY_STRANDED_BLOCKED_MINUTES` | `120` | config.py:360 | Blocked-task threshold for "stranded" observation |
@@ -367,6 +372,9 @@ product-strategy-research-pitch
> - `b3558d4e` ([chore] complexity: split 5 C-rank blocks to <=B, 2026-06-30): `kanban.py` `get_main_pm_board_flat` — refactored if/elif routing to a dict-dispatch (`status_col` + `team_col` maps) for xenon complexity gate; no functional change. > - `b3558d4e` ([chore] complexity: split 5 C-rank blocks to <=B, 2026-06-30): `kanban.py` `get_main_pm_board_flat` — refactored if/elif routing to a dict-dispatch (`status_col` + `team_col` maps) for xenon complexity gate; no functional change.
> - **v0.18.0** (2026-07-04): the X feature-spotlight content in this slice (`XEngine` feature-spotlight methods, `_x_feature_spotlight_loop`/`_dispatch_feature_spotlight_exploration`, migration 061, `x_feature_spotlight_enabled`) was authored directly into this file's Files/Key Symbols/Data Flow/Mermaid/Logical Tree/Entry Points sections at implementation time rather than landing as a dated delta — noted here for changelog continuity; the body text above is current as of this date. (Config Flags is unchanged — the X-engine flags live in deployment-tooling.md's comprehensive list, not here.) > - **v0.18.0** (2026-07-04): the X feature-spotlight content in this slice (`XEngine` feature-spotlight methods, `_x_feature_spotlight_loop`/`_dispatch_feature_spotlight_exploration`, migration 061, `x_feature_spotlight_enabled`) was authored directly into this file's Files/Key Symbols/Data Flow/Mermaid/Logical Tree/Entry Points sections at implementation time rather than landing as a dated delta — noted here for changelog continuity; the body text above is current as of this date. (Config Flags is unchanged — the X-engine flags live in deployment-tooling.md's comprehensive list, not here.)
> - `11915f36` (PR #551, Telegram V2 security follow-up, 2026-07-17): `x_post_service.py``XPostService.approve`/`_approve_locked` add a CANCELLED-task guard (pre-lock and re-checked under lock) returning a new `already_rejected` status, closing a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call). > - `11915f36` (PR #551, Telegram V2 security follow-up, 2026-07-17): `x_post_service.py``XPostService.approve`/`_approve_locked` add a CANCELLED-task guard (pre-lock and re-checked under lock) returning a new `already_rejected` status, closing a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call).
> - `7e01c0ce` (PR #570, "project-branded drafts + project badges", 2026-07-18): migration 075 adds `company_goals.company_name`; `CompanyGoalsService.resolve_product_name` (company_goals.py:79) is the new single fallback chain (project name → charter `company_name` → "RoboCo") consumed by both `XEngine._voice_guide`/`draft_release_post` and `VideoEngine` (see `docs/map/video-engine.md`) so their prompt builders stop hardcoding "RoboCo". New `roboco/api/schemas/project_fields.py`'s `task_project_fields` helper adds `project_slug`/`project_name` to the X and video post-queue API responses (`api/routes/x.py`, `api/routes/video.py`); the panel renders them via a shared `ProjectBadge` — see `docs/map/panel.md`.
> - `461a6e1a`+`96401f4c`+`5f32d876` (Phases 1/2-3/4, 2026-07-18/19, #571/#575/#581) — Phase 4 makes `GitHubProvisioningService` provider-aware: `_build_provider` (github_provisioning.py:68) dispatches to `GitHubProvider`/`GiteaProvider`/`GitLabProvider` by `ROBOCO_PROVISIONING_PROVIDER`, `.enabled` additionally requires `ROBOCO_PROVISIONING_HOST` for gitlab/gitea, and `_is_already_exists` (github_provisioning.py:61) matches the "already exists" idempotency signal across all three forges' differing status codes/phrasing. The forge transport package itself (`GitProvider`/`ForgeRouter`/provider implementations) is documented in `docs/map/worksession-git.md` — this slice only covers the provisioning consumer.
> - `a0baf94b` ("agnosticism-residue", agnosticism audit items B6/B8): `x_engine.py`'s remaining hardcoded `"RoboCo"` literals (the reply-prompt builder and the feature-spotlight exploration description — `draft_release_post`/`_voice_guide` were already fixed by `7e01c0ce` above) are threaded out: `_reply_prompt` gains a `product_name` param, `_FEATURE_EXPLORATION_DESCRIPTION` (a module constant) becomes `_feature_exploration_description(product_name)` (a function), and `run_cycle`/`open_feature_spotlight_exploration` each resolve `product_name` once via `resolve_product_name` and thread it through.
## Regression Risks ## Regression Risks
+7 -1
View File
@@ -116,8 +116,10 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
| _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) | | _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) |
| _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) | | _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) |
| _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) | | _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) |
| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via notify, board/main_pm/cell-member via handlers | | can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via notify, board/main_pm/cell-member via handlers; the CEO branch now consults `_check_ceo_a2a` (below) instead of an unconditional `True` |
| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (auditor/pr_reviewer/prompter/secretary — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed |
| get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message | | get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message |
| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 88 (`ceo` group 18) after `_check_ceo_a2a` excludes no-comms roles; the panel switchboard's section matrix reads off this same computation |
| _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order | | _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order |
| detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None | | detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None |
| refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) | | refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) |
@@ -277,6 +279,10 @@ prompts-roles-taxonomy slice
> **v0.19.0** (2026-07-05): Ponytail build-laziness doctrine bundled with Fable — `ponytail_doctrine_layer(prompts_path, role)` in `roboco/agents/factories/_base.py`, gated on the same `fable_mode_enabled` flag (no separate flag — ponytail is Fable's complementary build-doctrine), slotted into compose_prompt immediately after `fable_doctrine_layer`. Role-scoped: developers (`AgentRole.DEVELOPER`) → `agents/prompts/doctrine/ponytail.md` (the full ladder: YAGNI → reuse-in-this-codebase → stdlib → native-platform → installed-dep → one-line → minimal, the rules, the Intensity table, the `ponytail:` comment convention) plus a trailing `**Operative intensity: {settings.ponytail_intensity}.**` directive; every other role → `agents/prompts/doctrine/ponytail-ethos.md` (ethos-only — the code-mechanics rungs and the Intensity table are dropped so they can't leak into prose artifacts like task plans / review notes / docs). Both files vendored from the ponytail plugin (MIT, Copyright (c) 2026 DietrichGebert), trimmed, YAML frontmatter stripped, and carry a 5-point RoboCo preamble that makes the ladder yield to the Architectural Conventions Standard (placement), the 80% coverage gate + QA review + self-verification, the per-team design bar, task hygiene, and reviewer feedback — overlap mitigated by scoping, not deletion. `ROBOCO_PONYTAIL_INTENSITY` (lite/full/ultra, default full; `roboco/config.py` `ponytail_intensity`, a string value — NOT a feature flag) selects the developer's operative intensity; non-developers get no dial. Prompt-only — no hooks, no grok-path changes; a flag-off spawn is byte-for-byte unchanged. > **v0.19.0** (2026-07-05): Ponytail build-laziness doctrine bundled with Fable — `ponytail_doctrine_layer(prompts_path, role)` in `roboco/agents/factories/_base.py`, gated on the same `fable_mode_enabled` flag (no separate flag — ponytail is Fable's complementary build-doctrine), slotted into compose_prompt immediately after `fable_doctrine_layer`. Role-scoped: developers (`AgentRole.DEVELOPER`) → `agents/prompts/doctrine/ponytail.md` (the full ladder: YAGNI → reuse-in-this-codebase → stdlib → native-platform → installed-dep → one-line → minimal, the rules, the Intensity table, the `ponytail:` comment convention) plus a trailing `**Operative intensity: {settings.ponytail_intensity}.**` directive; every other role → `agents/prompts/doctrine/ponytail-ethos.md` (ethos-only — the code-mechanics rungs and the Intensity table are dropped so they can't leak into prose artifacts like task plans / review notes / docs). Both files vendored from the ponytail plugin (MIT, Copyright (c) 2026 DietrichGebert), trimmed, YAML frontmatter stripped, and carry a 5-point RoboCo preamble that makes the ladder yield to the Architectural Conventions Standard (placement), the 80% coverage gate + QA review + self-verification, the per-team design bar, task hygiene, and reviewer feedback — overlap mitigated by scoping, not deletion. `ROBOCO_PONYTAIL_INTENSITY` (lite/full/ultra, default full; `roboco/config.py` `ponytail_intensity`, a string value — NOT a feature flag) selects the developer's operative intensity; non-developers get no dial. Prompt-only — no hooks, no grok-path changes; a flag-off spawn is byte-for-byte unchanged.
> >
> **PR #544** (2026-07-17, `fd621f0d`): The design bar's web dials (`DESIGN_VARIANCE`/`MOTION_INTENSITY`/`VISUAL_DENSITY`) were silently steering a `source=video` authoring task toward "dense product UI → motion 2-3" — wrong for a marketing film. `agents/prompts/teams/ux_ui.md` gains a one-line video-mode override (see Files above) telling the dev the web dials do not apply to a video-authoring task and pointing it at `motion/README.md`'s cinematography bar + the vendored `motion/skills/` doctrine instead. Doc-only within this slice — the runtime half of the same PR (playwright MCP registered for the video-authoring ux-dev spawn, `_is_video_authoring_spawn`) is documented in `docs/map/video-engine.md` and `docs/map/orchestrator.md`. > **PR #544** (2026-07-17, `fd621f0d`): The design bar's web dials (`DESIGN_VARIANCE`/`MOTION_INTENSITY`/`VISUAL_DENSITY`) were silently steering a `source=video` authoring task toward "dense product UI → motion 2-3" — wrong for a marketing film. `agents/prompts/teams/ux_ui.md` gains a one-line video-mode override (see Files above) telling the dev the web dials do not apply to a video-authoring task and pointing it at `motion/README.md`'s cinematography bar + the vendored `motion/skills/` doctrine instead. Doc-only within this slice — the runtime half of the same PR (playwright MCP registered for the video-authoring ux-dev spawn, `_is_video_authoring_spawn`) is documented in `docs/map/video-engine.md` and `docs/map/orchestrator.md`.
>
> **"taste-skill-aesthetics"** (deferred half of the Design bar work): two more `Leonxlnx/taste-skill` (MIT) distillations layered onto the core Design bar. A `## Niche aesthetic vocabularies` section — identical body in both `agents/prompts/teams/frontend.md` and `ux_ui.md` — names three opt-in visual systems (industrial brutalist, minimalist editorial, premium agency), each keyed onto the same three dials (a vocabulary changes *what* the dials produce, never whether they apply); picked only when a task brief explicitly calls for one. A `## Image direction` section lives in `ux_ui.md` only (composition variety, palette discipline, anti-slop imagery, iconography, mockup/device-frame conventions, cross-asset set consistency) — `frontend.md` carries a one-line pointer instead of duplicating it. Doc-only, same `tests/unit/agents/test_design_bar_layer.py` guard as the core bar; no compose_prompt/flag change.
>
> **CEO A2A pairs, two sequential fixes.** `fc6d6f64` (2026-07-18, "CEO pairs join the switchboard matrix"): the static pair matrix previously filtered by `is_human_only_role` (a spawn-semantics check) which dropped the CEO before `can_a2a_direct` (which explicitly allows CEO→anyone) ever ran, so `A2A_ALLOWED_PAIRS` carried ZERO CEO pairs; `agents_config.py` now excludes only `prompter`/`secretary`/`system` from that pre-filter (matrix size 70→93). `56b6693e` ("security-hygiene-sweep") then narrows it correctly: `_check_ceo_a2a` (agents_config.py:635, consulted from `can_a2a_direct`'s CEO branch — previously an unconditional `True`) refuses a CEO target in `NO_COMMS_ROLES` (see `docs/map/foundation-policy-misc.md`), shrinking `A2A_ALLOWED_PAIRS` 93→88 (`ceo` group 23→18) with no separate matrix edit — the derivation is fully automatic off `can_a2a_direct`. See `docs/map/panel.md` for the switchboard's `"CEO Direct"` section + collapsible-sections panel change, and `docs/map/a2a-audit-journal-permissions.md` for the conversation-creation-time refusal.
## Regression Risks ## Regression Risks
+6 -1
View File
@@ -23,14 +23,18 @@ The pytest test suite for RoboCo: 571 test_*.py files across tests/foundation, t
| tests/unit/ | ~430 unit tests mirroring roboco/: agents, api (+routes/+schemas), billing, config, conventions, db, enforcement, events, foundation/policy, gateway (105), llm, mcp_servers, migrations, models, runtime (64), scripts, services (120), templates, utils | | | tests/unit/ | ~430 unit tests mirroring roboco/: agents, api (+routes/+schemas), billing, config, conventions, db, enforcement, events, foundation/policy, gateway (105), llm, mcp_servers, migrations, models, runtime (64), scripts, services (120), templates, utils | |
| tests/unit/gateway/ | 105 Choreographer/verb-runner unit tests — the largest single cluster: every intent verb's guards, envelopes, evidence, claim locks, lane barrier, pr gate, conventions gate, content actions | | | tests/unit/gateway/ | 105 Choreographer/verb-runner unit tests — the largest single cluster: every intent verb's guards, envelopes, evidence, claim locks, lane barrier, pr gate, conventions gate, content actions | |
| tests/unit/runtime/ | 64 orchestrator unit tests: spawn/manifest/cwd/worktree, reaper, respawn persistence, rate-limit/overload sweeps, ci_watch/dep_update/release/self_heal loops, no_spawn_human_roles, per_dev_lane_queue, readopt_running_agents | | | tests/unit/runtime/ | 64 orchestrator unit tests: spawn/manifest/cwd/worktree, reaper, respawn persistence, rate-limit/overload sweeps, ci_watch/dep_update/release/self_heal loops, no_spawn_human_roles, per_dev_lane_queue, readopt_running_agents | |
| tests/unit/services/ | 120 service unit tests: task, git (+worktree), workspace, work_session, release_executor/readiness/manager, sequencing, conventions, playbook, notification, rate_limit_tracker, optimal_brain/ (10) | | | tests/unit/services/ | 120+ service unit tests: task, git (+worktree), workspace, work_session, release_executor/readiness/manager, sequencing, conventions, playbook, notification, rate_limit_tracker, optimal_brain/ (10), forge/ (`test_gitea_provider.py`, `test_gitlab_provider.py`, `test_router.py` — mocked-transport coverage of the forge package, distinct from the live contract suites below) | |
| tests/e2e_smoke/ | Scripted-agent smoke tests against the live in-process orchestrator; separate from the default pytest collection (run by the PR/NAS smoke gate) | | | tests/e2e_smoke/ | Scripted-agent smoke tests against the live in-process orchestrator; separate from the default pytest collection (run by the PR/NAS smoke gate) | |
| tests/e2e_smoke/harness.py | E2E harness: E2EStack app + orchestrator client + per-test agent manifests; used by the e2e_smoke tier | ~520 | | tests/e2e_smoke/harness.py | E2E harness: E2EStack app + orchestrator client + per-test agent manifests; used by the e2e_smoke tier | ~520 |
| tests/e2e_smoke/test_gitea_live.py | Live-Gitea contract suite for `GiteaProvider` — fully self-seeding (creates its own uniquely-named repo, pushes real commits) against a real Gitea instance; skipped unless `ROBOCO_GITEA_E2E_URL`/`ROBOCO_GITEA_E2E_TOKEN` are both set; exercises PR open → duplicate-409→422 reshape → list/filter → diff → comment review → commit-status CI reshape → squash merge → branch delete → release, plus the git-CLI Basic-auth extraheader claim | 250 |
| tests/e2e_smoke/test_gitlab_live.py | Live-GitLab contract suite for `GitLabProvider` — mirrors `test_gitea_live.py`, self-seeding against a real GitLab instance (gitlab.com works, project deleted afterward best-effort); skipped unless `ROBOCO_GITLAB_E2E_URL`/`ROBOCO_GITLAB_E2E_TOKEN` are both set; exercises MR open → duplicate reshape → GitHub-shape adaptation → diff reassembly → note review → commit-status CI reshape → squash merge → branch delete → release → the oauth2 Basic-auth git-CLI claim | 265 |
## E2E smoke harness ## E2E smoke harness
The `tests/e2e_smoke/` tier runs scripted agents against a real in-process FastAPI app and orchestrator. It is **not** collected by the default `uv run pytest` invocation; it is exercised separately by the PR gate / NAS smoke run. The harness in `tests/e2e_smoke/harness.py` builds an `E2EStack`, mounts the orchestrator client, and gives each test a per-agent manifest. The `tests/e2e_smoke/` tier runs scripted agents against a real in-process FastAPI app and orchestrator. It is **not** collected by the default `uv run pytest` invocation; it is exercised separately by the PR gate / NAS smoke run. The harness in `tests/e2e_smoke/harness.py` builds an `E2EStack`, mounts the orchestrator client, and gives each test a per-agent manifest.
**Live-forge contract suites** (`test_gitea_live.py` / `test_gitlab_live.py`) are a different animal from the scripted-agent harness above — no `E2EStack`, no orchestrator, no manifests. Each drives its `GitProvider` implementation directly against a REAL forge instance (a dockerized `gitea/gitea` or real `gitlab.com`), fully self-seeding (creates its own repo/project, pushes real commits via subprocess `git`, tears down after). Both are `pytest.mark.skipif`'d off unless their pair of `ROBOCO_{GITEA,GITLAB}_E2E_{URL,TOKEN}` env vars is set, so a normal CI/local run never attempts network I/O against a real forge. These are what caught the slash-encoding (branch names with `/` need `quote()`-encoding before hitting Gitea's router) and the http-vs-https scheme gaps that the mocked-transport unit tests in `tests/unit/services/forge/` could not have found, since those mock the HTTP layer entirely.
Recent harness hardening for the auditor-revival slice (PR #498, task `8323cd50`): Recent harness hardening for the auditor-revival slice (PR #498, task `8323cd50`):
- `tests/conftest.py` now catches a missing `pgvector` extension when creating the ephemeral test database and continues with a warning. The core schema does not require it and the e2e smoke suite does not exercise RAG, so lightweight Postgres sandboxes can run the suite without the pgvector package. - `tests/conftest.py` now catches a missing `pgvector` extension when creating the ephemeral test database and continues with a warning. The core schema does not require it and the e2e smoke suite does not exercise RAG, so lightweight Postgres sandboxes can run the suite without the pgvector package.
@@ -240,6 +244,7 @@ tests/
> - **1f129199** auditor-trigger e2e smoke test: added `tests/e2e_smoke/test_auditor_triggers.py` exercising scheduled sweep and reactive QA-fail alert paths end-to-end, plus harness mount of `/api/notifications` so `_dispatch_audit_work` can poll ALERT rows. > - **1f129199** auditor-trigger e2e smoke test: added `tests/e2e_smoke/test_auditor_triggers.py` exercising scheduled sweep and reactive QA-fail alert paths end-to-end, plus harness mount of `/api/notifications` so `_dispatch_audit_work` can poll ALERT rows.
> - **babffe0a** fix(e2e_smoke): repair auditor-trigger smoke tests and harden harness (#498): fixed `tests/e2e_smoke/test_auditor_triggers.py` so scheduled/reactive auditor-trigger tests reach their spawn assertions, hardened `tests/conftest.py` to tolerate missing pgvector, and cleared leaked `ROBOCO_AGENT_TOKEN` in `tests/e2e_smoke/harness.py` before scripted agents load `flow_server`. See the E2E smoke harness section above for the exact patterns. > - **babffe0a** fix(e2e_smoke): repair auditor-trigger smoke tests and harden harness (#498): fixed `tests/e2e_smoke/test_auditor_triggers.py` so scheduled/reactive auditor-trigger tests reach their spawn assertions, hardened `tests/conftest.py` to tolerate missing pgvector, and cleared leaked `ROBOCO_AGENT_TOKEN` in `tests/e2e_smoke/harness.py` before scripted agents load `flow_server`. See the E2E smoke harness section above for the exact patterns.
> - **f081a574** (PR #502, 2026-07-13): Follow-up e2e lifecycle smoke fix. Restored the `ROBOCO_AGENT_TOKEN` pop in `tests/e2e_smoke/harness.py:ScriptedAgent._module` after it was accidentally removed, and clarified the `/api/notifications` mount comment so it no longer implies the router was newly added. The orchestrator `__new__` pre-init from `89b68786` means `_fresh_orchestrator` no longer needs to manually set `_instances`. > - **f081a574** (PR #502, 2026-07-13): Follow-up e2e lifecycle smoke fix. Restored the `ROBOCO_AGENT_TOKEN` pop in `tests/e2e_smoke/harness.py:ScriptedAgent._module` after it was accidentally removed, and clarified the `/api/notifications` mount comment so it no longer implies the router was newly added. The orchestrator `__new__` pre-init from `89b68786` means `_fresh_orchestrator` no longer needs to manually set `_instances`.
> - **Forge providers, 2026-07-18/19 (#569/#571/#575/#579/#581)**: adds `tests/unit/services/forge/` (`test_gitea_provider.py` 297 lines/17 tests, `test_gitlab_provider.py` 713 lines/33 tests, `test_router.py` 90 lines/9 tests — all mocked-transport) + `tests/unit/foundation/policy/test_forge.py` (132 lines/23 tests, `extract_host`/`detect_provider`/`validate_project_forge`) + the live-forge contract suites `test_gitea_live.py`/`test_gitlab_live.py` (see the "Live-forge contract suites" section above). See `docs/map/worksession-git.md` for the forge package itself.
## Regression Risks ## Regression Risks
+1
View File
@@ -48,6 +48,7 @@ CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET
- **2026-07-17** (PR #543, `3e801697`): Two renderer root causes fixed — `@hyperframes/producer` was floating (`^0.7.36`, no lockfile), so image builds silently picked up `0.7.60`, which fails EVERY render ("Cannot access 'rt' before initialization"); pinned exact (`0.7.36`, no caret) and committed a lockfile (regenerated as `pnpm-lock.yaml` by the immediate follow-up `a12fefcb`, not the npm `package-lock.json` this PR first wrote — this package is pnpm-managed). Second: the producer's per-clip visibility scheduler runs on a clock that lags ~50% behind the encoded timeline on a long cut, so tail scenes (past roughly the halfway mark) were silently missing from the MP4 regardless of authoring — fixed by treating `class="clip"` + `data-start`/`data-duration` as a structural-layer-only primitive and driving every beat with base-hidden styles + a delayed CSS animation instead (documented in `motion/README.md`'s "Clip windows are for structural layers only" rule). Also added the two choreography engines to `motion/kit/kit.js` (`choreographCursor` / `choreographCamera`, see Files above) plus a "Cinematography & rhythm" section in `motion/README.md` and a craft-bar block in the dev video spawn prompt (`roboco/runtime/orchestrator.py`) so a locked-off camera or a popping/freezing cursor reads as an automatic revision. - **2026-07-17** (PR #543, `3e801697`): Two renderer root causes fixed — `@hyperframes/producer` was floating (`^0.7.36`, no lockfile), so image builds silently picked up `0.7.60`, which fails EVERY render ("Cannot access 'rt' before initialization"); pinned exact (`0.7.36`, no caret) and committed a lockfile (regenerated as `pnpm-lock.yaml` by the immediate follow-up `a12fefcb`, not the npm `package-lock.json` this PR first wrote — this package is pnpm-managed). Second: the producer's per-clip visibility scheduler runs on a clock that lags ~50% behind the encoded timeline on a long cut, so tail scenes (past roughly the halfway mark) were silently missing from the MP4 regardless of authoring — fixed by treating `class="clip"` + `data-start`/`data-duration` as a structural-layer-only primitive and driving every beat with base-hidden styles + a delayed CSS animation instead (documented in `motion/README.md`'s "Clip windows are for structural layers only" rule). Also added the two choreography engines to `motion/kit/kit.js` (`choreographCursor` / `choreographCamera`, see Files above) plus a "Cinematography & rhythm" section in `motion/README.md` and a craft-bar block in the dev video spawn prompt (`roboco/runtime/orchestrator.py`) so a locked-off camera or a popping/freezing cursor reads as an automatic revision.
- **2026-07-17** (PR #544, `fd621f0d`): The three craft capabilities wired one hop closer to the hands doing video work — vendored the vendor's own official HyperFrames agent skills (`hyperframes-core`/`-creative`/`-keyframes.md`, see Files above; supersedes the external-pointer-only version briefly added by the intervening `1416bd1d`); registered the `playwright` MCP for a ux-dev spawned onto a `source=video` task (`_is_video_authoring_spawn`, fail-closed role/team/task-source probe — gating-only, `agent-ux`'s image already bakes the browser); and added a video-mode override to the `ux_ui` team prompt's design bar ("video-authoring tasks are FILMS, not UI — these dials do not apply") so a video task no longer reads its own "dense product UI → motion 2-3" dial as license to ship a static slideshow. - **2026-07-17** (PR #544, `fd621f0d`): The three craft capabilities wired one hop closer to the hands doing video work — vendored the vendor's own official HyperFrames agent skills (`hyperframes-core`/`-creative`/`-keyframes.md`, see Files above; supersedes the external-pointer-only version briefly added by the intervening `1416bd1d`); registered the `playwright` MCP for a ux-dev spawned onto a `source=video` task (`_is_video_authoring_spawn`, fail-closed role/team/task-source probe — gating-only, `agent-ux`'s image already bakes the browser); and added a video-mode override to the `ux_ui` team prompt's design bar ("video-authoring tasks are FILMS, not UI — these dials do not apply") so a video task no longer reads its own "dense product UI → motion 2-3" dial as license to ship a static slideshow.
- **2026-07-17** (Wave 6, PR #550): Authoring craft, not engine code — `motion/README.md` gained `## Visual design bar (demo/kit register)` (spacing/hierarchy, beat pacing, `pk-chip`/`pk-pill` semantic discipline, camera+cursor+rhythm, anti-generic tells for the `kit/` register), four upstream HyperFrames craft references vendored verbatim under `motion/skills/references/` (fixing `hyperframes-creative.md`'s previously-dead `references/` pointers), and a new `motion/skills/hyperframes-catalog-index.md` (133-entry HyperFrames catalog vocabulary index, read-on-demand). No service/verb/schema change; the render/post pipeline documented above is untouched. - **2026-07-17** (Wave 6, PR #550): Authoring craft, not engine code — `motion/README.md` gained `## Visual design bar (demo/kit register)` (spacing/hierarchy, beat pacing, `pk-chip`/`pk-pill` semantic discipline, camera+cursor+rhythm, anti-generic tells for the `kit/` register), four upstream HyperFrames craft references vendored verbatim under `motion/skills/references/` (fixing `hyperframes-creative.md`'s previously-dead `references/` pointers), and a new `motion/skills/hyperframes-catalog-index.md` (133-entry HyperFrames catalog vocabulary index, read-on-demand). No service/verb/schema change; the render/post pipeline documented above is untouched.
- **2026-07-18** (PR #570, "project-branded drafts"): the release-video script/prompt/brief builders in `roboco/services/video_engine.py` (`_fallback_release_script`, `_release_video_prompt`, `_release_video_brief`, `_draft_release_script`) all gained a required `product_name` param — `draft_release_post_video` (the `ReleaseProposalService.approve` publish-success hook, mirroring `XEngine.draft_release_post`) now resolves it via `CompanyGoalsService.resolve_product_name(project)` (the release's own project name → charter `company_goals.company_name` → "RoboCo" literal) instead of hardcoding "RoboCo" into the script/brief text — see `docs/map/product-strategy-research-pitch.md` for the shared resolver. `GET /api/video/posts` responses also gained `project_slug`/`project_name` (`api/schemas/video.py`, via the same `task_project_fields` helper `x.py` uses) so the panel's `video-post-queue.tsx` can render a `ProjectBadge` alongside the source-kind badge.
## Health ## Health
+41 -6
View File
@@ -4,10 +4,13 @@ Scope key: `worksession-git` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/robo
- `roboco/services/work_session.py` - `roboco/services/work_session.py`
- `roboco/services/git.py` - `roboco/services/git.py`
- `roboco/templates/git/` (`__init__.py`, `branch.py`, `commit.py`, `constants.py`, `pr_internal.py`, `pr_root.py`) - `roboco/templates/git/` (`__init__.py`, `branch.py`, `commit.py`, `constants.py`, `pr_internal.py`, `pr_root.py`)
- `roboco/services/forge/` (`__init__.py`, `base.py`, `github.py`, `gitea.py`, `gitlab.py`, `registry.py`, `router.py`, `shaping.py`)
- `roboco/foundation/policy/forge.py`
- `roboco/foundation/policy/pr_labels.py`
## Purpose ## Purpose
This slice is the git substrate every delivery agent works on. `GitService` runs all git subprocesses (status/commit/branch/push/rebase), mints branches + commit messages + PR bodies from templates, and drives the GitHub REST API for PR create/merge/close. `WorkSessionService` persists the per-claim row that links an agent to a task's branch/commits/PR and enforces the single-active-per-task invariant. The `roboco/templates/git/` package is the pure rendering layer for branch names, commit messages, and internal/root PR bodies. Together they are the boundary between the task lifecycle and the actual git history on disk + GitHub. This slice is the git substrate every delivery agent works on. `GitService` runs all git subprocesses (status/commit/branch/push/rebase), mints branches + commit messages + PR bodies from templates, and drives the REST API for PR create/merge/close — now routed through a **multi-forge layer** (`roboco/services/forge/`) instead of talking to GitHub directly. `WorkSessionService` persists the per-claim row that links an agent to a task's branch/commits/PR and enforces the single-active-per-task invariant. The `roboco/templates/git/` package is the pure rendering layer for branch names, commit messages, and internal/root PR bodies. `roboco/foundation/policy/pr_labels.py` derives the org-structure label vocabulary (`to master`/`to slave`, `root`, `MegaTask`, layer labels) applied at PR-open. Together they are the boundary between the task lifecycle and the actual git history on disk + the configured forge (GitHub, Gitea, or GitLab).
## Files ## Files
@@ -21,6 +24,16 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `roboco/templates/git/commit.py` | `CommitContext` + `build_commit_message` (traceability links) | 114 | | `roboco/templates/git/commit.py` | `CommitContext` + `build_commit_message` (traceability links) | 114 |
| `roboco/templates/git/pr_internal.py` | Internal (subtask→parent) PR title/body builder | 140 | | `roboco/templates/git/pr_internal.py` | Internal (subtask→parent) PR title/body builder | 140 |
| `roboco/templates/git/pr_root.py` | Root (→master, CEO-level) PR title/body builder with task tree | 245 | | `roboco/templates/git/pr_root.py` | Root (→master, CEO-level) PR title/body builder with task tree | 245 |
| `roboco/services/forge/__init__.py` | Package re-exports (`ForgeRouter`, `GitProvider`, `GitHubProvider`, `GiteaProvider`, `GitLabProvider`, `RepoRef`, `provider_for`, `register_project_forge`) | 32 |
| `roboco/services/forge/base.py` | Pure contract: `RepoRef` dataclass + the `GitProvider` ABC (~20 abstract methods every forge implements) | 208 |
| `roboco/services/forge/github.py` | GitHub.com/GHE REST transport — the pre-existing inline `httpx` logic pulled out unchanged; owns the sole retry policy in the package (`list_ci_runs`) | 432 |
| `roboco/services/forge/gitea.py` | Self-hosted Gitea REST transport, adapting the wire contract back into GitHub shapes | 471 |
| `roboco/services/forge/gitlab.py` | GitLab REST v4 transport — heaviest adaptation (MR `iid`, diff reassembly, pipelines) | 679 |
| `roboco/services/forge/registry.py` | Host↔provider(+scheme) map + `provider_for(project)` resolution, self-healing per-process | 104 |
| `roboco/services/forge/router.py` | `ForgeRouter` — implements `GitProvider` by picking a transport per call from `RepoRef.host` | 195 |
| `roboco/services/forge/shaping.py` | `ShapedResponse` — an `httpx.Response`-compatible stand-in a non-GitHub provider returns when it must synthesize a status the wire call didn't produce (e.g. a shaped 501 for `merge_branch`) | 51 |
| `roboco/foundation/policy/forge.py` | Pure host/provider detection (`extract_host`, `detect_provider`) + registration-time validation (`validate_project_forge`) | 99 |
| `roboco/foundation/policy/pr_labels.py` | `derive_pr_labels` — the org-structure label vocabulary (`to <base_branch>`, `root`, `MegaTask`, layer labels) applied at every PR-open site | ~90 |
## Key Symbols ## Key Symbols
@@ -85,9 +98,11 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) | | `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) |
| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) | | `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) |
| `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` | | `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` |
| `GitService.cleanup_stale_branches` | method | git.py:3711 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable | | `GitService.close_task_pr_best_effort` | method | git.py:3655 | No-clone-needed cancel-path cleanup: resolves the project token + `git_url``RepoRef`, fetches the PR via `self._forge.get_pr`, no-ops unless `state=="open"`, else `update_pr(payload={"state":"closed"})`; every failure path (missing token/project, unparseable URL, `httpx.HTTPError`) returns `False` rather than raising |
| `GitService._stale_branch_window` | method | git.py:3777 | One deterministic `ORDER BY id` window of sweep candidates; ladder rungs excluded from results but still advance the cursor | | `GitService.cleanup_stale_branches` | method | git.py:3697 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable |
| `GitService._cleanup_one_stale_branch` | method | git.py:3810 | Per-branch remote+local delete for one sweep candidate; raises on unexpected failure so the caller's try/except counts it as an error | | `GitService._stale_branch_window` | method | git.py:3767 | One deterministic `ORDER BY id` window of sweep candidates; ladder rungs excluded from results but still advance the cursor |
| `GitService._live_task_dependents` | method | git.py:3810 | Companion sweep guard: excludes a terminal candidate's branch when a non-terminal task still records that exact `branch_name`, or when a non-terminal task is a direct child of the candidate (the child's future PR base would resolve to the parent's branch via `resolve_parent_branch` even before it has opened a PR — catches what the OPEN-PR-only `_branch_has_open_dependents` can't see) |
| `GitService._cleanup_one_stale_branch` | method | git.py:3841 | Per-branch remote+local delete for one sweep candidate; raises on unexpected failure so the caller's try/except counts it as an error |
| `GitService.pr_target` | method | git.py:4021 | Return PR base branch (project_id scoped) | | `GitService.pr_target` | method | git.py:4021 | Return PR base branch (project_id scoped) |
| `GitService.create_pr` | method | git.py:3418 | Branch-keyed open PR (gateway path; ensures base on remote) | | `GitService.create_pr` | method | git.py:3418 | Branch-keyed open PR (gateway path; ensures base on remote) |
| `GitService._record_pr_atomically` | method | git.py:2601 | Atomic pr_number/url write to task | | `GitService._record_pr_atomically` | method | git.py:2601 | Atomic pr_number/url write to task |
@@ -107,6 +122,21 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `build_pr_body_root` / `build_pr_title_root` | funcs | templates/git/pr_root.py:167/235 | Root PR rendering with task tree + AC checklist | | `build_pr_body_root` / `build_pr_title_root` | funcs | templates/git/pr_root.py:167/235 | Root PR rendering with task tree + AC checklist |
| `MAX_TASK_DEPTH` | const | templates/git/constants.py:45 | 4 (umbrella→root→cell→dev) | | `MAX_TASK_DEPTH` | const | templates/git/constants.py:45 | 4 (umbrella→root→cell→dev) |
| `BRANCH_TYPES` / `COMMIT_TYPES` | consts | templates/git/constants.py:10/21 | Allowed prefixes | | `BRANCH_TYPES` / `COMMIT_TYPES` | consts | templates/git/constants.py:10/21 | Allowed prefixes |
| `RepoRef` | dataclass | forge/base.py:26 | Provider-opaque repo identity (`owner`, `repo`, `host`); GitLab packs the full URL-encoded namespace path into `owner`, leaving `repo` empty |
| `GitProvider` | ABC | forge/base.py:49 | The ~20-method transport contract every forge implements (PR CRUD, review flow, `merge_branch`, CI signal surface, repo/label/branch/release/provisioning) |
| `GitService._forge` | property | git.py:399 | A fresh `ForgeRouter()` per access (cheap, no I/O — built this way, not `__init__`-cached, so tests can `GitService.__new__` bypass the constructor) |
| `ForgeRouter` | class | forge/router.py:37 | Implements `GitProvider` by picking a transport per call from `RepoRef.host` |
| `ForgeRouter._provider_for_ref` | staticmethod | forge/router.py:40 | `None` host → `GitHubProvider()`; a registered `"gitea"`/`"gitlab"` host → that provider constructed with its remembered scheme; unregistered → `GitError` naming the fix |
| `register_project_forge` | func | forge/registry.py:50 | Records a project's host→provider(+scheme) mapping; called from `ProjectService.create`/`update`/`get`/`get_by_slug` — in-memory, per-process, self-healing (a restart forgets it; the next project read re-registers) |
| `provider_for` | func | forge/registry.py:77 | Resolve a `GitProvider` for a project (duck-typed on `.git_provider`/`.git_url`) |
| `GitHubProvider` | class | forge/github.py:71 | GitHub.com/GHE REST transport — the pre-existing inline `httpx` logic, byte-for-byte; owns the sole retry policy in the package (`list_ci_runs`, 3 attempts/0.5s backoff, retryable on 429+5xx) |
| `GiteaProvider` | class | forge/gitea.py:55 | Self-hosted Gitea transport: `token`-scheme auth (Bearer is rejected by classic PATs); duplicate-PR 409→422 reshape on `create_pr`; `merge_pr` POSTs (not PUTs) with the method under a `"Do"` key; commit-status → synthetic `check_runs`/`workflow_runs`; branch names with slashes URL-`quote()`-encoded before hitting Gitea's router (caught live by the e2e suite) |
| `GitLabProvider` | class | forge/gitlab.py:85 | GitLab REST v4 transport — the most semantically divergent: MR `iid``number`, `source_branch`/`target_branch``head.ref`/`base.ref`; `get_pr_diff` reassembles a unified-diff string from up to 3 pages of per-file JSON diffs (no raw-diff media type); `post_review` routes APPROVE to `/approve` and everything else to a plain note (no request-changes verb exists); `request_reviewers` synthetic-skips (needs numeric user ids RoboCo doesn't store); `create_org_repo` resolves a group path to a numeric namespace id, falling back to the token's personal namespace on 404 |
| `ShapedResponse` | class | forge/shaping.py:17 | `httpx.Response`-compatible stand-in a non-GitHub provider returns to synthesize a status the wire call didn't produce (e.g. `merge_branch`'s shaped 501) |
| `extract_host` / `detect_provider` / `validate_project_forge` | funcs | foundation/policy/forge.py:27/45/62 | Pure host extraction (https/ssh/scp-like URLs), github.com/gitlab.com auto-detection, and registration-time validation (a self-hosted host with no explicit `git_provider` is a registration-time rejection — the GHE/self-hosted escape hatch requires the operator to set the column) |
| `derive_pr_labels` | func | foundation/policy/pr_labels.py | Org-structure label vocabulary: `base_branch` (required kwarg, the PR's real resolved target — never assumed from `is_root_pr`) → `f"to {base_branch}"`, plus `root`/`MegaTask`/layer labels (`main-pm`, `cell/{team}`, `subtask/{team}`) |
Neither Gitea nor GitLab has GitHub's server-side merges API for the env-sync cascade: both return a shaped 501 from `merge_branch`, landing `GitService.sync_env_branch` on the local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; a conflict aborts leaving the remote untouched, same status vocabulary as the GitHub server-side path) — this fallback lives entirely in `GitService`, not the forge package. Plain git (clone/fetch/push) needed zero forge-specific work: all three forges accept a PAT as the Basic-auth password with username ignored, so the existing `x-access-token:<token>` extraheader works unchanged (verified live against a dockerized Gitea).
## Data Flow ## Data Flow
@@ -317,7 +347,11 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr
> Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286 — closed regression risks #108 and #109 in this slice: `_merge_with_retry` now falls back to a permitted merge method on 405 via `_first_allowed_merge_method` before raising `MergeConflictError`; `close_pull_request` default flipped to `delete_branch=False`, choreographer caller now passes `delete_branch=True` explicitly). `00513399` ([bug] push_branch — `push_branch(branch_name)` now passes `branch=branch_name` to `self.push()` so the gateway `open_pr` path pushes the actual named task branch rather than the clone root's current checkout; fixes the "No commits between" 422 → `i_am_blocked` wedge observed in the F123 per-worktree model). `2759edf7` ([B-REL] release executor — added `_CiRunQuery` dataclass at git.py:241 to bundle per-project CI-fetch inputs; `get_latest_ci_conclusion` and `_fetch_latest_ci_run` now accept an optional `head_sha` so the release CI gate polls a specific release commit's own run rather than branch-latest; `settings.release_ci_workflow` config flag added). `69071030` ([chore] work-session-routes — added `WorkSessionService.task_team_for_session` helper (route layer PM cell-ownership check for `merge_pr`); route layer now stamps `merged_by` from the authenticated caller rather than the request body — `WorkSessionService.merge_pr` signature is unchanged, but `MergePRRequest` schema dropped `merged_by` field). > Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286 — closed regression risks #108 and #109 in this slice: `_merge_with_retry` now falls back to a permitted merge method on 405 via `_first_allowed_merge_method` before raising `MergeConflictError`; `close_pull_request` default flipped to `delete_branch=False`, choreographer caller now passes `delete_branch=True` explicitly). `00513399` ([bug] push_branch — `push_branch(branch_name)` now passes `branch=branch_name` to `self.push()` so the gateway `open_pr` path pushes the actual named task branch rather than the clone root's current checkout; fixes the "No commits between" 422 → `i_am_blocked` wedge observed in the F123 per-worktree model). `2759edf7` ([B-REL] release executor — added `_CiRunQuery` dataclass at git.py:241 to bundle per-project CI-fetch inputs; `get_latest_ci_conclusion` and `_fetch_latest_ci_run` now accept an optional `head_sha` so the release CI gate polls a specific release commit's own run rather than branch-latest; `settings.release_ci_workflow` config flag added). `69071030` ([chore] work-session-routes — added `WorkSessionService.task_team_for_session` helper (route layer PM cell-ownership check for `merge_pr`); route layer now stamps `merged_by` from the authenticated caller rather than the request body — `WorkSessionService.merge_pr` signature is unchanged, but `MergePRRequest` schema dropped `merged_by` field).
> >
> (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Local branch refs stop leaking alongside remote ones: `delete_task_branch` now also skips environment-ladder rungs (previously only the remote-delete's own main/master/develop guard existed) and returns `bool`; new `cleanup_stale_branches` + `_stale_branch_window` + `_cleanup_one_stale_branch` back a PM/CEO-only `POST /git/branches/cleanup` sweep of terminal tasks' remote+local branches, exposed as a confirm-dialog button on the panel Git page. See `docs/map/task-service.md` for the paired per-task reap at cancel/completion and `docs/map/workspace.md` for the new `WorkspaceService.delete_local_branch` primitive both routes share. > `496c24d1` (PR #548, "git hygiene", 2026-07-17) Local branch refs stop leaking alongside remote ones: `delete_task_branch` now also skips environment-ladder rungs (previously only the remote-delete's own main/master/develop guard existed) and returns `bool`; new `cleanup_stale_branches` + `_stale_branch_window` + `_cleanup_one_stale_branch` back a PM/CEO-only `POST /git/branches/cleanup` sweep of terminal tasks' remote+local branches, exposed as a confirm-dialog button on the panel Git page. See `docs/map/task-service.md` for the paired per-task reap at cancel/completion and `docs/map/workspace.md` for the new `WorkspaceService.delete_local_branch` primitive both routes share.
>
> **Forge providers — GitHub + Gitea + GitLab (2026-07-18/19, PRs #569/#571/#575/#579/#581).** A new `roboco/services/forge/` package (`base.py`/`github.py`/`gitea.py`/`gitlab.py`/`registry.py`/`router.py`/`shaping.py`) plus `roboco/foundation/policy/forge.py` route every REST call `GitService` makes (PRs/CI/reviews/labels/releases/provisioning) through a provider-agnostic transport. `388bab24` (Phase 0, #569): `projects.git_provider` column (migration 076, nullable, plain string not a pg enum — validated at the service layer, not the DB) + `validate_project_forge`/`detect_provider` (github.com auto-detects, self-hosted needs an explicit column value — the GHE/self-hosted escape hatch). `461a6e1a` (Phase 1, #571): the `GitProvider` ABC + `GitHubProvider` extracted byte-for-byte from `GitService`'s old inline `httpx` calls; `GitService._forge` (git.py:399) becomes the seam every call site routes through. `96401f4c` (Phases 2/2.1/3, #575): `GiteaProvider` + `GitLabProvider` + `ForgeRouter` (per-call transport dispatch off `RepoRef.host`) + the local-git `merge_branch` fallback for forges with no server-side merges API. `5f32d876` (Phase 4, #581): `roboco/services/github_provisioning.py` becomes provider-aware (`ROBOCO_PROVISIONING_PROVIDER`/`ROBOCO_PROVISIONING_HOST` — see `docs/map/product-strategy-research-pitch.md`) so pitch-driven repo creation works on all three forges. `d4cb5797` (#579) + the pre-existing `tests/e2e_smoke/test_gitea_live.py` are the live contract suites (self-seeding against a dockerized `gitea/gitea` / real `gitlab.com`, env-gated) that caught the slash-encoding and http-scheme gaps in the Gitea provider. Panel: the edit-project dialog's "Forge" `<Select>` (`edit-project-dialog.tsx`) offers Auto-detect/GitHub/Gitea/GitLab.
>
> **PR label base-branch agnosticism + cancel-path PR close (2026-07-18/19, "agnosticism-residue" + "cancel-pr-dependents-guard").** `derive_pr_labels` gained a required `base_branch: str` keyword-only param; the label is now `f"to {base_branch}"` (the PR's real resolved target) instead of `"to master" if is_root_pr else "to slave"` — a prerequisite for the env-branches ladder and non-GitHub forges, where "master"/"slave" aren't universal. All three `GitService` call sites (`_labels_for_pr_request`, the assembled cell→root/root→master PR opener) now thread the real resolved base branch through. Separately, `GitService.close_task_pr_best_effort` (git.py:3655) closes a task's forge PR on cancel — `TaskService._close_task_pr_best_effort` (task.py:6767) calls it once per cascaded descendant and once for the cancelled task itself, alongside the existing `_delete_task_branch_best_effort` — closing a gap where a cancelled task's branch was force-deleted but its open PR lingered on the forge forever. The same change adds `GitService._live_task_dependents` (git.py:3810) so `cleanup_stale_branches` never deletes a terminal task's branch while a non-terminal task still records it, or is a direct child of it (whose future PR base would resolve to that branch before it even opens a PR).
## Regression Risks ## Regression Risks
@@ -327,6 +361,7 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr
| `pr_merge` CEO-only guard refuses default-branch target | git.py:3671 | If a non-root PR's target legitimately resolves to the repo default branch (e.g. a mis-configured `default_branch` or a single-branch repo), `pr_merge` now hard-fails with `UnauthorizedError`. Any cell that previously merged a leaf PR into master via this path (shouldn't happen per policy, but the route existed) is now blocked. Since the env-branches ladder (#534) the comparison target is the env-ladder **head** rung (`_project_default_branch``head_branch(project)`), not necessarily prod — a project that declares head != prod has this guard protect the head rung, not the CEO's actual prod branch. | medium | | `pr_merge` CEO-only guard refuses default-branch target | git.py:3671 | If a non-root PR's target legitimately resolves to the repo default branch (e.g. a mis-configured `default_branch` or a single-branch repo), `pr_merge` now hard-fails with `UnauthorizedError`. Any cell that previously merged a leaf PR into master via this path (shouldn't happen per policy, but the route existed) is now blocked. Since the env-branches ladder (#534) the comparison target is the env-ladder **head** rung (`_project_default_branch``head_branch(project)`), not necessarily prod — a project that declares head != prod has this guard protect the head rung, not the CEO's actual prod branch. | medium |
| `pr_merge` parent-row `SELECT FOR UPDATE` can deadlock | git.py:3503/3683 | Two PMs merging sibling subtasks of *different* parents that share an ancestor, under并发 lock ordering, could deadlock if lock acquisition order diverges. Lock is only on the immediate parent, so risk is bounded, but a deadlock surfaces as a rollback (not a clean 409 retry). | medium | | `pr_merge` parent-row `SELECT FOR UPDATE` can deadlock | git.py:3503/3683 | Two PMs merging sibling subtasks of *different* parents that share an ancestor, under并发 lock ordering, could deadlock if lock acquisition order diverges. Lock is only on the immediate parent, so risk is bounded, but a deadlock surfaces as a rollback (not a clean 409 retry). | medium |
| `create_branch` `reset --hard` on zero-commit branch inside worktree | git.py:1104 | If `rev-list --count {base_ref}..{branch_name}` returns `0` for a branch that actually carries work (e.g. base_ref mis-resolved to a ref that already contains the work), the worktree is hard-reset and uncommitted work in the worktree is lost. The `unique==0` check is the only guard; a stale `base_ref` could trip it. | medium | | `create_branch` `reset --hard` on zero-commit branch inside worktree | git.py:1104 | If `rev-list --count {base_ref}..{branch_name}` returns `0` for a branch that actually carries work (e.g. base_ref mis-resolved to a ref that already contains the work), the worktree is hard-reset and uncommitted work in the worktree is lost. The `unique==0` check is the only guard; a stale `base_ref` could trip it. | medium |
| Panel Forge-select help text is stale vs. the shipped GitLab provider | `panel/src/components/projects/edit-project-dialog.tsx:315` | The `HelpTip` copy next to the Forge `<Select>` still reads "...GitLab support is planned" even though `gitlab` is a live `SelectItem` one line below and the backend has full GitLab Phase 3 support (`GitLabProvider`, `ForgeRouter`) — cosmetic only, no functional gap, but confusing to an operator reading the tooltip before picking GitLab. | low |
| F123 worktree routing — commit/conventions/rebase run in worktree, merge sync runs in clone root | git.py:3696/3785 | `pr_merge` calls `_sync_target_branch_best_effort(workspace,...)` with the clone-root workspace (from `get_workspace`), not the per-task worktree. If the target branch is checked out in a worktree, the sync's `checkout` of target in the clone root fails ("already checked out"). Best-effort swallows it, but the local target ref may stay stale for the next sibling merge. | medium | | F123 worktree routing — commit/conventions/rebase run in worktree, merge sync runs in clone root | git.py:3696/3785 | `pr_merge` calls `_sync_target_branch_best_effort(workspace,...)` with the clone-root workspace (from `get_workspace`), not the per-task worktree. If the target branch is checked out in a worktree, the sync's `checkout` of target in the clone root fails ("already checked out"). Best-effort swallows it, but the local target ref may stay stale for the next sibling merge. | medium |
| ~~`_merge_with_retry` retries on 409 only; 405 falls through to already-merged check then `MergeConflictError`~~ | git.py:3597 | **FIXED** (`536bbb64` #108) — `_merge_with_retry` now falls back to a permitted merge method (via `_first_allowed_merge_method`, exclude='squash') on 405 before raising `MergeConflictError`, mirroring the CEO `merge_pull_request` path. A 405 with no permitted fallback or a second 405 still falls through to disambiguation/`MergeConflictError`. | ~~medium~~ resolved | | ~~`_merge_with_retry` retries on 409 only; 405 falls through to already-merged check then `MergeConflictError`~~ | git.py:3597 | **FIXED** (`536bbb64` #108) — `_merge_with_retry` now falls back to a permitted merge method (via `_first_allowed_merge_method`, exclude='squash') on 405 before raising `MergeConflictError`, mirroring the CEO `merge_pull_request` path. A 405 with no permitted fallback or a second 405 still falls through to disambiguation/`MergeConflictError`. | ~~medium~~ resolved |
| `is_behind_base` raises on fetch failure; gate fail-opens | git.py:3922/3938 | A flaky origin fetch makes `is_behind_base` raise; the i_am_done gate catches it and fail-opens, letting a behind branch submit. The merge layer's own behind check is the backstop, but a genuinely-behind branch can reach QA. Documented, but a regression in the "gate is authoritative" expectation. | low | | `is_behind_base` raises on fetch failure; gate fail-opens | git.py:3922/3938 | A flaky origin fetch makes `is_behind_base` raise; the i_am_done gate catches it and fail-opens, letting a behind branch submit. The merge layer's own behind check is the backstop, but a genuinely-behind branch can reach QA. Documented, but a regression in the "gate is authoritative" expectation. | low |
@@ -337,4 +372,4 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr
## Health ## Health
Integrity is **good and actively hardened**. The slice carries the scars of multiple live meltdowns (single-active work-session defect, pr_fail re-submit loop, cell_pm merge block<->unblock, MegaTask depth cap, cross-repo PR-number collision) and each is closed with a deterministic guard plus a comment explaining the failure mode. The F123 per-task-worktree routing is consistently threaded through commit/checkout/rebase/conventions, and the merge path has layered defenses (parent-row lock, 409 retry, already-merged disambiguation, CEO-only master guard). Two formerly-medium risks in the merge path have since been closed: `_merge_with_retry` now has the 405 method-fallback that `merge_pull_request` has (`536bbb64`), and `close_pull_request` now defaults to `delete_branch=False` (`536bbb64`). The remaining residual risk is **`pr_merge`'s post-merge target sync** running in the clone root (not the worktree) and best-effort-swallowing a checkout conflict — the local target ref may stay stale for the next sibling merge. Test coverage of the work-session lifecycle is solid; the newer `pr_merge`/`sync_task_branch`/`rebase_onto_base`/`close_pull_request` quartet deserves the most scrutiny on any future change. No outright bugs found; the drift vs CLAUDE.md is documentation undersell (commit header format, gateway merge-path description), not behavioral mismatch. Integrity is **good and actively hardened**. The slice carries the scars of multiple live meltdowns (single-active work-session defect, pr_fail re-submit loop, cell_pm merge block<->unblock, MegaTask depth cap, cross-repo PR-number collision) and each is closed with a deterministic guard plus a comment explaining the failure mode. The F123 per-task-worktree routing is consistently threaded through commit/checkout/rebase/conventions, and the merge path has layered defenses (parent-row lock, 409 retry, already-merged disambiguation, CEO-only master guard). Two formerly-medium risks in the merge path have since been closed: `_merge_with_retry` now has the 405 method-fallback that `merge_pull_request` has (`536bbb64`), and `close_pull_request` now defaults to `delete_branch=False` (`536bbb64`). The remaining residual risk is **`pr_merge`'s post-merge target sync** running in the clone root (not the worktree) and best-effort-swallowing a checkout conflict — the local target ref may stay stale for the next sibling merge. Test coverage of the work-session lifecycle is solid; the newer `pr_merge`/`sync_task_branch`/`rebase_onto_base`/`close_pull_request` quartet deserves the most scrutiny on any future change. No outright bugs found; the drift vs CLAUDE.md is documentation undersell (commit header format, gateway merge-path description), not behavioral mismatch. The forge-providers rollout (GitHub/Gitea/GitLab) added real breadth without touching the merge-path defenses above — `GitService`'s callers still reason in GitHub-shaped responses, and `ForgeRouter`/`GiteaProvider`/`GitLabProvider` carry the entire adaptation burden behind that seam; the live-forge e2e suites (`tests/e2e_smoke/test_gitea_live.py`/`test_gitlab_live.py`, both env-gated and self-seeding) are the only coverage that exercises a real forge over the wire rather than a mocked transport, and they already caught two real gaps (slash-encoding, http scheme) the mocked unit tests couldn't have found.