Commit Graph
36 Commits
Author SHA1 Message Date
73b7c16211 [3cc1729c] Add self-hosted LLM provider with dynamic model discovery (#128)
* [684dace4] Self-hosted LLM provider: API layer, hooks, UI section, routing mode button, and Mix mode grouping (#124) (#126)

* [684dace4] feat(providers): add self-hosted LLM API types, endpoints, and React Query hooks

- Add ModelProvider.SELF_HOSTED enum value to types/index.ts
- Extend RoutingMode to include 'self_hosted' in lib/api/providers.ts
- Add SelfHostedConfig, SelfHostedTestResult, SelfHostedModel interfaces
- Add SelfHostedConfigPayload for PUT requests
- Add 5 providersApi methods: getSelfHostedConfig, saveSelfHostedConfig,
  testSelfHosted, getSelfHostedModels, refreshSelfHostedModels
- Add 5 React Query hooks: useSelfHostedConfig, useSetSelfHostedConfig,
  useTestSelfHosted, useSelfHostedModels, useRefreshSelfHostedModels
- Cache keys follow existing providerKeys pattern with proper invalidation

* [684dace4] feat(settings): create SelfHostedSection component with full self-hosted LLM UI

- Base URL text input with placeholder showing saved URL when set
- Optional auth token field (type='password') with Eye/EyeOff toggle button
- Save button that calls useSetSelfHostedConfig mutation
- Test Connection button disabled until a URL is saved; shows inline
  green 'Connected — N models' badge on success or red error badge on fail
- Three empty states: no URL configured (CTA), error state (last-checked +
  Retry), connected with 0 models (pull-guidance)
- Model list with auto-discovered chip, Refresh Models button, and
  Last refreshed relative timestamp when test_status === 'connected'
- Token field shows masked placeholder when has_auth_token is true
  (consistent with Ollama Cloud key field pattern)

* [684dace4] feat(settings): add Self-Hosted mode button, model picker, and Mix mode provider grouping

- Wire SelfHostedSection into AIRoutingCard with testResult state tracking
- Expand routing mode grid from 3 to 4 buttons (2×2 on mobile, 4-col on md+)
- 4th 'Self-Hosted' mode button disabled until test_status === 'connected'
- Self-hosted model picker appears below mode grid when mode === 'self_hosted'
- flipToSelfHosted handler sends mode='self_hosted' with optional default_model
- Mix mode per-agent dropdown now groups entries under SelectGroup/SelectLabel
  headings: Anthropic, Ollama Cloud, Self-Hosted with colored ProviderBadge pill
- saveMix validates self-hosted model selection requires a successful test
- ProviderBadge helper renders blue/violet/purple pills for each provider type
- pnpm typecheck and pnpm lint pass with zero errors

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [2897ce90] Implement self-hosted LLM provider API, routing, and discovery (#125) (#127)

* [2897ce90] feat(provider): add self-hosted LLM provider API, routing, and discovery

- Add migration 027 to seed Self-Hosted (Ollama) LOCAL provider row
- Add probe_ollama_tags() helper for Ollama /api/tags connectivity checks
- Extend ModelRoutingService: derive_mode returns 'self_hosted' for LOCAL
  GLOBAL assignments; apply_mode handles 'self_hosted' mode; upsert_assignment
  routes non-catalog model names to LOCAL provider; resolve_for_agent falls
  back to Anthropic when self-hosted server is unreachable
- Add PUT /api/providers/self-hosted, POST /api/providers/self-hosted/test,
  GET /api/providers/self-hosted/models endpoints
- Extend ApplyModeRequest and ModeResponse literals with 'self_hosted'
- Add SelfHostedConfigRequest, SelfHostedConfigResponse, SelfHostedTestResponse schemas

* [2897ce90] test(provider): add integration tests for self-hosted routing and route endpoints

- Add llm_setup_with_local fixture that seeds LOCAL provider row
- Test derive_mode returns 'self_hosted' for single GLOBAL LOCAL assignment
- Test apply_mode('self_hosted') clears prior assignments, enables LOCAL, inserts GLOBAL
- Test apply_mode('self_hosted') requires default_model argument
- Test upsert_assignment routes non-catalog model names to LOCAL provider
- Test mix mode accepts self-hosted model names without ValueError
- Test resolve_for_agent returns base_url when LOCAL server is reachable
- Test resolve_for_agent falls back to Anthropic when LOCAL server is unreachable
- Test upsert_assignment raises ValueError when model unknown and no LOCAL provider
- Add app_client_with_local fixture for route tests
- Test PUT /self-hosted saves base_url and enables provider
- Test PUT /self-hosted stores encrypted token when auth_token provided
- Test PUT /self-hosted returns 404 when LOCAL provider not seeded
- Test POST /self-hosted/test returns {ok:true,model_count:N} when reachable
- Test POST /self-hosted/test returns {ok:false,error} (never 500) when unreachable
- Test GET /self-hosted/models returns model name list
- Test GET /self-hosted/models returns 404 when not configured
- Test GET /self-hosted/models returns 503 when server unreachable
- Rename migration from 027 to 028 to rebase on 027_system_settings

* [2897ce90] chore(migration): remove superseded 027 migration, fix formatter changes to provider schemas

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [042462df] feat(providers): align self-hosted types, hooks, and UI to backend contract (#129) (#131)

- SelfHostedConfig now has {base_url: string, has_token: boolean, enabled: boolean}
- SelfHostedTestResult now has {ok: boolean, model_count: number | null, error: string | null}
- Remove SelfHostedTestStatus type and refreshSelfHostedModels POST API function
- Remove SELF_HOSTED from ModelProvider enum (LOCAL covers self-hosted semantics)
- useRefreshSelfHostedModels now invalidates GET cache instead of calling POST
- isSelfHostedConnected derived from testResult?.ok === true
- Self-hosted model picker uses value='__clear__' sentinel (no empty-string SelectItem)
- self-hosted-section.tsx reads result.ok/result.error and config?.has_token
- pnpm typecheck passes with zero errors

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [f66d6d4d] Fix self-hosted API S1-S4/L1-L5: routes, schemas, services, migration 028, and tests (#130) (#132)

* [f66d6d4d] fix(provider): self-hosted API S1-S4/L1-L5 - routes, schemas, services, migration 028, and tests

AC1: Add GET /providers/self-hosted returning {base_url, has_token, enabled}
AC2: GET /self-hosted/models now returns list[SelfHostedModelEntry] with model_name and display_name
AC3: probe_ollama_tags generic except logs exception server-side and returns hardcoded generic string
AC4: upsert_assignment calls ProviderService.update_provider(enabled=True) when routing to LOCAL
AC5: derive_mode return annotation is Literal[...] — type:ignore comments removed
AC6: All migration refs in routes/services say 028 (not 027)
AC7: Migration 028 downgrade() deletes model_assignments before provider_configs
AC8: PUT /self-hosted only passes enabled=True when data.base_url is non-empty
AC9: ModelProvider.LOCAL docstring updated to describe self-hosted Ollama provider
AC10: Direct unit tests for probe_ollama_tags (5 cases) in tests/unit/llm/
AC11: Contract tests added/updated for GET /providers/self-hosted, models, and test endpoints
AC12: test_migration_028_seed_self_hosted.py with upgrade and FK-safe downgrade tests
AC13: test_apply_mode_ollama_without_provider_returns_404 asserts exactly HTTPStatus.NOT_FOUND
AC14: ruff and mypy pass with zero errors

* [f66d6d4d] fix(tests): add AC4 test proving LOCAL.enabled transitions False->True in upsert_assignment

The existing tests (test_upsert_assignment_routes_unknown_model_to_local and
test_mix_mode_with_self_hosted_models) both use llm_setup_with_local which seeds
LOCAL with enabled=True, making the AC4 assertion vacuous.

New test test_upsert_assignment_enables_local_when_disabled:
- Creates LOCAL ProviderConfigTable row with enabled=False
- Asserts pre-condition: local.enabled is False
- Calls upsert_assignment with a non-catalog model name ('non-catalog-model:7b')
- Refreshes LOCAL row via db_session.refresh(local)
- Asserts row.provider.type == ModelProvider.LOCAL and local.enabled is True

This proves the state transition from False->True, not merely that the
already-enabled state is preserved. ruff and mypy still pass with zero errors.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [7cd6ae6e] fix(providers): type SelfHostedConfig.base_url as string | null to match backend contract (#133) (#136)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [46ee9104] test(migration_028): replace upgrade test with self-seeding contract test (#134) (#135)

Remove test_migration_028_upgrade_local_row_inserted which relied on alembic
upgrade head having run (and thus the Self-Hosted Ollama row being present).

Replace it with test_migration_028_upgrade_insert_contract that:
- Executes the exact INSERT SQL from migration 028 upgrade() directly
- Asserts name='Self-Hosted (Ollama)', type='local', enabled=False
- Runs the INSERT a second time and asserts exactly one row (ON CONFLICT
  DO NOTHING idempotency)

The downgrade test is left byte-for-byte unchanged.

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [f0d19f30] test(provider): add DELETE-before-seed isolation and app_client_with_ollama fixture (#137) (#138)

- Add ModelAssignmentTable import to test_provider_routes.py
- Fix app_client_with_local: execute DELETE on ModelAssignmentTable then
  DELETE on ProviderConfigTable (FK-safe order) and flush before seeding
- Add new app_client_with_ollama fixture with same isolation pattern,
  seeding only ANTHROPIC + OLLAMA_CLOUD rows
- Update 7 tests to use app_client_with_ollama instead of app_client:
  test_get_catalog, test_get_ollama_key_status, test_set_ollama_key,
  test_get_current_mode, test_apply_mode_anthropic_clears_assignments,
  test_apply_mode_unknown_returns_4xx, test_apply_mode_mix_without_per_agent_returns_400

Fixes order-dependent failures in test_get_self_hosted_models_not_configured_returns_404:
routes call db.commit() which persists rows across test sessions; without
DELETE-before-seed, stale LOCAL provider rows with base_url set from prior
runs cause the test to see 503 instead of 404.

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* refactor(llm): split resolve_for_agent and apply_mode to clear xenon rank C

resolve_for_agent and apply_mode were cyclomatic rank C, failing the
xenon gate (--max-absolute B). Extract behavior-preserving helpers:

- resolve_for_agent -> _resolve_assignment (precedence ladder),
  _route_from_resolved / _local_route_or_none / _decrypt_route_or_none
  (None signals fall-through to legacy), _legacy_route.
- apply_mode -> _apply_anthropic / _apply_ollama / _apply_self_hosted /
  _apply_mix dispatched from a thin if/elif.

No behavior change. Also correct the stale 'default: Kimi K2.6' docstring
(OLLAMA_DEFAULT_MODEL is minimax-m3:cloud).

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-13 08:38:31 +02:00
Renn F 320499811b fix(panel): derive the retention input instead of syncing it in an effect
The Transcript Retention card seeded its input from the settings query with a
useEffect + setState, which trips react-hooks/set-state-in-effect (cascading
renders). Derive the displayed value (edited ?? serverValue) during render
instead; the user's edits live in 'edited', reset to null after a successful
save so the field re-syncs to the server value. No effect, no setState-in-effect.
2026-06-12 23:21:55 +02:00
fbbb7b3251 Feat: transcript retention (#123)
* feat(retention): prune old agent transcripts + panel-tunable setting

Agents write a {session-id}.jsonl per spawn under ~/.claude/projects; nothing
ever deleted them, so the operator's bind-mounted ~/.claude grew without bound.

Add a throttled orchestrator sweep that prunes agent-owned transcripts (the
shared -app dir + per-workspace dirs) older than a retention window — and ONLY
agent-owned dirs, never the operator's own Claude sessions (proven by the
temp-dir selection tests). The window is panel-tunable: a new system_settings
key-value table (migration 027) holds transcript_retention_days, read via
SettingsService with the roboco.config default (14d) as the fallback, exposed
through GET/PUT /api/settings. Panel wiring follows.

* feat(panel): add a panel-tunable transcript retention control

Wire the settings page to the /api/settings backend: a settings API client and
a self-contained Transcript Retention card (React Query) that loads
transcript_retention_days and saves it back, with client-side validation. The
existing settings controls are unchanged.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-12 23:11:01 +02:00
Corey DeanandRenn F 048e7ecf6f fix(panel): surface the CEO "Approve & Start" gate so it can't be missed (#119)
After the Board reviews a task, the orchestrator sets board_review_complete,
notifies the CEO, and leaves the task PENDING (that pending state is what
drives Main PM dispatch on approval). But the panel never surfaced this:

- The task-page "Approve & Start" button was gated on team===BOARD plus a
  product-scoped predicate (no project_id, has product_id). A project-scoped
  intake task carries a project_id, team=its lead cell, and no product_id, so
  the button rendered nowhere and the CEO had no way to approve it.
- The dashboard CEO Approval Queue only queried awaiting_ceo_approval, so a
  board-reviewed pending task showed up nowhere on the dashboard either.

Fixes:
- Gate the task-page button on the orchestrator's own criterion: pending +
  board_review_complete, and not yet handed to Main PM (team !== main_pm).
- Add a "Ready to start · board reviewed" section to the dashboard CEO
  Approval Queue listing pending + board_review_complete tasks, each with an
  Approve & Start action, with the header count covering both stages.

Both exclude team === main_pm because approve_and_start re-targets the task to
the Main PM without changing status — otherwise an already-approved task would
never leave the queue.
2026-06-12 16:07:07 +02:00
Renn F ec790d18b5 fix(panel): orchestrator status reflects reachability; drop CEO from the agent roster
- The Orchestrator card showed red 'Stopped' whenever zero agents were
  running, even though the service was healthy (it read total_agents > 0).
  Base Running/Stopped on whether the status query resolves; agent count is
  already shown in its own card.
- The human CEO leaked into the Board agent grid (its record carries
  team=board). Exclude the ceo role from getBoardAgents — the CEO is the
  operator, not a spawnable agent.
2026-06-11 23:30:46 +02:00
Renn F bd64f62637 ci(panel): pin pnpm to 10.25.0 via packageManager
CI runs on Node 20 with `corepack enable`, which — with no packageManager pin —
downloaded the latest pnpm (11.5.3). pnpm 11 requires Node >= 22.13 and imports
node:sqlite, so `pnpm install --frozen-lockfile` crashed with
ERR_UNKNOWN_BUILTIN_MODULE on the Node 20 runner. Pin packageManager to
pnpm@10.25.0 (the version that generated the lockfile) so Corepack uses a
deterministic, Node-20-compatible pnpm in CI, the node:22 Docker build, and
locally.
2026-06-11 23:28:59 +02:00
547fe444f2 [4865ff8b] Add WebSocket support to the usage dashboard (#115)
* [e7349d84] feat(dashboard): WS usage store, hook extension, status badge, and smooth animations (#111) (#113)

- Add src/store/usage-store.ts with typed UsageData interface, useUsageStore
  Zustand store, setUsageData, clearUsageData, and setWsState actions
- Export useUsageStore and UsageData from store/index.ts
- Extend use-rate-limit-websocket.ts: rename msg type to SystemWsMessage,
  add key_metrics field; add useEffect syncing wsState into useUsageStore;
  add USAGE_UPDATE/USAGE_SNAPSHOT handler dispatching to useUsageStore
  (RATE_LIMIT_HIT/LIFTED handling and onReconnect unchanged)
- Update CommandCenter to read key_metrics from useUsageStore when
  wsState === 'connected' and usageData non-null; falls back to
  useCeoOverview() (refetchInterval: 60000) when WS disconnected
- Update KeyMetricsPanel: add wsState prop, render connection status Badge
  matching AgentStreamViewer pattern (bg-green-500+Wifi / bg-yellow-500+
  Loader2 spin / bg-gray-500+WifiOff); add transition-all duration-300
  ease-in-out to metric value spans for smooth animated updates

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [c9745ee8] feat(events): add USAGE_UPDATE/SNAPSHOT event types, throttled publisher, /ws/system usage bridge (#112) (#114)

- Add EventType.USAGE_UPDATE='usage.update' and EventType.USAGE_SNAPSHOT='usage.snapshot'
  to the EventType StrEnum in roboco/models/events.py

- Create roboco/services/usage_events.py with _UsageThrottle class (5-second per-agent
  window using time.monotonic()) and publish_usage_update() / publish_usage_snapshot()
  helpers; lazy imports prevent circular dependency with roboco.events

- Extend orchestrator._sweep_token_snapshots() to publish USAGE_UPDATE per active agent
  (throttled) and a USAGE_SNAPSHOT aggregate after each sweep cycle; wrapped in
  contextlib.suppress so event errors never abort DB snapshot operations

- Add _handle_usage_event() to websocket_bridge.py following _handle_rate_limit_event
  pattern; register USAGE_UPDATE and USAGE_SNAPSHOT subscriptions in
  register_websocket_bridge_handlers() forwarding both to /ws/system via broadcast_system()

- Add unit tests: test_usage_events.py (throttle suppression, publish helpers) and
  test_websocket_bridge.py extended with _handle_usage_event coverage and updated
  registration assertion to include USAGE_UPDATE/USAGE_SNAPSHOT

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* fix(usage-ws): reconcile the realtime token/cost contract end-to-end

The backend and frontend halves shipped mismatched contracts, so the usage
dashboard never received live data:

- The bridge forwarded the dotted event value ("usage.update") while the panel
  switched on "USAGE_UPDATE"; map both to the UPPER_SNAKE type string the same
  way the rate-limit handler does.
- The backend emitted token/cost telemetry but the frontend read a key_metrics
  field and fed the org-metrics panel. Rewire the frontend to consume the
  USAGE_SNAPSHOT token/cost payload into the "Token Usage & Cost" panel —
  WS-first with polling fallback and a connection-status badge — and revert the
  unrelated KeyMetricsPanel / CommandCenter wiring.

Backend cleanups in the same path:

- Replace the multi-argument publish helpers with typed UsageUpdate /
  UsageSnapshot payloads, removing the too-many-arguments lint suppressions.
- Extract _fetch_agent_tokens and _persist_token_snapshot from the token sweep,
  removing the too-many-statements suppression; label the live snapshot "live".

Hardening uncovered while fixing the above:

- _finalize_spawn_session pulled the full RAG stack into the
  session-finalization path through a transcript-parse import; move the pure
  parser into a dependency-light roboco.agent_sdk.transcript_usage module so
  finalization never imports the agent SDK server.
- Reduce _finalize_spawn_session complexity by extracting
  _resolve_final_token_usage, and widen the transcript-fallback guard so a read
  error can never abort finalization.

Also align KeyMetricsPanel with the metrics /dashboard/ceo actually returns: it
read velocity_24h / avg_time_to_done / active_agents, none of which
get_key_metrics() emits, so four of five rows rendered "—". Render
velocity_weekly, completion_rate, documentation_coverage and active_blockers.

* docs: note live usage push over /ws/system on the usage dashboard

* fix(usage): finalize on self-exit and de-duplicate transcript token counts

Two bugs left token capture broken even after the transcript-read fallback
landed — surfaced by a live agent run:

- Agents that self-exit (the normal i_am_idle -> container shutdown, exit 0)
  were never finalized. _finalize_spawn_session is only called from
  stop_agent(), but a graceful self-exit goes through _handle_stopped_container,
  which set the instance OFFLINE and returned without finalizing — leaving the
  spawn-session row open with zero tokens. Finalize there for both graceful
  (exit_reason="completed") and crash (exit_reason="crashed") exits.

- sum_transcript_usage double-counted. Claude Code logs one assistant message
  as several JSONL lines (one per content block — thinking / text / tool_use),
  each repeating the same message.usage, so summing every line roughly doubled
  the totals. De-duplicate by message.id.

Verified against a live agent transcript: the raw sum (12, 1068, 62502, 115828)
vs the de-duped (6, 516, 62502, 63336), which matches the session's
authoritative result.usage exactly.

* feat(usage): fall back to the transcript in the live token sweep

The 60s token sweep read only the agent SDK's /usage/status, which races
container teardown and reports zero mid-run — so live usage (and the
USAGE_SNAPSHOT pushed to /ws/system) stayed at zero for active agents.
Extract _resolve_active_tokens: try the SDK, then fall back to the durable
transcript (the same source finalize uses) so running agents report live.

* feat(usage): add GET /usage/sessions for the dashboard's Recent Sessions

The panel's Recent Sessions table was mock-only — the backend had no sessions
endpoint, so production always showed 'No sessions recorded yet'. Add
UsageService.get_recent_sessions + a /usage/sessions route returning the most
recent spawn-session rows (token totals + cost), and point the panel client
at it.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 23:19:50 +02:00
Renn F 27fa74be70 chore(release): bump version to 0.2.0
Bump pyproject, panel/package.json, roboco.__version__, and config.app_version
to 0.2.0, and cut the CHANGELOG [Released] bucket as [0.2.0] - 2026-06-11
(rate-limit handling, token usage & cost analytics, /ws/system, and the
workspace/gate fixes).
2026-06-11 19:10:15 +02:00
303c2db289 Fix: rate limit real probe (#110)
* fix(rate-limit): real provider liveness probe instead of time-based stub

The rate-limit recovery sweeper cleared a provider and resumed parked agents
purely on elapsed time — _do_probe was a stub that always returned True once
the retry_after window passed, so it never confirmed the provider had actually
stopped rate-limiting us. Under a sustained limit that resumes agents straight
into another 429, re-parking them: avoidable churn.

Make the probe real. _do_probe now issues a free, unmetered liveness call —
Anthropic GET /v1/models or Ollama GET /api/tags — and treats any non-429
response as the limit having lifted. A 429 keeps the provider parked; a
network error keeps it parked too (retry next sweep). When the provider can't
be probed (no API key, or an unrecognized provider), it falls back to the
prior time-expiry optimism rather than stranding agents. _probe_target keeps
URL/header resolution separate and testable, and _do_probe stays a
monkeypatchable boundary so the existing sweep tests are unaffected.

Also drop two acceptance-criteria-number labels from comments in this file.

* chore(rate-limit): clear merged gate debt in rate-limit tests + deps lint

The rate-limit PR landed with ruff violations the full gate flags but the
authors' runs missed: test_rate_limit_sweep.py was unformatted, and
test_rate_limit_tracker.py had unsorted/unused imports and magic-value
comparisons. Format the sweep test, drop the dead imports, and bind the
magic comparison values to locals. Also strip acceptance-criteria-number
labels from comments/docstrings across the three rate-limit test files
(leaving genuine acceptance_criteria=[...] test data untouched), and add
api/deps.py to the PLC0415 per-file-ignore — it is the DI wiring hub and
defers a couple of service imports to call time to avoid import cycles,
the same rationale already applied to api/routes, runtime, and services.

* fix(rate-limit): resolve redis type errors in RateLimitStateTracker

A cold mypy run (the gate's true state — prior passes were warm-cache only)
flagged four redis-typing errors in rate_limit_tracker.py that the merge
missed: three unused type:ignore[type-arg] on redis.Redis, and an
aclose() the bundled redis type stub doesn't expose.

Drop the now-unused ignores, and close the scan client via
'async with redis.from_url(...) as r:' instead of a finally-block
aclose(). The context manager closes the client on exit using the modern
redis.asyncio API — no deprecated close(), no stub-missing aclose(), no
suppression. Extend the test's redis mock to model the async
context-manager protocol so it returns itself on enter.

* test(prompter): pass route='main_pm' in the product main-PM routing test

Pre-existing master failure, unrelated to the rate-limit work. The test is
named ...product_routes_to_main_pm and asserts team=MAIN_PM, but called
confirm_live_draft without a route, so it got the 'board' default — which
assigns the Product Owner and yields team=BOARD by design (the board-review
path keeps the root at team=board until the CEO approves). The Main-PM path
is selected with route='main_pm', exactly as the sibling
...main_pm_route_assigns_main_pm test does. Add the missing kwarg so the test
verifies the path it names; behaviour under test is unchanged.

* Updated uv.lock

* refactor(complexity): bring all rank-C blocks under the xenon B ceiling

The full quality gate's xenon step (--max-absolute B --max-modules A
--max-average A) failed on eight rank-C blocks plus the extraction module
average — debt the rate-limit and token-analytics merges deferred. Reduce
each by extracting cohesive helpers, behaviour unchanged:

- orchestrator._probe_one_provider: split into _too_early_to_probe,
  _on_probe_success, _on_probe_failure, _parked_agents_for.
- rate_limit_tracker.list_rate_limited_providers: extract _read_rate_limited_entry
  and a _decode helper.
- trigger_filter.decide_spawn: extract _stale_trigger_decision (drops the
  PLR0911 suppression too).
- ollama_embedder (embed_query, _embed_batch_sync, aembed_query,
  _embed_batch_async): share _rl_backoff / _map_embed_error / _log_429 /
  _sleep_connect_retry / _asleep_connect_retry; remove a dead post-loop guard
  in aembed_query.
- mentor._synthesize_answer: extract _select_system_prompt and
  _answer_from_response.
- indexes/base.ask: extract the 429-retried LLM call into _ask_llm.
- extraction.__init__: extract _compile_patterns so the module average
  lands at rank A.

xenon now exits 0; rate-limit, optimal_brain, extraction, and events suites
all green.

* chore(deps): drop obsolete types-redis stub; honor redis 8.0 inline types

types-redis 4.6 (typed for redis 4.x) shadowed redis 8.0's own inline types,
which both masked real annotation mismatches in stream_bus.py and forced
awkward workarounds elsewhere. The stale stub is why the mypy gate only ever
passed warm-cached: a cold run under the wrong stub disagreed with the code.

Remove types-redis (and its orphaned transitive stubs) so mypy uses redis's
shipped types. That surfaces that xreadgroup/xclaim return bytes-keyed records
while _handle_message is annotated str — the code already decodes bytes
defensively, so this is an annotation gap, not a runtime bug. Make the types
honest: cast each result to its concrete shape and decode the stream name and
message id to str at the dispatch boundary via a _to_str helper.

mypy roboco/ is now clean cold (247 files) against redis's real types; events
suite green.

* Updated uv.lock

* fix(workspace): install the dev extra so agents can run make quality

Agent workspaces were set up with plain `uv sync`, which installs only the
project's default dependency group (pytest) — not the `dev` *extra* where the
gate tools live (ruff, mypy, xenon, radon, vulture, bandit, deptry). So an
agent's .venv had pytest but no linters, and `make quality` died immediately
on `ruff: command not found`. Agents literally could not lint, type-check, or
complexity-check their own work, which is how format/mypy/xenon debt merged
unseen. Sync the `dev` extra (`uv sync --extra dev`) so the workspace gets the
full toolchain the setup's own docstring already promised.

* fix(panel): rate-limit endpoint shape + websocket path

Two panel-facing breakages from the rate-limit rework:

- GET /api/system/rate-limits returned a raw list, but the panel store reads
  response.entries — so `r.entries is not iterable` crashed the banner sync on
  page load. Return the panel's contract: a { entries: [...] } envelope whose
  items are camelCase {provider, affectedAgents, hitAt, resumeAt,
  retryAfterSeconds}, derived from the raw Redis state (resumeAt = hitAt +
  retryAfter).
- The rate-limit websocket hook passed "/ws/system" while getWebSocketUrl()
  already supplies the "/ws" base, producing the doubled "/ws/ws/system" URL.
  Pass "/system" to match the agents/channels/notifications hooks.

Note: the backend /ws/system endpoint itself does not yet exist (the rework
shipped the panel hook only); the REST fix keeps the banner correct on load
and reconnect until that endpoint is built.

* test(workspace): assert uv sync installs the dev extra

Follow the workspace setup change: the dependency-install command is now
`uv sync --extra dev` so the agent workspace gets the lint/type/complexity
toolchain. Update the three assertions that pinned the old `uv sync`.

* feat(ws): add /ws/system stream and bridge rate-limit events to the panel

The rate-limit rework shipped the panel's websocket hook but no backend: there
was no /ws/system endpoint and nothing forwarded RATE_LIMIT_HIT/LIFTED to a
socket, so the banner got no live updates.

Build the missing half:
- ConnectionManager grows a system-wide connection set with connect_system /
  broadcast_system, and disconnect() now clears it.
- A /ws/system websocket endpoint (operator stream, no per-agent keying) with
  the same connected + ping/pong lifecycle as the other streams.
- websocket_bridge subscribes RATE_LIMIT_HIT/LIFTED and forwards each to
  broadcast_system tagged with the type the panel switches on. Both events
  ride the same StreamEventBus singleton, and the subscriptions register
  before start_listening(), so the consumer reads their streams.

Pairs with the panel hook now passing '/system' (getWebSocketUrl supplies the
'/ws' base). Covered by handler, manager, and endpoint-lifecycle tests.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 18:16:20 +02:00
98e618c243 [aaac85d2] Rate limit guardrails for Anthropic and Ollama providers (#104)
* [25aa5b24] Implement rate-limit Zustand store, Axios interceptor, WebSocket hook, banner component, and page-load sync (#99) (#101)

* [25aa5b24] feat(rate-limits): add types, Zustand store, Axios 429 interceptor, WS hook, sync hook, and banner component

- panel/src/types/rate-limits.ts: RateLimitEntry, RateLimitHitEvent, RateLimitLiftedEvent, RateLimitApiResponse
- panel/src/store/rate-limit-store.ts: useRateLimitStore with Map state, hitRateLimit/liftRateLimit/syncFromApi
- panel/src/lib/api/rate-limits.ts: GET /api/system/rate-limits with isMockMode guard
- panel/src/lib/api/client.ts: 429 interceptor dispatches to store first, Sonner toast on retry exhaustion
- panel/src/hooks/use-rate-limit-websocket.ts: RATE_LIMIT_HIT/LIFTED events + onReconnect callback
- panel/src/hooks/use-rate-limit-sync.ts: mount sync + no-op with console.warn when endpoint unavailable
- panel/src/components/rate-limit/rate-limit-banner.tsx: amber rows with countdown, no dismiss button
- panel/src/app/(dashboard)/layout.tsx: RateLimitBanner mounted below Header
- store/index.ts, hooks/index.ts: export new store and hooks

* [25aa5b24] fix(rate-limit-banner): use lint-clean countdown pattern (computeSecondsLeft outside render)

* [25aa5b24] fix(client): add real retry loop to 429 interceptor so Sonner toast fires on exhaustion

- Increment error.config._retryCount and return api(error.config) when
  retryCount < RATE_LIMIT_MAX_RETRIES, actually retrying the request.
- Toast fires only when retryCount >= RATE_LIMIT_MAX_RETRIES (3 attempts).
- Fixes AC4: toast was dead code because without return api(error.config)
  every 429 saw retryCount=1, permanently below the threshold of 3.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [4112cd34] feat(rate-limit): add RateLimitError with 5-retry exponential backoff at all LLM call sites (#102) (#103)

- Create roboco/services/exceptions.py with RateLimitError(provider, retry_after),
  HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES constants, and
  parse_retry_after_header() helper
- extraction.py: extract _call_anthropic_with_retry() helper; retry Anthropic
  call 5x on 429 with exponential backoff; re-raise RateLimitError from outer
  except instead of swallowing it
- ollama_embedder.py: 5-retry outer loop (429) wrapping existing 3-retry inner
  loop (ConnectError/Timeout) for all 4 call sites; two concerns kept isolated
- indexes/base.py, mentor.py, validator.py: replace magic 429 literals with
  HTTP_TOO_MANY_REQUESTS; 5-retry loop on 429 for LLM calls
- middleware.py: add rate_limit_exception_handler returning HTTP 429 with
  Retry-After response header
- tests/unit/services/test_rate_limit_retry.py: 28 tests covering exhaustion,
  Retry-After header sleep, partial retries then success, ConnectError isolation

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [18107054] feat(rate-limit): Redis rate-limit state tracker + i_am_blocked rate_limited path (#105) (#106)

- Add RateLimitStateTracker in roboco/services/gateway/rate_limit_tracker.py
  with activate(), clear(), is_rate_limited(), get_state(),
  increment_probe_failures(), reset_probe_failures() backed by redis.asyncio
- Add RATE_LIMIT_HIT = "rate_limit.hit" to EventType StrEnum in events.py
- Add _handle_rate_limited_parking() to Choreographer: intercepts
  i_am_blocked(reason='rate_limited') before block state transition,
  parks all active agents sharing affected provider via mark_waiting_long,
  publishes RATE_LIMIT_HIT event to StreamEventBus, task stays in_progress
- Add get_provider_for_agent() and get_active_agent_slugs_for_provider()
  helper methods to AgentOrchestrator
- Wire orchestrator and stream_bus into ChoreographerDeps via deps.py
- Add test_rate_limit_tracker.py (basic ops, probe failures, cross-reconnection
  persistence, provider isolation) and test_i_am_blocked_rate_limited.py
  (AC3/AC4/AC5 coverage: task stays in_progress, mark_waiting_long call count
  equals active agent count, RATE_LIMIT_HIT event payload structure)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [5501e4b4] Wire RateLimitStateTracker into live orchestrator paths — 4 CEO-identified integration gaps (#109)

* [8451ca50] feat(gateway): wire RateLimitStateTracker.activate() into i_am_blocked rate-limited path and add provider-rate-limit gate to decide_spawn() (#107)

- Add provider/provider_rate_limited optional fields to TriggerContext (backward-compatible defaults)
- Insert rule 2 in decide_spawn(): QUEUE when trigger.provider_rate_limited is True with reason 'provider X rate-limited'
- Call RateLimitStateTracker(provider).activate() in _handle_rate_limited_parking() after mark_waiting_long loop (wrapped in contextlib.suppress for Redis fault tolerance)
- Extend gateway_pre_spawn_check() with optional provider param; check RateLimitStateTracker.is_rate_limited() when provider is known
- Pass provider=self.get_provider_for_agent(agent_id) from orchestrator call site
- Add TestProviderRateLimitGate (6 tests) to test_trigger_filter.py
- Add TestRateLimitTrackerActivateOnParking (6 tests) to test_i_am_blocked_rate_limited.py
- All 38 unit tests pass; ruff and mypy clean on changed files

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [e9cef0f0] feat(rate-limits): sweeper probe loop, CEO notification, and GET /api/system/rate-limits endpoint (AC4, AC8, AC9) (#108)

- Add RATE_LIMIT_LIFTED event type to EventType enum in models/events.py
- Add RateLimitStateTracker.list_rate_limited_providers() classmethod to scan
  Redis for all currently rate-limited providers (used by the new endpoint)
- Add orchestrator._rate_limit_probe_loop(): background task started/stopped in
  start()/stop(), runs _sweep_rate_limit_probes() every 30s
- Add orchestrator._probe_one_provider(): checks estimated_lift_at gate, calls
  _do_probe(); on success: tracker.clear(), resolve_wait() for all parked agents
  with waiting_for='rate_limit_lifted' matching the provider, publishes
  RATE_LIMIT_LIFTED event; on failure: increments probe_failures counter, sends
  CEO notification at threshold 10 (once per episode via _rate_limit_ceo_notified)
- Add orchestrator._make_tracker(): injectable factory for RateLimitStateTracker
- Add orchestrator._do_probe(): overridable async bool probe (default: True)
- Add orchestrator._notify_rate_limit_ceo(): high-priority notification to CEO
  containing provider name, duration since activation, and paused agent count
- Add roboco/api/routes/system.py with GET /rate-limits endpoint (AC9)
- Register system_router in app.py under /api/system prefix
- Add 17 unit tests in tests/unit/runtime/test_rate_limit_sweep.py covering
  all AC4/AC8/AC9 paths: probe success/failure, CEO threshold, endpoint schema

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 09:21:26 +02:00
ff35a646fa Chore: reduce analytics complexity (#100)
* refactor(analytics): reduce cyclomatic complexity in usage/pricing/rollup

Collapse the three near-identical get_by_* aggregation methods in
UsageService into a shared _aggregate_by helper parameterized by group
column and key name, and centralize token null-coalescing in a
_row_tokens helper. Extract the per-row upsert in _sweep_daily_rollup
into _upsert_rollup_row, and the pricing-table lookup into
_lookup_prices. All blocks now rank <= B and both modules rank A, so the
xenon gate passes; behavior is unchanged and existing tests stay green.

* feat(billing): make token pricing provider-aware

Distinguish three cases when a model has no per-token rate: a non-Anthropic
model (local Ollama, or an Ollama Cloud ":cloud" model billed by flat
subscription / GPU-time) legitimately has no per-token cost and returns 0.0
silently; an unpriced Anthropic ("claude"-named) model also returns 0.0 but
logs a warning, since that is real spend being undercounted and catches new or
renamed Claude models missing from the table. Folds the old ollama/ prefix
special-case into the general non-Anthropic path so there is one code path,
and replaces the blanket 'no pricing data' warning that fired even for
self-hosted models.

* fix(tasks): preserve ownership when force-unclaiming to pending

The stale-claim reaper and the dependency-blocked release both routed through
_force_unclaim_to_pending, which nulled assigned_to and left the task in a
pending state owned by nobody — no dispatcher re-spawns an ownerless pending
task, so it went dormant. The dispatcher-side claimed_by fallback only masked
half the cases.

Capture the owner before releasing the claim and keep both assigned_to and
claimed_by pointed at it (mirroring the unblock restore), releasing only the
live claim (active_claimant_id + heartbeat) and the WorkSession. The same agent
now resumes the task once it re-dispatches. Updates the reaper test that
asserted the old orphaning behavior and adds owner-preservation coverage for
both the reaper and dependency-release paths.

* fix(tasks): unblock restores the owner into both ownership fields

Audit follow-up to the force-unclaim ownership fix. unblock() only restored
assigned_to from blocker_raised_by, which block() stashes solely from
assigned_to. A task claimed via give_me_work (claimed_by set, assigned_to null)
therefore unblocked into a split-owner state — assigned_to null but claimed_by
set — that both the dev dispatcher and the PM pool-router race to pick up. It
also left claimed_by pointing at the resolver PM after an escalation.

Resolve the owner as blocker_raised_by or assigned_to or claimed_by and write
it to both fields, matching the force-unclaim and reassign convention so the
original worker resumes cleanly. Adds coverage for the give_me_work-claim case
and asserts owner restoration on the existing in_progress-resume test.

* test(orchestrator): cover dev owner resolution and the claimed_by fallback

_resolve_dev_owner_uuid had no coverage. Add the status-dependent precedence
(claimed/blocked prefer the live claimant; other statuses prefer the
PM-assigned owner) and the half-reap fallback where a pending task with
assigned_to nulled still resolves its owner from claimed_by instead of going
dormant.

* fix(tasks): wire the pre-block snapshot so unblock(restore=True) works

The restore=True path on a PM unblock was a no-op: pre_block_state /
pre_block_assignee (migration 006) were read by unblock_with_restore but never
written, so it always fell through to legacy unblock() and the restore flag did
nothing.

Snapshot the resting status + owner at every block entry (dependency block,
soft block, escalation) before mutating, capturing only the first block in a
chain so a re-block doesn't overwrite the original state. Escalation snapshots
the outgoing owner, not the escalation target, so restore returns the original
worker. The restore path applies the same branchless guard legacy unblock()
relies on — a snapshotted in_progress with no branch diverts to pending instead
of looping the dispatcher — and is extracted into _apply_pre_block_restore to
keep complexity under the gate. Adds coverage for snapshot capture, restore,
the branchless divert, and escalation owner restoration.

* test(tasks): update orphan-reconciler and dependency-release tests for owner preservation

Both the startup orphan reconciler and the dependency-blocked claim release
route through unclaim_for_reaper / _force_unclaim_to_pending, which now preserve
the owner instead of nulling assigned_to. Update the two tests that asserted the
old orphaning behavior to assert the owner is kept (so the same agent resumes)
while the live claim is released.

* chore(tests): scrub internal work-item labels from test names, docstrings, comments

Rename four test files that carried audit work-item IDs in their filenames
(test_p0_7_branch_atomicity, test_p2_8_orphan_reconciler,
test_p2_9_autogen_prompt_layer, test_p2_7_attempt_id) to describe what they
test, and strip the matching P-/D-/S- cluster labels from docstrings, comments,
and assertion messages across the test suite and two orchestrator comments.
These are internal references with no meaning in the codebase; behavior is
unchanged.

* style: reformat assertion line shortened by the internal-ref scrub

* build: waive unreachable torch CVE-2025-3000 in pip-audit gate

torch is a transitive CPU-pinned dep (piragi / sentence-transformers) never
loaded at runtime — the stack uses Ollama over HTTP for all embeddings/LLM, so
the vulnerable torch.jit.script path is unreachable. CVE-2025-3000 is MEDIUM,
local-only, with no published fix. Documented --ignore-vuln waiver; revisit when
a fixed torch ships.

* fix(orchestrator): route unplaceable pending tasks to main-pm instead of dropping them

_get_routing_target returned None when a 'dev'-classified task had no cell
agent (no team, or a non-cell team like fullstack/system) or when the routing
classification was unrecognized. _route_unassigned_pm_task logged 'no routing
target found' and returned, leaving the task ownerless and pending — and no
dispatcher re-spawns an unrouted pending task, so it went dormant for 10+ min
until the stuck-task detector caught it.

Fall back to main-pm (the same default cell_pm routing and escalation already
use) so the task is always owned and triaged, never stranded. Logs the fallback
so unplaceable tasks stay visible. Adds a test asserting no (routing, team)
combination ever resolves to None.

* fix(panel): make intake chat markdown inherit the bubble's text color

MarkdownBody is shared by the assistant (text-foreground) and user
(text-primary-foreground) bubbles. [&_*]:!text-inherit only colored the prose
div's descendants, so the prose div itself kept the prose typography body color
(gray) and children inherited that — unreadable on the muted assistant bubble.
Add !text-inherit on the prose div itself so it inherits the bubble's color
too; descendants then inherit the correct foreground. Fixes both bubbles without
hardcoding a color.

* fix(prompter): keep a board-reviewed product on the board team so Approve & Start shows

A product coordination root confirmed via 'Board review & Start' is assigned to
a board reviewer (product-owner) for review, but create_task_from_draft set
team=main_pm for every product unconditionally. The CEO's Approve & Start gate
keys on team=board, so the button never appeared — and because the owner stayed
a board agent while the team said main_pm, the dispatcher routed it to the board
path (nothing left to do after review) and the task stranded at pending, with
the board agent fruitlessly trying to escalate it up.

Route a product by its assignee: a board reviewer keeps it team=board (so the
gate appears and approve_and_start later hands it to Main PM), while a main-pm
assignee — the 'Approve & Start' straight-through path — is team=main_pm. Adds
_assignee_is_board mirroring TaskService's board-role check, and a test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 04:36:17 +02:00
b3057628b0 [499f9eb1] Token Usage & Cost Analytics — Full-Stack Instrumentation, Persistence, and Visualization (#90)
* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88)

- Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice
- Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage
- Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks
- Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost
- Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel
- Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination)
- Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table)
- Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through
- Install recharts 3.8.1
- Export all new symbols through their barrel index.ts files

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89)

* [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables

Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups
tables with correct BIGINT columns, indexes, and unique constraint.
Chain: 025_agentrole_prompter → 026_token_usage_tables.

* [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation

Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable
to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns
match the migration schema with BIGINT token counts and proper indexes.

* [10372f0f] feat(billing): add pricing module with calculate_cost() function

Create roboco/billing/__init__.py and roboco/billing/pricing.py with
calculate_cost() supporting Claude opus/sonnet/haiku models with
input/output/cache pricing. Unknown models return 0.0 without raising.

* [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK

Extend _SessionState with token counters. Add TokenReportRequest and
TokenUsageStatus models. POST /usage/report additively accumulates token
counts; GET /usage/status returns current session totals for sweeper polling.

* [10372f0f] feat(orchestrator): add token usage instrumentation hooks

- _launch_spawn() calls _record_spawn_session() after successful container spawn
- stop_agent() calls _finalize_spawn_session() before container removal
- _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick
- New methods: _record_spawn_session, _finalize_spawn_session,
  _sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section

* [10372f0f] feat(api): add token usage analytics API with 7 endpoints

Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py.
Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team,
/by-model, /projection, /cache-efficiency. Register in app.py.

* [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard

Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas.
Add usage_summary: UsageSummary | None to CEOOverview. Update
get_ceo_overview() to populate usage_summary from daily_usage_rollups.

* [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py

- Remove unreachable `except Exception as e` block in orchestrator.py
  _sweep_daily_rollup() (lines 3376-3381) which referenced undefined
  `agent_id` and was copy-pasted from _sweep_token_snapshots by mistake
- Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/
  haiku tiers with all 4 token types, unknown model → 0.0, empty string
  → 0.0, and substring-match priority (longer fragment wins)
- Add tests/unit/services/test_usage.py: 25 tests covering get_summary
  trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model
  pct_of_total summing to 100%, get_projection formula (avg_daily×30),
  and get_cache_efficiency hit-rate and cost_saved arithmetic
- pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target)

* [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation

get_time_series() previously computed total_tokens as tokens_input +
tokens_output only. get_summary() includes all 4 token types (input +
output + cache_read + cache_write). AC9 requires both endpoints to agree
on their totals for the same period.

Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in
get_time_series() and include them in the total_tokens calculation.

Also adds 4 new unit tests in TestGetTimeSeries covering:
- total_tokens includes cache_read and cache_write (the AC9 guard)
- zero cache tokens still produces correct total
- empty result returns empty list
- required fields are present in each point

* [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10)

- Remove import math (F401 — never used)
- Remove text from sqlalchemy import (F401 — never used)
- Remove unused local calculate_cost import inside get_cache_efficiency (F401)
- Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent,
  get_by_team, and get_by_model; update grand_total and per-item total to
  include all 4 token types so totals match get_summary() (AC10 fix)
- Update test mock rows to include explicit tokens_cache_read=0 and
  tokens_cache_write=0 so they work with the fixed code
- Add new test cases: test_cache_tokens_included_in_total_tokens and
  test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94)

Update all usage-related frontend code to match the actual FastAPI backend
response shapes and endpoint paths:

- panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct
  endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model,
  /usage/by-team, /usage/time-series, /usage/projection,
  /usage/cache-efficiency); send period query param (24h/7d/30d not hours);
  mock generators produce data matching real backend shapes exactly;
  getUsageSessions returns [] in prod (no /usage/sessions endpoint exists)

- panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary
  (tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow
  to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow,
  UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use
  bucket field; update UsageSession to use agent_slug

- panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types;
  add useTeamUsage, useUsageProjection, useCacheEfficiency hooks

- panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field
  (not timestamp) for axis labels
- panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and
  total_tokens (not agent_name/tokens_today)
- panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept
  TeamUsageRow[] from API directly
- panel/src/components/metrics/model-usage-donut.tsx: use total_tokens,
  cost_usd, pct_of_total (not tokens/cost/percentage)
- panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys
  updated
- panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary
  with tokens_input/tokens_output/total_cost_usd/trend_pct
- panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add
  TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types
- panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug
- panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95)

- Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace
  hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT
- Add UUID to TYPE_CHECKING imports to satisfy ruff F821
- Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring
  self._lock so the SDK HTTP round-trip does not hold the lock
- Add usage_session_id: UUID | None field to AgentInstance dataclass
- Change _record_spawn_session to return UUID | None; wire return value
  back to instance.usage_session_id in _launch_spawn
- Update _finalize_spawn_session to use WHERE id=usage_session_id for
  direct session row lookup when usage_session_id is not None
- Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup
  aggregate query to avoid re-aggregating all-time history each sweep

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98)

- pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75)
  and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama
  zero-cost early-return; add structlog warning for unmatched model names
- app.py: move usage_router import before routes.v1 block (ruff isort fix)
- orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import;
  add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports
- orchestrator.py _sweep_token_snapshots: prefer direct lookup by
  instance.usage_session_id; fall back to agent_slug heuristic only when None
- tests: add test_sweep_daily_rollup_inserts_new_row and
  test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py
- usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* Mypy compliance

* fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation

The master merge brought in 026_completed_dependency_ids alongside the rework's
026_token_usage_tables — both off 025, forking the alembic head and breaking
the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids
(linear chain, single head).

Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old
NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root
to PENDING (Main PM's claim source). Update the assertion.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-10 14:38:44 +02:00
b9082a5c70 Fix: agent idle deadlock and lifecycle hardening (#96)
* fix(panel): cap dialog height and pin footer so actions stay reachable

Shared DialogContent now caps at max-h-[85vh] with overflow-y-auto, and the
footer is sticky to the bottom. Long content (e.g. a pasted change-request
note) no longer pushes the submit/cancel buttons past the viewport — the body
scrolls while the actions stay visible. No-op on dialogs that already fit.

* feat(notifications): suppress duplicate same-purpose notifications at send

A notification is not created when an unacknowledged one with the same purpose
— same sender, same type, same task, overlapping recipients — already exists.
Body text is not compared, so rewording cannot defeat it; a different type,
task, sender, or an already-acked recipient all still send through. Stops
agents that loop re-issuing the same signal from piling up unread that
soft-blocks the recipient's idle path.

* fix(gateway): stop board/PM lifecycle verbs from 500-crashing

Two unguarded crashes that wedged the org in respawn/escalate loops:

- escalate_to_ceo dereferenced None.status when the verb runner declined the
  escalation (task not in awaiting_pm_review — e.g. a board agent escalating a
  blocked task). It now returns a clean invalid_state. The message/remediate
  build moved to a helper so the function stays within the complexity gate.

- The coordination-root git ops (pr_target, pr_merge, PR update, branch-token
  resolve) called UUID(str(task.project_id)) directly, which raised on a
  coordination/integration task (project_id is None — 'badly formed hexadecimal
  UUID string'). They now resolve through _project_for_task, which falls back to
  the product's repo for project-less roots.

* refactor(intake): split out _block_to_chunk per-block classifier

Extract the per-block classification from _blocks_to_chunks so each function
stays within the xenon cyclomatic-complexity gate (was rank C). Behaviour is
unchanged — verified by the existing intake_driver tests.

* feat(gateway): make the i_am_idle unread soft-block satisfiable

The soft-block on unread A2A / @mentions had no clearing path, so once those
briefing fields populated an agent could never idle — a whole-org deadlock.
Keep the guard (it is correct) and add the missing clear paths:

- New read_messages content verb (schema -> route -> handler ->
  a2a.mark_all_read -> MCP tool -> role do_tools): bulk-zeroes the caller's
  unread A2A and stamps read_at. The idle hint now points to it.
- list_unread_mentions returns UNACKED MENTION-type notifications (each @mention
  already raises one via messaging._notify_mentions) instead of raw,
  unconditional mentions, so they clear via the existing notify_ack. No schema
  migration needed.

The soft-block is now satisfiable: A2A via read_messages, mentions and
notifications via notify_ack.

* fix(tests): repair notification-dedup db.scalar mocks + prompter agent seeding

The notification send-dedup added a db.scalar() purpose-lookup to
_create_notification; the two hand-rolled _FakeDb test stubs (test_notification,
test_a2a_priority_tristate) had no scalar() method → AttributeError. Add
scalar() returning None (no duplicate) so creation proceeds.

Separately, the prompter '& Start' route tests assign the draft to a fixed
product-owner / main-pm AGENT_UUID but only seeded system + CEO, so the
assigned_to FK failed in isolation (and main-pm flaked in the full suite). Seed
both via idempotent merge() in _seed_project_and_ceo.

* fix(git): gitignore .pnpm-store + flag GH001 push rejection as permanent

A dev once committed the ~115 MB pnpm store → GitHub GH001 (>100 MB) pre-receive
reject → open_pr retry-loop. Two root fixes:
- Add .pnpm-store/ to .gitignore — an ignored dir can't be staged by any git add.
- push() restates a GH001 / file-size rejection as an unmistakable PERMANENT
  error pointing at i_am_blocked, so the agent stops blind-retrying a push that
  can never succeed (it otherwise mis-reads the raw output as a transient timeout).
  The per-verb retry cap (open_pr: 5) already bounded the burn; this ends it.

* fix(gateway): accept a PM decision note as satisfying the complete/submit_up reflect gate

A cell/main PM that wrote a fresh decision but no separate reflect note bounced
on the reflect tracing-gate indefinitely (re-confirmed live: cell PMs looped on
cell_pm_complete -> journal:reflect until reaped, burning tokens — worse because
each respawn resets the per-verb retry cap). For a PM closing/submitting a task
the decision note already documents the close; the separate reflect is the
redundant artifact weak-model PMs forget. Accept a fresh decision as satisfying
reflect for complete + submit_up — the gate still requires a decision +
substantive notes, so the close stays documented.

NOTE (enforcement tradeoff, flagged for CEO review): this intentionally relaxes
the PM complete/submit_up gate. It does NOT touch the developer i_am_done gate.

* feat(gateway): refuse i_am_idle when a PM still owns a task awaiting its review

A cell/main PM once tried to 'send work back' by DMing the developer and going
idle — but a DM changes no task state, so the task stayed awaiting_pm_review and
the orchestrator just re-dispatched the PM in a loop. i_am_idle now refuses (like
the pending-assignment guard) when a PM owns an awaiting_pm_review task, with a
clear remediation: complete() to finish, or reassign()/delegate() to route it
back. PM-only; devs/QA/doc unaffected. Pairs with the reflect-gate relaxation so
the PM can actually complete instead of looping.

* feat(gateway): push a prior-work handoff digest into task-scoped briefings

A freshly spawned or respawned agent previously started cold on every
lifecycle hand-off: the prior worker's PR, commits, acceptance status and
journal highlights lived in task evidence but were pull-on-demand, so each
new role agent re-explored the codebase from scratch — wasted tokens and
fragile context loss across respawns.

build_task_handoff() composes a compact, DB-only digest (no git diff) and
_briefing_for() now attaches it to context_briefing whenever the caller
already holds the task row. The digest is built only from a passed-in task,
so there are zero extra fetches: every resumption entry point (give_me_work
and pm_give_me_work, i_will_work_on, i_will_plan, triage/triage_all,
i_am_done, submit_up, escalate_up, complete) threads the loaded task, while
id-only correction/rejection paths cleanly omit it.

Every field is type-guarded so a partial row never leaks a non-serialisable
value into the envelope.

* docs(prompts): tell agents to resume from the briefing handoff before re-exploring

The base prompt described the success envelope but never told agents to act
on context_briefing, so a respawned or hand-off agent would re-scan the
whole repo and re-derive the plan even when the briefing already carried the
prior worker's PR, commits, acceptance status and journal highlights.

Adds a 'Resume from your briefing' section that walks each task_handoff
field and instructs the agent to continue from it — and to read the unread
A2A / mention / notification lists, which are messages addressed to them.

Pairs with the gateway change that now pushes task_handoff into every
task-scoped briefing.

* feat(tasks): remember cleared dependencies so the unblock briefing can surface them

When an upstream dependency completed, _unblock_dependents removed its id from
the dependent's dependency_ids to let it be claimed — destroying the only
record of which upstream task had just landed. The revived dependent then
re-discovered that work from cold.

Adds tasks.completed_dependency_ids (Alembic 026, uuid[] default '{}'):
_unblock_dependents now appends the cleared id there instead of only dropping
it, and the briefing handoff digest surfaces it so the agent picking the task
back up knows its blocker cleared because that upstream work shipped. The base
prompt documents the field.

Migration round-trip verified against postgres (upgrade adds the column,
downgrade drops it).

* docs(prompts): instruct PMs to split oversized tasks into per-concern subtasks

A subtask carrying a long acceptance list or spanning multiple layers/files
drove repeated QA failures and a PM revision loop — QA can't pass a partial,
and the dev keeps re-touching unrelated parts. Nothing in the PM prompts told
them to decompose by size/concern.

cell_pm gets a 'Sizing' rule: one subtask = one focused concern with ~2-4
criteria and its own dev->QA pass; decompose anything larger before
delegating, sequencing with dependencies. main_pm gets a matching reminder to
scope each cell's slice to that cell's layer rather than handing a cell a
cross-layer monolith that just pushes the problem down a level.

* fix(gateway): mirror the task= kwarg on ChoreographerHelpers helper signatures

The handoff-digest change added a keyword-only task= parameter to
_briefing_for and _build_tracing_gap in _impl, but the ChoreographerHelpers
base that the role mixins inherit still declared the old signatures, so the
composed Choreographer had two incompatible base definitions (mypy [misc]).
Sync the base declarations to match.

* fix(tasks): keep the owner on a substitute-out so the task isn't orphaned

build_substitute_update unconditionally nulled assigned_to, so any
substitute that routes to PENDING (max_retries, low_context, out_of_scope_*)
— the path a verb hitting repeated 500s or its retry limit takes — left the
task pending AND unassigned. The dispatcher only respawns a pending task when
it has an owner, so the task went dormant: no agent ever picked it back up.

Keep the task with its current owner instead. A substitute-out is almost
always a transient stall, so the task re-dispatches to the SAME agent, which
resumes from the briefing handoff. Only the task_complete -> PM-review handoff
changes owner (unchanged).

* feat(a2a): suppress duplicate unread A2A messages at send

A respawned or retrying agent could re-emit the same DM, stacking identical
copies on the recipient's inbox and re-bumping the unread count — noise that
the recipient then has to clear. The notification path already dedups; A2A did
not.

send_chat_message now suppresses a send when an identical message from the
same sender is still unread in the conversation, keyed on (conversation,
sender, message_kind, content). Genuinely different messages are never
collapsed (verified: distinct content still produces distinct rows), so this
avoids the earlier per-pair over-suppression. No migration.

* fix(panel): default the notifications view to Unread, not All

Landing on the All tab buried new notifications under everything already
seen — the most-reported annoyance. The Unread tab is the actionable view, so
make it the default; the All/Pending tabs are one click away.

* fix(panel): show clone progress during intake prep instead of a frozen pill

The first clone of a repo can take a few minutes, during which the intake
form showed only a static 'Preparing the agent…' button — indistinguishable
from a hang. Add a progress region while preparing: an elapsed timer, a
saturating progress bar (approaches but never reaches 100% until the agent
actually answers), and staged copy (spinning up → cloning → first-clone-takes-
a-while → reading the codebase) so the wait reads as work, not a freeze.

* feat(docs): index workspace-authored docs that never reached the RAG store

Docs written through roboco_docs_write land at /app/docs on the orchestrator
and index fine. But a documenter can also write docs with Edit/Write directly
in its own clone (README, CHANGELOG, workspace markdown); those resolve to a
/app/docs path that doesn't exist on the orchestrator, so the indexer reads
nothing and the docs never become searchable — a cross-container miss with no
shared mount to bridge it.

On docs completion, capture each listed doc's committed content out of the
branch (new GitService.read_file_at_branch, via git show) and write it
server-side under /app/docs before indexing, so workspace-authored docs reach
RAG too. Docs already present server-side are skipped; absolute paths and
unreadable/uncommitted files are passed over best-effort.

* feat(prompter): survive a browser reload by reconnecting to the live intake chat

The intake chat lived entirely in React state, so a page reload wiped it and
dropped the human back to the scope form — even though the agent container
outlives the page. Now the chat persists a small TTL'd slice (session id,
messages, scope, draft) to localStorage and, on mount, reconnects: it asks the
new GET /live/{id}/status whether the session is still running and, if so,
restores the history and reopens the SSE stream; if dead or expired it clears
and shows the form. A full reload doesn't run React effect cleanup, so the
navigate-away reap never fires on refresh and the session stays up.

Backend adds the status endpoint + PrompterLiveRegistry.is_alive; localStorage
is cleared on confirm, start-another, and SPA navigate-away.

* chore: remove internal session-bookkeeping refs from code comments (part 1)

Strip leaked task/finding numbers, Wave/Phase/cluster/audit labels from
docstrings and comments across services, foundation policy, runtime, mcp,
api schemas, and agent_sdk — they mean nothing to a repo reader and expose
process internals. Wording preserved; only the labels dropped. Done by hand,
one comment at a time (no scripted rewrite). _impl.py follows separately.

* chore: remove internal session-bookkeeping refs from code comments (part 2)

Finishes the manual scrub: the choreographer _impl.py docstrings/comments plus
the remaining dogfood-run ('smoke-N') labels across runtime, mcp, foundation,
api schemas, services, and agent factories. Reworded to describe the bug or
behaviour in plain words; every label dropped. The repo source is now free of
task/finding numbers, Wave/Phase/cluster/audit/smoke labels. By hand, one
comment at a time.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-10 12:04:24 +02:00
9f8834155a Feature: prompter gold upgrade (#84)
* feat(prompter): make the assistant a RoboCo insider and fully wire launch

The Prompter's intelligence lived in two thin static prompts, so it asked
generic checklist questions and produced a flat task. The launch path was
also only half-wired: the panel called the generic task-create endpoint with
no project, bypassing the Prompter's own confirm flow.

Interview brain
- Rewrite the chat system prompt with RoboCo's org model, the task-spec
  standard, a dimensions playbook, and a reflect-back, 1-2-questions-per-turn,
  auto-stop discipline.
- Inject the live projects/products list each turn so the assistant grounds
  questions in real surfaces and resolves the target itself.
- Replace the brittle phrase-match readiness with a parsed roboco-meta control
  block (parse_readiness); the block is stripped from the visible reply and the
  turn now returns draft_ready + scale.

Structured GOLD draft
- Add first-class draft fields (objective, what_this_builds, the_work, notes)
  carried in the existing draft_data JSONB — no migration.
- Compose the GOLD markdown description deterministically from those fields
  (compose_description); the model never hand-formats the body.

Adaptive routing + wired launch
- Confirm now runs through the Prompter confirm endpoint with the human's
  project/product choice and edited structured draft.
- Single-cell targets a project and the cell team; a multi-cell feature targets
  a product and becomes a Main-PM coordination root that fans out.

Frontend
- Turn-envelope draft_ready (drop the duplicated phrase-match), structured
  draft card, confirm dialog with a project/product picker and a per-cell
  The Work editor, and the corrected priority labels (0 highest .. 3 lowest).

* fix(prompter): commit session writes so they survive across requests

Session create returned 201 but the row was never durably committed, so the
immediately-following /messages call could not find it and 404'd. The prompter
routes were the only write surface that never called db.commit() — every other
write route (tasks, a2a, groups, docs, product) commits explicitly rather than
rely on the request-teardown auto-commit, which is sensitive to middleware and
teardown ordering under the production server.

- Commit explicitly in all four prompter write routes (create session, send
  message, get/generate draft, confirm).
- Fix _get_session's NotFoundError: it passed a full sentence as resource_type,
  producing the doubled "... not found not found" message; now uses the
  (resource_type, resource_id) signature.
- Panel: when a message hits a session the server no longer has, start a fresh
  session and retry once instead of dead-ending on a stale id.

Add a regression test that gives each request its own non-committing session —
the real cross-request boundary the shared-session integration tests never
crossed. It reproduces the production 404 without the route commit and passes
with it.

* refactor(prompter): drop the "GOLD" jargon for plain wording

"GOLD" was informal shorthand for "a good/well-formed spec" that should never
have been baked into the LLM prompts, comments, and docstrings as if it were a
defined term. Replace it everywhere with plain language ("a well-formed task",
"a complete task spec", "the markdown description", "structured spec fields").
No behaviour change.

* feat(intake): add the intake interviewer agent role (static definition)

Phase 1 of the intake-agent feature: a new first-class `prompter` role — the
intake interviewer the CEO chats with to draft a task. This commit defines the
role across every foundation layer (no runtime yet); spawning + the live
session come next.

- identity: Role.PROMPTER, RoleLevel.INTAKE (lowest authority), an AGENTS row
  (intake-1) on the board team, ROLE_LEVEL entry. Deliberately NOT in
  BOARD_ROLES — it interviews, it does not review.
- lifecycle: gets i_am_idle like every agent (its only verb); no
  delivery-lifecycle intents.
- journaling: ReadTier.OWN — isolated, reads only its own journal.
- role_config: human-only manifest — note + evidence only, no say/dm/notify/
  channels; allows_subagent=True (research), allows_write=False.
- agents_config derives it automatically and correctly excludes it from
  TASK_CREATOR_ROLES (it drafts, it never creates tasks).
- seed presentation ("Intake"); regenerated lifecycle artifacts.
- role system prompt: read the code first, single-CEO awareness, propose
  rather than interrogate — written against the failures we saw.
- docs: roster count 19 -> 20, org charts, verb-surface table, usage roster.

All foundation drift checks pass; role/manifest/permission tests green.

* feat(intake): migrate agentrole enum to add 'prompter'

ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'prompter' so the intake agent
row seeds/spawns against a migrated production DB. Forward-only (postgres
can't drop enum values), guarded for offline mode — matches migration 012.

* style(intake): ruff format the role additions

* fix(intake): unguard the agentrole migration so it renders offline

The enum-migration-parity test renders 'alembic upgrade head --sql' (offline)
and greps for ALTER TYPE ... ADD VALUE. The is_offline_mode() guard skipped
emitting it, so the parity check couldn't see 'prompter'. Drop the guard —
PG16 permits ADD VALUE in a transaction, same as migration 020's backfill.

* feat(intake): the live-session driver (Claude Agent SDK loop)

Phase 2 begins. The intake agent isn't a one-shot `claude -p`; it's a live
Claude Code session the human chats with. This driver is the container's loop:
open one long-lived claude-agent-sdk ClaudeSDKClient, then per human message
run a turn (query + receive_response) and stream its events out, keeping
conversation context in-process — verified against the real SDK (v0.2.94).

- StreamChunk + normalize(): map SDK messages (StreamEvent text deltas,
  AssistantMessage text/thinking/tool_use blocks, ResultMessage→session_id) to
  panel-facing chunks. Duck-typed, so it works on real SDK objects and on test
  fakes alike — SDK-free, fully unit-tested.
- IntakeDriver.run(): the loop, with injected session/source/sink seams; a turn
  failure surfaces as an error chunk without killing the session.
- SdkIntakeSession + build_intake_options: the only SDK-coupled code (lazy
  import; needs the live claude binary, so excluded from coverage).
- Add claude-agent-sdk dependency + mypy ignore-missing-stubs.

Relay, panel SSE, and the persistent on-demand spawn are the next steps.

* Updated uv.lock

* feat(intake): the panel<->agent live bridge (registry, routes, entrypoint, image)

Wires the live intake chat end to end (Phase 2 integration layer):

- prompter_live.py: the orchestrator-side per-session registry — open/close,
  push (agent->panel), stream (SSE drain), deliver (panel->container). In-process
  (the orchestrator is single-process). 7 unit tests.
- routes/prompter_live.py: GET /live/{id}/stream (SSE), POST /live/{id}/messages
  (deliver), POST /live/{id}/events (relay in); registered under /api/prompter.
  5 integration tests.
- agent_sdk/intake_main.py: the container entrypoint — a POST /turn receiver
  (the driver's MessageSource) + a relay-poster EventSink + the ClaudeSDKClient
  session, run concurrently. 4 unit tests on the wiring helpers.
- docker/agent-prompter.Dockerfile: FROM base, ENTRYPOINT = the driver (not the
  one-shot `claude` the other agents use).

Remaining for Phase 2: the orchestrator persistent-spawn path (scope->workspace
clone, CMD = driver, registry.open on spawn, reap-on-confirm) — the deploy-side
piece, best finalized against a buildable image.

* feat(intake): orchestrator persistent spawn + start/stop for the live chat

Add the task-free spawn path for the intake (prompter) agent: one fixed
intake-1 container running the Agent-SDK driver (image ENTRYPOINT, not
claude -p), one live session at a time.

- spawn_intake_session clones the scope's repo(s) via WorkspaceService
  (project -> one; product -> each distinct project, primary first),
  composes the intake-1 prompt, resolves the model, and builds docker run
  via _build_intake_run_cmd: no settings/hook mount (driver owns 9000),
  no MCP config, no -w; registers the live relay and best-effort delivers
  the opening message once the receiver is up.
- reap_intake_session closes the relay and stops the container.
- Routes: POST /live/start (project XOR product) and POST /live/{id}/stop.
- ROLE_MODEL_MAP[prompter]=opus; intake-1 -> roboco-agent-prompter image map.
- Replace the budget-sweep try/except/continue with _fetch_budget_status,
  which logs the swallow at debug instead of silently dropping it.

25 new tests; docker + the clone are mocked. End-to-end container spawn is
pending a built image and the stack.

* feat(intake): wire /prompter to the live agent — scope form + SSE chat

Replace the Ollama chat loop on /prompter with the spawned-agent flow.

- IntakeForm: pick scope (project XOR product) + opening message + Start
  before the chat; the agent clones that scope and reads the real code.
- use-prompter rewritten as the live brain (lib/api/prompter-live.ts): Start
  spawns via POST /live/start, then an EventSource on /live/{id}/stream
  streams the agent working — token deltas fill the assistant bubble,
  tool_use/thinking drive a live activity line, a draft event renders the
  existing DraftProposalCard. Messages go via POST /live/{id}/messages.
- Chat UX unchanged (Keep Chatting / Review & Confirm / ConfirmDialog reused);
  reap-on-confirm and reap-on-leave call POST /live/{id}/stop.
- Drop the dead Ollama prompterApi client; trim prompter.ts to shared types.

Frontend gate green (tsc --noEmit, lint, build). The draft event + the
/live/{id}/confirm endpoint are the Phase 4 backend seam.

* feat(intake): confirm draft -> backlog task + agent draft emission

Complete the live intake vertical: the agent proposes a structured draft and
Review & Confirm turns it into a task.

- Draft emission: the prompter prompt instructs the agent to emit a fenced
  roboco-draft JSON block when the spec is ready; the driver parses it into a
  'draft' event over the existing relay -> the panel's DraftProposalCard. The
  panel strips the raw block from the chat bubble.
- Fix a double-text bug: with include_partial_messages the reply arrives as
  both StreamEvent deltas and the final AssistantMessage; the driver now takes
  text from deltas only and the AssistantMessage for thinking/tool_use/draft.
- POST /live/{id}/confirm -> confirm_live_draft, reusing a draft->task core
  extracted from confirm_draft; reaps the session on success.
- Both prompter confirm paths create at BACKLOG, not pending: backlog is the
  holding area a draft waits in until it's reviewed and promoted to pending
  (TaskService.activate). The legacy Ollama confirm was creating at pending,
  skipping that gate — fixed.
- Remove the dead 'context' bootstrap param from the Ollama session-create
  chain (schema + route + method + tests), superseded by the live scope form.
- No suppressions: replace every type:ignore/noqa across the intake surface
  with a real fix (ORM .id -> UUID(str(x)); fakes -> monkeypatch.setattr;
  lazy imports -> pyproject per-file ignore; union-attr -> recipients[0]).

Full make quality green; frontend tsc + lint green.

* build(intake): add the agent-prompter image builder to compose

The orchestrator references roboco-agent-prompter (AGENT_IMAGES + the
_ensure_agent_image dockerfile map) and docker/agent-prompter.Dockerfile
exists, but docker-compose.yml built every other agent image up front and
left this one out — so the image wasn't pre-built for a stack bring-up.

Mirror the other specialized agent-*-image builders: build from
docker/agent-prompter.Dockerfile, tag roboco-agent-prompter, depend on
agent-base-image.

* Created docker-compose.yaml for the NAS

* fix(intake): non-blocking /live/start so spawn never times out

The start POST awaited the whole spawn — workspace clone + first-time image
build + docker run — which blew past the panel's 60s HTTP timeout ('Request
timed out. The server may be busy.') and triggered a duplicate send. Found on
the 2026-06-09 NAS smoke.

- start_intake_session opens the live relay synchronously, then spawns the
  container in the background (_spawn_intake_container_guarded). The route
  returns the session id immediately; the panel opens the SSE stream right away.
- A background spawn failure is pushed onto the relay as an 'error' event and
  closes the session, so the panel shows it instead of hanging.
- spawn_intake_session stays as the synchronous variant for direct callers/tests.
- Panel shows a 'Preparing the agent…' indicator until the first event arrives.

18 intake-spawn tests green; tsc + lint green. E2E re-validates on next smoke.

* fix(intake): propose_draft MCP tool + lock the agent down

Smoke 2026-06-09 exposed two compounding problems: the agent never reliably
emitted the draft (it narrated the spec instead of typing the magic fence), and
it had inherited the CEO's entire Claude Code env — Write/Edit/Bash + Gmail/
Notion/Calendar/Drive MCP — because bypassPermissions ignored the allowlist and
the mounted ~/.claude leaked the host MCP config.

- propose_draft: build_intake_options now registers an in-process SDK MCP tool
  (create_sdk_mcp_server + @tool). The agent calls it to submit the draft; the
  driver turns that ToolUseBlock into a 'draft' event (_is_propose_draft /
  _draft_from_tool_input, tolerant of nested/flat/JSON-string input). The fenced
  roboco-draft block stays as a fallback.
- Lockdown: strict_mcp_config=True + setting_sources=[] (ignore host MCP +
  settings); permission_mode 'dontAsk' + a can_use_tool gate enforcing a hard
  allowlist (Read/Grep/Glob/Task + propose_draft) replaces bypassPermissions.
- Prompt: call propose_draft (not a fence); the draft's downstream chain is
  backlog -> Board (PO + HoM) -> CEO approve -> Main PM, and the agent's job ends
  at the draft (it never routes or hands off).

SDK API verified against the installed claude-agent-sdk. Driver detection unit-
tested; the SDK-construction is validated on the next NAS smoke (incl. that
setting_sources=[] doesn't break the mounted-~/.claude auth).

* fix(intake): panel UX cluster from the smoke (#3/#4/#6/#12)

- #3 message boundaries: a tool call now ends the current text bubble, so the
  agent's words before and after a tool render as separate messages instead of
  one merged wall (the 'two waves merged into one bubble' the CEO saw).
- #4 activity indicator: promoted from tiny grey text to a prominent primary-
  tinted pill so 'watch it work' is actually visible.
- #12 End chat: a header button (any chat state) reaps the agent and resets to
  the form, reusing startAnother (which already stops the session). Backend
  POST /live/{id}/stop already existed.
- #6 log noise: the opening-message delivery retry logs at debug, not error —
  those failures are expected until the container receiver is up.
- Also fix a latent test gap from the #1 commit: the live-route test's fake
  orchestrator now exposes start_intake_session (the route's non-blocking entry).

Frontend tsc + lint green; live-route + prompter_live tests green.

* fix(intake): render markdown in the chat bubbles (#8)

The agent emits rich markdown (### headers, **bold**, tables, lists) but the
bubble rendered raw text, so it was illegible (CEO-flagged on the smoke). Render
assistant content with react-markdown + remark-gfm (GFM tables) in a prose
container. Adds react-markdown + remark-gfm to the panel.

* feat(intake): #14 — two start routes (Board review vs straight to Main PM)

Per the CEO spec, the draft confirm now starts the task at PENDING with an
explicit assignment instead of parking it at backlog:

- route="board" (Board review & Start): assigned to the Product Owner, so the
  orchestrator dispatches the full Board review (PO + Head of Marketing) before
  the Main PM picks it up.
- route="main_pm" (Approve & Start): assigned straight to the Main PM, who
  delegates to the cells (Board review skipped).

create_task_from_draft gains status + assigned_to params (default BACKLOG, so the
legacy confirm_draft is unchanged); confirm_live_draft + the /live/{id}/confirm
request carry the route. Service tests cover both routes.

* feat(intake): #14 draft-card buttons — Board review vs Approve & Start

Three buttons on the draft card now (CEO spec): Keep chatting / Board review &
Start / Approve & Start. The two action buttons confirm directly with their
route — launchTask(route) sends route to POST /live/{id}/confirm, which starts
the task at pending assigned to the Board (PO+HoM) or straight to the Main PM.

Supersedes the ConfirmDialog review step (scope is chosen up front in the form),
so it's removed from the page flow. The ConfirmDialog component + its sub-editors
are now unused — flagged for a follow-up cleanup, left in place to avoid churn.

tsc + lint green.

* fix(intake): keep the live SSE stream bound to its relay session

The orchestrator opened the relay session twice per live chat — once on the
request path (before the start call returns) and again inside the background
container spawn. The SSE stream binds to the session's queue the moment the
panel connects, so the second open swapped in a fresh queue and stranded the
stream: the agent replied normally, but its events went to the new queue while
the panel kept reading the old one, so the chat looked frozen on "Preparing…".

The second open was always redundant (the relay is opened by the caller before
the spawn). Remove it, and make open() idempotent so a live session is never
replaced out from under a stream that is already connected to it.

* fix(intake): draft-card launch buttons silently did nothing

The launch path required a `description` field, but the prompter draft schema
intentionally has none — it sends `objective` + the structured spec and the
backend composes the description (compose_description). `editableDraft.description`
was therefore undefined, so `description.trim()` inside launch validation threw a
TypeError that propagated out of the button's onClick. Clicking "Board review &
Start" / "Approve & Start" did nothing, with no feedback — the wall blocking the
whole confirm → task → reap flow.

- Map a proposed draft's description from `objective` as a fallback.
- Make launch validation null-safe.
- Replace the silent early-return with a toast that names what's missing, so a
  blocked launch is never a dead, feedback-less button again.

* fix(intake): steer the agent to ask inline, not via AskUserQuestion

The intake's job is to ask clarifying questions, so it reached for the
AskUserQuestion tool — which isn't wired to the live chat panel and isn't in its
allowlist. The bare deny left it to stumble ("let me clarify… — no worries, let
me just lay it out") and waste a visible turn.

- Prompt: spell out that it asks by writing in the chat (the human reads every
  message live) and that no question/prompt tool is available to it.
- Gate: give AskUserQuestion a specific deny message that nudges it to ask inline,
  so even a reflex attempt degrades gracefully.

Also refresh the now-stale "what happens after propose_draft" section: the draft
card has three choices (Keep chatting / Board review & Start / Approve & Start)
and produces a pending task — not the old two-button "backlog" description.

* feat(intake): copy buttons on agent messages and the draft card

The CEO asked for a way to save the agent's plan/spec elsewhere "just in case" —
a cheap manual backstop until refresh-durability lands.

- New CopyButton: async Clipboard API when available, plus a legacy
  textarea+execCommand fallback. The fallback is load-bearing — the panel is
  served over plain http on a LAN IP, where navigator.clipboard is absent
  (clipboard needs a secure context), so the modern API alone would never copy.
- Copy button under each assistant message (copies its text).
- Copy button on the draft card (copies the full spec as markdown: title,
  objective, what-this-builds, the-work per cell, notes, success criteria).

* feat(intake): unbuffer logs + log each turn so the container isn't a black box

Debugging the intake smoke was painful for two reasons: (a) the orchestrator
block-buffered stdout, so `docker logs` lagged minutes behind reality, and (b)
the intake container logged only "session opened" then went silent for the whole
conversation (the chat streams to the relay, not stdout).

- Set PYTHONUNBUFFERED=1 on the orchestrator and agent-base images so structured
  logs reach `docker logs` in real time instead of in large delayed chunks.
- Log each intake turn: "turn received" (with char count) and "turn streamed"
  (chunk count + whether a draft was emitted), so the container logs show the
  conversation's shape at a glance.

* chore(intake): remove the dead ConfirmDialog draft editor

The three-button draft card (Keep chatting / Board review & Start / Approve &
Start) replaced the old review-modal confirm flow, leaving ConfirmDialog and its
sub-editors (StringListEditor, TheWorkEditor) referenced by nothing but the
barrel export. Remove the three files and the export — typecheck + lint confirm
no remaining references.

* fix(intake): coerce bad draft enums on confirm instead of hard-failing

The intake agent is an LLM and will emit off-enum values — e.g. task_type="feature",
which is not a valid TaskType (code/documentation/research/planning/design/
administrative). `_coerce_draft_enums` called `TaskType(value)` directly, which
raised, and the confirm 400'd with "Draft has invalid or missing required fields:
'feature' is not a valid TaskType". That forced the agent to discover the valid
values and self-correct in-chat — unacceptable: clicking "Approve & Start" must
never blow up on a cosmetic enum guess.

Coerce each enum to a sane default on invalid/missing (task_type→code,
nature→technical, complexity→medium); team falls back to the first valid cell in
the_work, then backend. `_lead_cell_team` now skips invalid cell names too. The
confirm/launch action no longer hard-fails on an enum the model got wrong.

* fix(intake): draft card no longer renders above the user's latest message

attachDraft fell back to "the last assistant message anywhere" when the current
turn had no streamed text yet (propose_draft called first). That last message was
often the PREVIOUS turn's — sitting above the user's "Yes, propose it" — so the
draft card rendered above the user's message. Attach only to the current turn's
streaming message; otherwise append a fresh assistant message so the card always
lands at the bottom of the thread.

* test(intake): guard draft enum coercion + invalid-cell skipping

Regression tests for the confirm-time enum coercion: an off-enum task_type
("feature") / nature / complexity coerce to code/technical/medium instead of
raising, and _lead_cell_team skips invalid cell names. Locks in that a bad enum
guess from the agent can never 400 the launch again.

* fix(intake): stop the agent fumbling through Claude Code meta-tools

In smoke it reflexively probed CC built-ins before reaching propose_draft —
plan mode + ExitPlanMode (it announced a written plan and waited instead of
emitting the draft), ToolSearch, Write — each correctly denied by the lockdown
but stumbly, and it only proposed after explicit CEO nudges.

- Gate: ExitPlanMode now gets a specific deny nudge ("you don't use plan mode;
  call propose_draft"), and the generic deny names the actual toolset instead
  of a bare "not available", so any probe degrades into guidance.
- Prompt: forbid plan mode/ExitPlanMode/ToolSearch explicitly and spell out
  "you do not plan and wait — call propose_draft directly when the spec is
  ready," plus an anti-pattern bullet.

* feat(intake): make the container logs transparent mid-turn

`docker logs` on the intake container was a black box: only turn start/end, while
the agent read the codebase and spawned 20+ subagents invisibly (the conversation
streams to the relay, not stdout), and the benign 3x ~/.claude.json warning was
the only thing visible.

- Driver logs each tool call mid-turn ("Intake tool use" with the tool name) and
  the draft emission, plus a tools count in the turn-streamed summary. Text deltas
  stay unlogged (they'd spam). Now the logs show the turn's real shape.
- Pre-create ~/.claude.json ({}) at container boot so the CLI's "config not found"
  warning (printed 3x, self-healed anyway) stops drowning the real logs.

* fix(intake): render markdown in user messages + scope copy to code blocks

Two display fixes from the smoke:
- User messages collapsed newlines (plain {content} in a div) and rendered no
  markdown — a "1.\n2.\n3." answer showed as one run-on line. Render user AND
  assistant bubbles through a shared GFM markdown body that inherits the bubble's
  text color, so lists / newlines / styling render correctly on both.
- Copy was blanketed on every assistant message; scope it to KEY parts — a copy
  button on fenced code blocks (the draft card keeps its own). Removed the
  per-message button.

* fix(intake): prevent duplicate tasks from a double-click on launch

Clicking a draft launch button twice fired two confirms and created duplicate
tasks. Add a synchronous re-entry guard (a ref — no stale-closure window) at the
top of launchTask so a second click returns immediately, and disable + spin the
draft-card buttons while a launch is in flight so it's visually clear it's working.

* docs(how-to): lead task creation with the Task Assistant flow

Rewrite "1 · It starts with you" to walk the Prompter/Task Assistant path —
scope form, the agent reading the codebase, its grounded analysis, the draft
card, and the created task — then flow into the Board review. Replaces the old
manual task-definition form shots.

Image placeholder: images/prompter_draft_card.png (the 3-button card) is
referenced but not yet captured — TODO comment marks it for the next smoke run.
A second comment flags an optional re-capture of prompter_run_2 after the
markdown-rendering fix.

* fix(intake): restore assistant message text contrast

The markdown refactor dropped `dark:prose-invert` and made text inherit the
bubble's color, but the assistant bubble had no explicit text color — so its text
rendered near-invisible (dark-on-dark on bg-muted). Give the assistant bubble an
explicit text-foreground; the user bubble already carries text-primary-foreground,
and [&_*]:!text-inherit now resolves to a readable color on both.

* fix(intake): coerce draft priority too — confirm 500'd on priority="high"

The enum-coercion fix covered task_type/nature/complexity/team, but priority is a
non-enum int field handled by `int(draft_data.get("priority", 2))`, and the agent
guesses a word ("high") as readily as a number — so int("high") raised ValueError
and the confirm 500'd. Same class of bug, one field missed.

Add _coerce_priority: map words (urgent/high/medium/low → 0/1/2/3), clamp numbers
to 0-3, default to 2 (medium) on anything else. The launch can no longer crash on
any field the LLM guessed. + regression test.

* fix(intake): draft card shows distinct cells, not one badge per work item

the_work has one entry per work item, so a cell with several items rendered its
badge repeatedly ("Board-led across Backend Backend Backend Frontend Frontend
…"). De-dupe to distinct teams so the card reads "Board-led across Backend
Frontend" — and the "Cell:" vs "Board-led across" label keys off distinct count.

* docs(how-to): hero the teaser gif + resolve the Prompter/Task Assistant thread

- Move the 12s teaser gif to the top as the hero — it was buried between the
  "prefer video" link and the first screenshot.
- Name the connection: the Task Assistant IS the Prompter, so section 1 (using
  the tool) and the rest (RoboCo building it) read as one story — you use the
  tool the company built for itself, then watch the build.
- Re-anchor the section 1 → Board transition to follow the Prompter's own
  journey, instead of implying section 1's example task is the one reviewed next.

* Included images for how-to.md

* docs(how-to): align agent count to 20 (matches README + CLAUDE.md)

The how-to said "18 agents" with UX/UI at one dev and no Intake — stale against
the authoritative count. Bump 18→20 (prose + spelled-out eighteen→twenty), give
UX/UI 2 devs, and add the Intake line to the org tree (Intake leads section 1, so
it belongs in the tree). README + CLAUDE.md already say 20.

* ci(release): publish all RoboCo images to GHCR + Docker Hub

The release published only the orchestrator to GHCR. Build and push the full set
the stack needs — agent-base, the 8 agent images, orchestrator, and panel — to
BOTH ghcr.io/rennf93/* and docker.io/renzof93/*, at :<version> and :latest, so
consumers can pull instead of compose-building.

- agent-base builds first (the agent images build FROM roboco-agent-base, a local
  tag), then the rest; push only after every build succeeds.
- Image names mirror the docker-compose `image:` values 1:1.
- Free disk on the runner first (11 images is space-heavy).
- Needs a DOCKERHUB_TOKEN repo secret for the Docker Hub login.
- SECURITY.md updated to reference both registries.

* ci(release): use short SHA as the image tag on manual dispatch

A workflow_dispatch runs against a branch, and the branch name (e.g.
feature/prompter-gold-upgrade) was used verbatim as the image tag — but "/" is
illegal in a Docker tag, so the first build failed instantly with "invalid
reference format". Releases still tag from the release tag; manual dispatch now
always uses the short SHA, which is a valid tag.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-09 17:08:34 +02:00
Renn F f2df7fc30b fix(prompter): align frontend API client with the backend session contract
The Prompter frontend and backend were built against mismatched contracts:
- createSession POSTed no body (the endpoint requires one) and read a
  `session_id` field the response doesn't have (it is `id`), so every session
  create returned 422.
- sendMessage hit `/sessions/{id}/chat` with `{message}` and expected a
  `{reply, draft}` object, but the session endpoint is `/sessions/{id}/messages`
  with `{content}` and returns the full message list.
- API errors rendered as "[object Object]" because getErrorMessage String()'d a
  FastAPI validation array.

Point createSession/sendMessage at the real endpoints, adapt the message-list
response (latest assistant message is the reply; fetch the structured draft when
the reply signals readiness), and make getErrorMessage format string /
validation-array / structured-object details into a readable line.
2026-06-08 16:17:41 +02:00
Renn F 5a41ef5c90 Merge master (Prompter #80) into lifecycle-residual-hardening 2026-06-08 05:45:07 +02:00
Renn F 718a16c4f7 Close residual gaps: CEO escalation from blocked, ref repair, reset-script state
Three independent residual hardenings surfaced by the smoke run:

- escalate_to_ceo is now reachable from a blocked task (source widened to
  {awaiting_pm_review, blocked} with a matching status-transition row), so a
  main_pm or board agent has a clean verb to surface a task it cannot resolve
  to the CEO — who can then approve, reject, or cancel it. Regenerated the
  lifecycle artifacts (status-transitions doc + panel JSON) to match.

- WorkspaceService now prunes broken loose refs (.bak debris and any ref whose
  contents are neither an object id nor a symref) before the refresh fetch, so
  a ref left corrupt by an interrupted recovery no longer produces per-operation
  "broken ref" warnings or wedges ref enumeration. Best-effort, file-reads-only.

- The reset script's full-reset Claude-state clear now defaults to the
  replay-state subdirs (projects/, todos/) of the mounted Claude home and
  refuses to clear the home root, so a full reset actually clears conversation
  replay state by default without wiping the host's stored credentials.
2026-06-08 05:17:15 +02:00
Renn F 69da8de4b5 Panel: de-dupe live notifications and expose recovery states on the board
Notification bell: the websocket stream was rendered with a plain filter, so
a stream replay after a reconnect surfaced — and counted — the same
notification twice, inflating the unread badge. De-duplicate by
notification_id (newest copy wins, arrival order preserved) and key the list
on the id instead of array index.

PM kanban: the board had no columns for the recovery states (paused,
needs-revision, awaiting-CEO-approval, cancelled), so a human had no way to
drive a wedged task into them from the UI. Add those columns; dragging a card
issues an admin status override, giving the CEO direct recovery from the board.
2026-06-08 04:47:35 +02:00
5a99140a55 [9ef46be7] feat(prompter): implement Prompter page, nav entry, chat UI, and task creation wiring (#78) (#79)
- Add Task Assistant nav entry (Sparkles icon) to sidebar between Kanban and Projects
- Create lib/api/prompter.ts with createSession, sendMessage, getDraft API functions
- Create hooks/use-prompter.ts with 6-state machine (empty→chatting→draft_preview→review_modal→launching→success)
- Create ChatMessages with auto-scroll, user/assistant/error rendering, inline DraftProposalCard
- Create ChatComposer with Enter-to-send and Shift+Enter for newline
- Create DraftProposalCard with title, description, acceptance criteria, metadata badges, Keep Chatting/Review & Confirm actions
- Create ConfirmDialog (Radix Dialog, no form element) with MarkdownEditor, AcceptanceCriteriaEditor, metadata selects, warning banner, disabled Confirm & Launch until valid
- Create SuccessCard with task title, team, View Task link, Start Another button
- Create /prompter route page composing all components via usePrompter hook
- TypeScript and ESLint pass with zero errors

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
2026-06-07 22:38:36 +02:00
3205443119 Fix: dependency spawn gate and cell ownership (#73)
* Cleanup + Missing greenlet error

* fix(messaging): persist a group's active-session pointer so posts reuse it

create_session and create_session_with_access_check set group.active_session_id
from session.id BEFORE the flush that materializes it — the id is a flush-time
uuid4 default, so the pointer was written as NULL and every post opened a fresh
session, fragmenting one conversation across many. Flush first, then link, the
same ordering the seed path already uses.

Two tests fabricated "two distinct sessions" by calling create_session twice on
one group, which only differed because of this bug; switch them to two groups so
they keep testing their real intent. Add a regression guard that the pointer is
actually persisted and a second create reuses the live session.

* fix(orchestrator): gate spawns on dependencies and keep cell tasks in their cell

The cross-task dependency check ran only on the dev dispatch path, so cell-PM,
Main-PM and board agents were spawned onto dependency-blocked tasks and flailed
unblock / escalate / notify against an unfinished upstream — climbing ownership
of cell work up to the board, which cannot drive it, and deadlocking the task.

- Move the dependency gate into the shared spawn readiness check so it covers
  every role, and auto-block the task so it leaves the pending pool until the
  upstream reaches a terminal state (then the existing auto-unblock revives it).
- Cell-ownership invariant: a backend/frontend/ux_ui task may only be worked or
  owned by its own cell. The readiness gate refuses a board or Main-PM spawn
  onto a cell task; reassign refuses and clears such an owner; and on
  dependency-clear a mis-owned cell task is re-homed to its cell's pending pool
  instead of reviving under an owner that cannot progress it.
- A dependency block is never a CEO signal: notify(target=ceo) is refused while
  the task is waiting on an unfinished upstream, with a remediate to idle and
  wait — the block clears on its own.

* Uploading images + Fixing pyproject.toml

* ++

* revert(orchestrator): drop the cell-ownership block pending a tooling audit

The cell-ownership invariant added earlier — a board / Main-PM role may never be
spawned onto or reassigned to a cell task, plus re-homing a mis-owned cell task
on dependency-clear — was too absolute. It forbids a higher role from stepping
in when something genuinely deeper is going on, and contradicts the existing
rule that main_pm may hold a task at awaiting_pm_review. The dependency spawn
gate already prevents the cascade that handed the board cell tasks; the deadlock
it guarded against will be addressed with a return-path approach after auditing
what tools the cell PMs actually need. Keeps the dependency gate and the CEO
dependency-block notify guard.

* docs(prompts): a dependency wait is wait-and-idle, not escalate

The cell-PM and Main-PM prompts told agents to escalate_up / retry unblock on a
blocked task without distinguishing a dependency wait (which auto-clears the
moment the upstream completes) from a real wedge — the source of the
escalate/unblock flail and the CEO-notification spam. Split the blocked-state
guidance: a cross-cell dependency wait = note + i_am_idle (do not escalate,
unblock, or notify the CEO); escalate only a genuinely broken upstream. Fix two
stale references to i_am_blocked, a developer-only verb the PMs do not have,
to escalate_up.

Correct the CLAUDE.md verb-surface table, which understated every role: it
listed 4 cell_pm verbs while the flow manifest derives the full set (11,
including unclaim and i_am_idle) from lifecycle.spec.intents_for_role.

* feat(gateway): cell_pm reassign verb — intra-cell developer hand-off

A cell PM can now hand a claimed/in_progress task to another developer in its
own cell without unclaim (which drops the work back to the pool and loses the
assignee). The branch is keyed to the task, so the work-in-progress is
preserved; the new dev is respawned to continue. Intra-cell only: the task must
be in the caller's cell and new_assignee must be a developer of that same cell.

Wired through every layer: the reassign IntentSpec (composes=(), cell_pm-only),
the choreographer verb + intra-cell guard, a reaper-safe
TaskService.reassign_active_claim (reseeds the claim heartbeat so the new dev
is not immediately reaped), the ReassignRequest schema, the cell_pm flow route,
and the MCP flow-server tool. Tracing-waived like unclaim (mechanical hand-off).
Regenerated lifecycle/verb artifacts; prompt + CLAUDE.md updated.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-06 22:10:48 +02:00
Renn F d25994f87b fix(panel): dedupe notifications by id in the store
addNotification prepended every delivery and re-incremented unreadCount /
pendingAckCount, so a re-fetched or replayed notification stacked duplicates
and re-inflated the counts — already-acknowledged notifications re-surfaced to
the CEO and the pending badge kept climbing. Dedupe by id: update in place on
re-delivery; only add + count a genuinely new notification.
2026-06-04 06:47:30 +02:00
Renn F 2631f2647c fix(lifecycle): unblock a never-claimed task to pending, not branchless in_progress
A task blocked before it was ever claimed (a dependency-gated claim that got
escalated) has no branch. Legacy unblock() forced in_progress, which the
dispatcher refuses (state=in_progress but branch_name unset) -> a spawn-refused
loop. Add the blocked->pending transition and route no-branch tasks there so
they are freshly claimed (the claim gate then holds them cleanly while the
dependency is unmet). Branched tasks still resume in_progress. Artifacts
regenerated.
2026-06-04 02:01:09 +02:00
Renn F ceb4eec6ca feat(board): gate CEO Approve & Start on board-review completion
A board/coordination task stays pending throughout board review — that
pending state is what hands it to Main PM on approval — so the CEO's
Approve & Start button was live from the instant the task was created,
before the Product Owner and Head of Marketing had reviewed anything.
That let the CEO approve before the board finished.

Persist a board_review_complete flag the orchestrator sets once BOTH
board reviewers are done, and gate the button on it (the task stays
pending). The same handoff emits the formal CEO notification, so the
CEO gets an actionable signal instead of buried channel chatter.

- alembic 021: add tasks.board_review_complete (default false)
- TaskService.mark_board_review_complete: set the flag without leaving pending
- orchestrator: flag the task + notify CEO once both reviewers go idle
- panel: Approve & Start requires board_review_complete
2026-06-03 08:06:41 +02:00
110aaa7a77 Chore: v1 removal gateway canonical (#46)
* chore(agent_sdk): remove dead /traceability/remind endpoint and reminder map

The TRACEABILITY_REMINDERS dict and its /traceability/remind endpoint were
keyed entirely on pre-gateway tool names (roboco_task_*, roboco_journal_*,
roboco_message_send, roboco_session_create_for_tasks) deleted in the gateway
cutover. The endpoint had zero callers; v2 enforces traceability server-side
in the Choreographer.

* fix(bootstrap,seeds): onboarding prompts call give_me_work(), not deleted roboco_task_scan()

The startup prompt and the seeded cell/all-hands channel onboarding messages
instructed agents to call roboco_task_scan() — a tool removed in the gateway
cutover. Point them at the live give_me_work() flow verb.

* fix: replace remaining deleted v1 tool names with gateway verbs

Spawn prompts, onboarding strings, remediation messages, and comments still
referenced pre-gateway tools deleted in the cutover (roboco_task_*,
roboco_agent_idle, roboco_notify_*, roboco_message_send,
roboco_session_create_for_tasks, roboco_journal_*, roboco_escalate). Rewrote
each to the correct role-scoped gateway verb (give_me_work/i_will_work_on for
workers, triage for PMs, i_am_done vs complete, notify/notify_ack, escalate_up,
unclaim, i_documented, open_session, note). Updated one enforcement-message
test that matched the old tool name by coincidence.

* test: guard against deleted v1 tool names reappearing in roboco/

Scans roboco/ for the deleted pre-gateway tool names; excludes the orphaned
roboco/agents/ subtree (removed in a later phase).

* chore(exceptions): drop 8 unused pre-gateway exception classes + their tests

LLMError, RAGError, AlreadyExistsError, TaskBlockedError, TaskClaimError,
AgentNotAvailableError, AgentBusyError, NotificationPermissionError were never
raised in production. SessionClosedError/DatabaseError are kept (live + tested).

* chore(models): drop unused pre-gateway notification/channel/handoff factories

Removes create_task_assignment/_blocker_escalation/_review_request/
_documentation_request/_priority_change/_alert/_broadcast, create_cell_channel/
_cross_cell_channel/_announcements_channel, create_handoff (+ HandoffParams),
ProactiveContext, and A2APartType. The gateway choreographer builds these
server-side now. Drops the matching dead-code tests.

* chore(services): drop unused pre-gateway permission/messaging/audit/optimal/remediation methods

These pre-gateway helpers (channel-permission checks, channel-membership ops,
permission-denial audit hooks, doc ingestion, two remediation hints) have no
production caller — the gateway role_config + enforcement layer replaced them.
Drops the matching dead-code tests; live methods (send_message, the SESSION_*
flow, log_task_action_denial, etc.) are untouched.

* chore(orchestrator,ws,events,config): drop unused pre-gateway lifecycle/broadcast/roster symbols

orchestrator: get_running_agents, is_agent_busy, queue_priority_work,
get_all_instances (+ their OrchestratorAccessProtocol declarations in events.py).
websocket: broadcast_new_message, broadcast_session_closed (no event type emits
them). agents_config: ALL_PMS/ALL_DEVS/ALL_QA/CELL_PMS roster constants (ALL_DOCS
stays — it gates docs-write workspace perms).

* refactor(agents): delete orphaned pre-gateway agent subtree + dead organization model

The Gateway/full cutover replaced the Python agent-class implementations with
the server-side Choreographer; the classes survived only as a self-referential
island. Removes roboco/agents/{base,mixins,factory,board,developer,documenter,
pm,qa,orchestrator}.py and roboco/agents/factories/{board,cells,developers,
documenters,pms,qa}.py, plus roboco/models/organization.py (Cell/Board/
Organization — used only by those factories). Keeps factories/_base.py
(compose_prompt — the live prompt-layer composer the orchestrator calls at
spawn) behind minimal package __init__ files.

* chore(db): drop dead tasks.execution_log + outputs columns (migration 015)

Both JSON columns had zero readers/writers in code, tests, and migrations —
execution progress is tracked via progress_updates and artifacts via
commits/documents. Removes the ORM columns, the Pydantic Task.execution_log/
outputs fields, the ExecutionLog/FileRef models (+ their __init__ exports), and
the now-invalid kwargs from test fixtures. Migration 015 (down_revision
014_drop_pm_approvals) verified live: upgrade drops, downgrade re-adds.
Apply on the NAS with 'alembic upgrade head' at next deploy.

* chore(config): drop 16 unread Settings fields

Verified unused (no settings.X, no self.X property use, no getattr-by-name):
app_name, reload, workers, openai_api_key, secret_key, access_token_expire_minutes,
algorithm, log_level, log_format, the four session_* limits, message_max_length,
commit_subject_min_chars, commit_banned_words, agent_budget_sweep_interval_seconds.
Removes the empty Logging + Sessions&Messages sections and orphaned .env.example
vars. Kept: redis_db/redis_password (redis_url property), agent_sla_* (read via
getattr in task_lifecycle), encryption_key, and all live thresholds.

NOTE: commit_banned_words/commit_subject_min_chars and
agent_budget_sweep_interval_seconds were feature-config never wired to their
consumer (commit validator / budget sweep) — removed as dead, but flagged in
case the intent was to wire them.

* test(lifecycle): give i_will_work_on calls a substantive plan (#171 contract)

The real-DB lifecycle tests called i_will_work_on with a 13-char plan and no
risks/technical_considerations, so the substantive-plan gate (#171) rejected
them with incomplete_input — failing on master. Supply a >=150-char plan plus
technical_considerations and risks (mirroring tests/unit/gateway/
test_choreographer_dev.py). All 6 now pass; gate runs with no deselect.

* feat(gateway): wire commit-validator thresholds to settings

commit_subject_min_chars and commit_banned_words were config defined but never
read — the gateway commit() gate used the validator's hardcoded module defaults.
Re-add the two Settings fields and pass them through validate_commit_message in
content_actions.commit(), so config is the source of truth (validator defaults
remain the standalone/CI fallback). Adds wiring tests that monkeypatch settings
and assert the gate honors them.

* refactor(orchestrator): retire gateway_enabled flag; trigger_filter is unconditional

The gateway_enabled Settings field gated only the trigger_filter spawn-cooldown
(never the agent tool surface). Prod ran it on; the Phase-0 'legacy dispatch
path' it guarded no longer exists. Remove the field + the early-return branch in
gateway_pre_spawn_check so the cooldown runs for every spawn, drop the now-dead
ROBOCO_GATEWAY_ENABLED from docker-compose.yml, and update the stale Phase-0
comments + cooldown test. The per-container ROBOCO_GATEWAY_ENABLED env (set by
_append_manifest_args, read by agent_sdk to load the manifest) is unaffected.

* refactor(api): relabel /api/v2 -> /api/v1 as the canonical gateway surface

The gateway is the only agent API now, so the 'v2' label (with no v1) was
misleading. Renames roboco/api/routes/v2 -> routes/v1, schemas/v2 -> schemas/v1
(+ the matching test dirs and test_v2_role_dep/test_schemas_v2_flow files),
rewrites every /api/v2 path, routes.v2/schemas.v2 import, and v2-* router tag to
v1, and refreshes the stale 'v2' comments/docstrings. The panel is untouched (it
uses the unversioned /api/* REST routes). flow_server/do_server now POST to
/api/v1/*.

* docs(scripts): reset_runtime_state header matches actual SQL behavior

The header claimed it preserves groups + journals, but the .sql wipes both
(verified live: groups 6->0, journals 5->0; only agents/projects/channels
survive). Correct the wiped/preserved lists to match.

* refactor(gateway): extract _build_rich_plan to drop i_will_work_on under the complexity gate

i_will_work_on was cyclomatic rank C (11) — one over the xenon --max-absolute B
threshold — because of the five `x or default` fallbacks in the rich_plan dict.
Move that dict into a small _build_rich_plan helper (behaviour identical); both
methods are now rank B. make quality is fully green (xenon was its last failure;
bandit already passed — its 34 findings are all LOW severity, filtered by -ll).

* feat(foundation): add canonical CELL_TEAMS set; dedupe cell-subset literals

* feat(db): add ProductTable + ProductProjectTable ORM (per-cell project map)

* feat(task): add additive nullable product_id (ORM + model + DTO + create threading)

* feat(task): thread product_id through create_subtask/route/response

* feat(db): migration 016 — products, product_projects, tasks.product_id

* fix(db): document migration 016 plan deviations (revision len, FK name)

Two values in migration 016 intentionally diverge from the Task 2.4 plan
literals; this strengthens the in-file justification so the deviations are
self-documenting and verifiable.

- revision id (plan line 623): the plan's 36-char
  "016_add_products_and_task_product_id" overflows alembic_version.version_num
  (VARCHAR(32)) — alembic upgrade head raises asyncpg
  StringDataRightTruncationError. Kept at 27 chars
  ("016_add_products_product_id") so Step 4's live round-trip stays green.
- downgrade FK name (plan line 683): roboco/db/base.py sets a metadata
  naming_convention, so the FK upgrade() creates is
  "fk_tasks_product_id_products", not the Postgres default
  "tasks_product_id_fkey". The plan literal does not exist in the DB and
  would fail the downgrade with "constraint does not exist".

Both verified via the live upgrade/downgrade round-trip on a throwaway DB.

Issue 3 note: the prior commit (b896cac) also touched
tests/unit/api/test_schemas_tasks.py (added product_id=None to the
task_to_response stub). That line is load-bearing — task_to_response reads
task.product_id (added in Task 2.3, commit 67afa6b) — and belongs to Task 2.3's
scope; it is left in place because removing it breaks 4 tests and history is
not rewritten.

* refactor(db): trim migration 016 deviation notes to plan-faithful form

Reverts the out-of-scope documentation expansion (commit 1a4f296), which
was a second undocumented commit beyond Task 2.4's single plan-specified
commit and only bloated the migration docstring/comments.

The migration file now matches the plan-specified commit (b896cac) byte for
byte: the two necessary deviations from the plan literals stay (revision id
shortened to fit alembic_version.version_num VARCHAR(32); downgrade FK name
follows db/base.py's metadata naming_convention), each kept to a concise
inline note in the plan's header style.

The Task 2.3-scoped test stub line (tests/unit/api/test_schemas_tasks.py
product_id=None) is load-bearing — task_to_response reads task.product_id —
and is left in place; history is not rewritten.

Verified: live alembic upgrade head + downgrade to 015 round-trip on a
throwaway DB drops products/product_projects/tasks.product_id cleanly, and
make quality is green.

* refactor(test): annotate db_session and drop type: ignore in migration 016 test

Annotate the test_products_tables_and_task_fk_exist param as
db_session: AsyncSession (imported under TYPE_CHECKING) and remove the
# type: ignore[no-untyped-def] suppression, matching the typed db_session
pattern used across tests/integration/.

* feat(models): Product + ProductCreate/Update + ProductCellMapping (cell-validated)

* refactor(models): minimize ProductCellMapping config override to use_enum_values

The previous override re-declared validate_assignment, populate_by_name,
and extra=forbid, which RobocoBase already supplies. Pydantic merges
model_config across inheritance, so overriding only use_enum_values=False
is sufficient to keep team as a real Team enum (required so team in
CELL_TEAMS and enum identity hold for callers) while inheriting the rest
of the base config.

* fix(models): document ProductCellMapping use_enum_values override as plan-mandated

Resolves SPEC-COMPLIANCE review notes for Task 3.1 (Product domain models).

1. The ProductCellMapping use_enum_values=False override is a deviation from a
   bare project.py mirror, but it is mandated by the plan's own Task 3.1 code:
   RobocoBase sets use_enum_values=True, which coerces team to the plain string
   "backend". The plan's Step 1 test asserts m.team is Team.BACKEND (enum
   identity) and the Step 3 validator formats its error with v.value, both of
   which require team to remain a real Team enum. The override is therefore
   necessary; this commit relabels the comment to cite the specific spec lines
   that force it instead of leaving it as an unexplained departure. Downstream
   Task 3.2 (_replace_cells / project_for) already tolerates either form and the
   ORM stores the same value regardless, so the override has no behavioral reach
   beyond the in-memory enum identity the plan's test checks.

2. test_product_model.py hoists 'from uuid import uuid4' to module level rather
   than inline (as the plan's verbatim Step 1 code shows) because the global
   Pylint PLC0415 rule (import-outside-top-level) forbids inline imports and
   there is no per-file-ignore for tests/unit/models/. The hoisted form is the
   only ruff-clean rendering of the plan's test; left unchanged here.

3. Task 3.1 landed across two commits (c616d95 create, 6ebad255 refactor) rather
   than the plan's single Step 5 commit. Earlier history is intentionally not
   rewritten; this single follow-up commit brings the model to its final
   spec-faithful, fully-documented state.

* feat(service): ProductService CRUD + project_for per-cell resolver

* feat(api): Product CRUD routes + schemas, wired into the app

* fix(api): roll back and map cell-replacement IntegrityError on product update

update_product replaced cells via ProductService._replace_cells without
any try/except, so a duplicate-team cell (uq_product_projects_product_team)
or a non-existent project_id (product_projects.project_id FK) raised an
IntegrityError at flush, poisoning the AsyncSession and surfacing an
unhandled 500 with no rollback. Wrap the update + commit in a try/except
that rolls back and maps the UNIQUE violation to 409 and the FK violation
to 422, mirroring create_product's rollback discipline. Add integration
tests covering both client-error paths.

* fix(api): map create_product cell-mapping IntegrityError to 409/422

create_product only caught the slug conflict ('already exists' in str(e))
and bare-raised everything else, so a cells entry whose project_id does not
reference any project let the product_projects.project_id FK IntegrityError
propagate out of the route as an unhandled 500. The matching update_product
path was already hardened (uq_product_projects_product_team -> 409, FK
violation -> 422); apply the same mapping in create_product so a bad
project_id (or a duplicate-team cell) is a client error, not a server error.
The slug conflict is now caught as ConflictError directly instead of via a
broad except + string match.

* feat(gateway): add optional project_id to delegate inputs/request/routes

* feat(gateway): per-cell project routing (override -> product map -> parent) + product_id inheritance

* feat(task): approve_and_start — reassign board task to Main PM (CEO gate #1)

* feat(api): POST /tasks/{id}/approve-and-start (CEO gate #1, notes-required)

* test(api): cover approve-and-start 404-before-notes-gate for missing task

* feat(panel): Product types + Task.product_id

* feat(panel): productsApi + hooks + tasksApi.approveAndStart

* feat(panel): Products management screen + sidebar nav

* feat(panel): Approve & Start button (CEO gate #1)

* fix(api): narrow delete_product to IntegrityError + cover 204/409 delete paths

* test(task): assert approve_and_start persists + appends the audit note

* refactor(db): migration 016 names the tasks.product_id FK explicitly (house style)

* fix(db): make migrations authoritative + self-heal orphan product tables

init_db() no longer silently falls back to create_all when alembic upgrade
fails. That fallback masked migration failures and, since create_all cannot
ALTER an existing table, left the schema inconsistent — turning an unapplied
migration 016 into a crash loop: 016's CREATE TABLE products failed, the
upgrade rolled back, create_all re-created an empty orphan products table, and
every later boot failed again on the now-existing table while tasks.product_id
never got added. Now a migration failure is raised so the real error surfaces.

Migration 016 additionally drops EMPTY orphan products/product_projects tables
left by the old fallback before creating them, so an already-polluted DB
self-heals on the next deploy with no manual SQL. Skipped in offline (--sql)
mode; refuses to drop a table that holds rows.

* fix(db): create_all is the schema source of truth; alembic for increments

The Alembic chain is incomplete relative to the ORM — columns/tables like
notifications.delivered_at and the RAG indexed_documents table have NO migration
and have only ever been materialized by create_all. Tests don't catch this
because the test DB is also built via create_all, so migrations are never
exercised. The prior 'migrations are authoritative' init_db (and before it, the
create_all-only-on-failure fallback) therefore left a migrate-only boot with
missing columns/tables.

init_db now reflects reality:
  - Fresh DB  -> create_all builds the full current ORM schema, then stamp
                 Alembic at head so later incremental migrations apply.
  - Existing  -> run pending migrations (a real failure is raised, not masked),
                 then create_all(checkfirst) to gap-fill any missing ORM tables.
create_all cannot add a column to an existing table, so an ORM column added
without a migration needs a fresh rebuild of that table to appear.

* fix(db): migration 017 reconciles the Alembic chain with the full ORM schema

For years the live schema was built by create_all, not migrations, so the chain
drifted — tables/columns/indexes in the ORM had no migration (the
indexed_documents table, notifications.delivered_at, ~15 indexes, plus
timestamptz/server-default metadata). With init_db no longer masking that via a
create_all fallback, a migrate-only boot was missing those objects.

017 was produced by 'alembic revision --autogenerate' against Base.metadata,
reviewed, and verified: on a fresh DB, 'alembic upgrade head' (001..017) now
reproduces the create_all schema EXACTLY — a re-run of autogenerate detects zero
changes — and the 017 upgrade/downgrade round-trips cleanly. The migration chain
is now complete: migrate-only and create_all converge.

Also updates the init_db tests to assert the new behaviour (raise on an existing
DB's migration failure; create_all + stamp head on a fresh DB) instead of the
removed silent fallback.

* feat(panel): Product picker in the New Task form (drives per-cell routing)

The Products screen and Approve & Start button shipped, but the task-creation
form had no way to attach a Product — so a human couldn't set product_id from
the UI, which is exactly what drives per-cell project routing of delegated
subtasks. Adds an optional Product dropdown (Advanced -> Git config) populated
from useProducts(); 'None' falls back to the single project.

* fix(db): seed data is preserved on a fresh DB (run migrations, not bare create_all)

The previous fresh-DB path (create_all + stamp head) built the tables but never
ran the migration chain, so migration-embedded SEED DATA was skipped — most
visibly the AI providers seeded in 004. After a DB reset that left
provider_configs empty, so PUT /api/providers/ollama-key 404'd (the handler
raises NotFoundError when the Ollama provider row is missing).

Since migration 017 made the chain reproduce the full ORM schema, init_db now
runs 'alembic upgrade head' from base on a fresh DB — building every
table/column/index AND running the seeds. Verified: a fresh upgrade head seeds
both provider rows. Existing DBs still get migrations + create_all gap-fill.
Updates the init_db fresh-DB test accordingly.

* feat(task): project_id optional when a product_id is set (board fan-out tasks)

A board task that fans out across cells via a Product has no single repo of its
own — backend/frontend/ux_ui are each wrong, because the root coordinates and
delegates. Forcing one arbitrary Project was broken design (flagged at design
time). project_id is now nullable; a task must have project_id OR product_id:
  - TaskCreate model validator + a TaskService.create() invariant (covers every
    create path).
  - ORM/DTO/schema: project_id nullable; task_to_response uses to_python_uuid.
  - Gateway: a parent with only a product can delegate (guard now needs BOTH
    project and product to be None to reject); _resolve_subtask_project resolves
    each subtask from the product map and raises a clear error if a cell has no
    mapping and no parent project.
  - Migration 018 (tasks.project_id nullable), round-trip verified; fresh
    upgrade head still seeds providers.
  - Panel: Project no longer required once a Product is selected.
  - Removed the dead, never-called a2a create_task_from_message (it could only
    ever create a repo-less task) + its two coverage-only tests.

make quality green; panel tsc/lint/build green.

* Upgrade to Minimax M3

* fix(db): seed providers on existing DBs + correct enum casing

Migration 004 created the modelprovider/assignmentscope enums and seeded
provider rows in UPPERCASE, but the ORM (_str_enum) reads/writes the
lowercase StrEnum .value — so a fresh migrate-from-base DB built an enum
the ORM cannot read. Lowercase the enum labels and seed values in 004.

Add idempotent migration 019 to (re)seed the Anthropic + Ollama Cloud
providers with ON CONFLICT (name) DO NOTHING, so an existing DB whose
provider_configs table was created by create_all (and never ran 004's
seed) gets the rows on the next `alembic upgrade head` — fixing the
/api/providers/ollama-key 404 without a volume wipe.

* fix(tasks): let board/fan-out coordination tasks flow without a repo

A coordination task (project_id NULL, product_id set) targets no repo of
its own — it fans out to cell subtasks that each resolve a real project
from the product's cell->project map. Several paths still assumed every
task does git work and blocked it:

- orchestrator: add _is_coordination_task() and exempt these tasks from
  the project/branch/git-token gates in _readiness_check_task,
  _readiness_gate, _check_stuck_conditions, _validate_task_for_spawn.
- services/task.py: _ensure_branch_for_task returns "" (no branch) for a
  coordination task instead of raising; activate requires project OR
  product. This unblocks Main PM's i_will_plan claim, which otherwise
  raised before it could delegate the fan-out.
- gateway: _pending_assignment_guard exempts advisory roles
  (product_owner/head_marketing/auditor) from the "assigned but never
  claimed" idle gate — they review without claiming, so they could not
  satisfy a claim-or-unclaim remediation.

Adds focused unit tests for each.

* fix(tasks): coordination tasks reach in_progress + team reflects Main PM

The board->cells fan-out deadlocked: a coordination/fan-out task (product set,
no project of its own) could be created and claimed, but start()'s
claimed->in_progress transition hit validate_git_requirements, which still
demanded a branch_name and raised GitRequirementError. So Main PM's i_will_plan
never completed — it looped and never delegated. c961282 exempted
_ensure_branch_for_task (branch creation) but missed this parallel git gate in
the enforcement layer.

- task_lifecycle.py: add GitContext.is_coordination; skip the
  claimed->in_progress branch_name gate when it is set.
- task.py: populate is_coordination=(project_id is None and product_id is not
  None) in _validate_and_set_status; a branchless code task is still gated.
- approve_and_start: set team=Team.MAIN_PM on hand-off so the task isn't left
  labelled team=board after it leaves the board (now assigned to main-pm).

Adds a lifecycle-gate unit test and an end-to-end integration test that
claims, plans, and starts a project-less coordination task.

* fix(hooks): remove dead traceability hook + stale deleted-verb references

The v1-removal cleanup (2cfbf39) deleted the /traceability/remind SDK endpoint
but left the PostToolUse hook that curls it, so every gateway tool call 404'd
and agents silently lost their traceability reminders. Remove the dangling hook
(registration + TRACEABILITY_TRIGGER_TOOLS + Dockerfile COPY + the script); v2
carries per-verb guidance on the Envelope. Also correct two stale pre-gateway
tool names in hook text: the budget loop-detector nudged agents toward the
deleted roboco_task_escalate() (now unclaim()/i_am_idle(), which every looping
role has), and an sdk-startup comment referenced roboco_task_scan/get.

Extends the deleted-tool-name guard to scan docker/scripts/*.sh and to assert
every $SDK_URL/<path> a hook curls is a route still served by the SDK — the
check that would have caught this class (it lives in shell, invisible to mypy
and the Python import graph).

* fix(db): backfill ORM enum values the migration chain never added

Several StrEnum values were added to the ORM over time without a matching
`ALTER TYPE ... ADD VALUE` migration; 017 was autogenerate-derived and
autogenerate does not detect added enum labels, so the drift survived. On a DB
whose enum type predates the value, binding it raises at runtime — e.g.
`invalid input value for enum notificationtype: "a2a_request"` on
GET /api/notifications (list_system_notifications), and the same class for
blockerresolvertype/handoffstatus/team.

Migration 020 adds every drifted value idempotently (ADD VALUE IF NOT EXISTS —
no-op when 009 already reconciled it). Runs on the next `alembic upgrade head`.

Detected by comparing each ORM enum's values to the labels the migration chain
produces; adds tests/unit/test_enum_migration_parity.py which renders the chain
offline and fails on any future drift — the check that would have caught both
this and the provider-enum bug.

* fix(orchestrator): stop branch auto-block, board reassign, unblock livelock, agentless claims

Cluster C1 — four coupled orchestrator/task-invariant defects:

#18: a branch is created only at claim, so a pending, never-claimed code task
legitimately has no branch_name. The stuck-detection sweep (pending-only) and
readiness gate flagged that as "Task missing branch_name" and auto-blocked the
task every tick, so it never dispatched. Centralize the gate in
_branch_is_expected (status in claimed/in_progress/verifying, never a
coordination task) and apply it in both _check_stuck_conditions and
_readiness_check_task.

#14: the main_pm -> product_owner escalation rung handed an in_progress
descendant code task to the Product Owner (a board role) and marked it BLOCKED;
the board has no verb to own code work, so the dev's finished work deadlocked.
TaskService.apply_escalation (the single write primitive — covers both the
gateway escalate verb and the HTTP escalate route) now diverts a descendant code
task targeting a board/advisory role: it releases the task to PENDING for a
role-matched cell claim instead of stranding it.

#17: a blocked task reassigned to Main PM kept respawning the ex-assignee cell
PM to unblock it, but the assignee-only pre-unblock note returned not_authorized
— a livelock. _dispatch_blocker_work now dispatches the task's CURRENT PM/board
assignee (the unblock authority), falling back to the cell PM only when no
PM/board holds it. Also: a branchless coordination parent yields no valid merge
target — resolve_parent_branch now falls back to the child's own project default
branch (e.g. master) via TaskService.project_default_branch_for_task, and
_check_parent_branch_ready no longer blocks a child on a coordination parent's
non-existent branch.

#19: a task left claimed/in_progress with an assignee but no running container
was invisibly stuck (only PENDING tasks get fresh dispatch; the heartbeat reaper
can't see a freshly-seeded claim). New _dispatch_claimed_without_agent net:
after a short grace window it respawns the assignee, or releases the claim to
pending (lifecycle-safe via unclaim_for_reaper) when the assignee is unknown.
New config ROBOCO_CLAIMED_NO_AGENT_GRACE_SECONDS (default 120).

* fix(gateway): tolerant note verb + lock evidence do-tool invariant

#15: the note verb no longer hard-rejects thin decision/reflect payloads.
List-typed fields (options, consequences, next_steps) coerce a lone scalar
into a one-element list at both the NoteRequest schema (mode=before
validator) and the service layer; missing narrative fields default to a
visible placeholder instead of returning incomplete_input. The note is
always recorded, preserving audit value, and a well-intentioned note can no
longer trip the do-server 3-strikes circuit breaker. Widen the agent-facing
do_server.note hints to accept list-or-scalar and refresh the docstrings.

#8: add regression coverage locking the invariant that every role's do_tools
carries evidence (role_config + developer spawn manifest). The current source
already registers mcp__roboco-do__evidence for developers end-to-end; the
report stemmed from a stale deployed build, and the tests prevent silent
regression.

* fix(gateway): allow UX devs to receive design tasks; surface delegation rules to cell PM

The UX/UI cell's developers (ux-dev-1/ux-dev-2, Role.DEVELOPER on
Team.UX_UI) ARE its designers, but _validate_assignee_task_type rejected
task_type='design' for every DEVELOPER, blocking the UX cell's normal
design delegation. Allow 'design' for UX-team devs only; backend/frontend
devs stay rejected (design routing belongs to the UX cell). The
orchestrator already dispatches a developer for a design task
(_dev_dispatch_role_matches returns True), so this creates no orphan like
the documentation case.

Replace the static Cell-PM 'pass planning' remediate with a per-assignee
hint so a dev/design mis-type gets a developer-class next-step instead of
an off-topic planning hint.

Surface the three delegation guardrails in the cell-PM prompt so PMs stop
probing them by trial and error: valid task_type per assignee (incl.
design for UX devs), documentation auto-creation (non-delegatable), and
the sequential single-active code-spine. Fix the delegate-row task_type
list (documentation is NOT delegatable) and update the lifecycle spec
description; regenerate the lifecycle artifacts.

* fix(orchestrator): improve agent briefings for handoff consumption, product/project model, and workspace/secret hygiene

Main PM (roles/main_pm.md):
- Require reading the upstream Product Owner / Head of Marketing handoff
  (their decision/reflect journal entries + task description) BEFORE doing
  any own research or calling i_will_plan, so the Main PM builds on the
  Board's analysis instead of duplicating it. Added a dedicated section,
  hardened workflow step 1, and added an anti-pattern.
- Add a 'Products vs Projects' section: a Product fans out to one Project
  per cell; those Projects may be the SAME repo (monorepo subtrees) or
  DIFFERENT repos (multi-repo). The Main PM coordinates across them and
  must not assume one repo or call a monorepo subtree 'a separate repo'.
  Names the Prompter monorepo case (github.com/rennf93/roboco).

Developer (roles/developer.md):
- State the exact workspace path convention
  /data/workspaces/<project-slug>/<team>/<agent-slug>/, that the cwd is
  already set there, to stay inside the own cell workspace, and to not
  probe/guess the path (ls /, find /).
- Sanctioned secret handling: env/printenv is bash-guard denied and
  reveals nothing; needed secrets arrive via the task description, else
  i_am_blocked so the PM supplies them. Added matching anti-patterns.

Tests: add tests/unit/agents/test_briefing_cluster_c4.py asserting the
composed system prompt (the text mounted into agent containers) carries
each of the above.

* fix(orchestrator): board review involves PO+HoM and notifies CEO

Cluster C5 (#2, #4): a board/coordination task was reviewed by the Product
Owner alone, and the CEO got no formal signal when the review finished —
only buried channel chatter — so the Approve & Start handoff was invisible.

#4 — Board review is now a two-reviewer gate. _handle_board_assigned_task
dispatches BOTH the Product Owner and the Head of Marketing (one-shot each),
regardless of which one holds assigned_to, and the unassigned board-routing
path delegates here instead of claiming + spawning the PO alone. Board tasks
stay pending/unassigned for the CEO's Approve & Start. The board prompt now
makes the PO+HoM pair-review model explicit (HoM owns the UX/positioning
dimension).

#2 — Once BOTH reviewers have finished (dispatched and no longer active),
the orchestrator emits exactly one formal CEO notification via
NotificationService.send_board_review_complete_notification (APPROVAL type,
ack-required, carrying related_task_id) so the handoff is an actionable
signal. One-shot per task; a notification failure clears the guard so a
later tick can retry.

To let the non-assignee board member record its review note on a task held
by the other board member, content-action ownership now exempts a board role
posting to a board/coordination task (project_id is None, product_id set).
The exemption is narrow: it does not widen ownership for any other role or
any project-backed task.

Unit tests cover both reviewers dispatched, one-shot dispatch, the CEO
notification fired exactly once when both are done (and not before), the
retry-on-failure path, the notification builder, and the board co-review
ownership exemption (allowed for board+coordination, blocked otherwise).

* fix(workspace): install dev deps post-clone + raise git commit timeout for large changesets

Cluster C6 (#10, #13, #12-investigate).

#10: per-agent workspace clones never had the project's dev dependencies
installed, so the make-quality gate (ruff/mypy/pytest for Python, the TS
toolchain for the panel) was missing and devs re-downloaded tooling per
task. WorkspaceService now runs the project's install after cloning
(`uv sync` for Python, `pnpm install`/`npm ci`/`npm install` for Node/TS,
detected by manifest/lockfile). Idempotent via a lockfile-digest marker
under .git/ so a re-entry with unchanged lockfiles is a no-op; also runs on
the healthy short-circuit so pre-existing clones get backfilled. Gated by
workspace_install_dev_deps (default on) with workspace_dep_install_timeout_seconds.

#13: the gateway commit verb timed out on the large panel changeset because
every git op used the hardcoded 30s _GIT_TIMEOUT and each call also re-walks
the tree to chown. _run_git now takes a per-call timeout override sourced
from settings (git_command_timeout_seconds default); the staging + commit
ops in commit() and create_commit() use the longer git_commit_timeout_seconds
(default 180s). httpx REST timeouts unchanged in value.

#12 (investigate only — no push, no history change): the clone base ref is
NOT hardcoded; it already comes from project.default_branch threaded through
git.get_workspace -> ensure_workspace -> _clone_repo (git clone --branch).
The stale-base problem is a deploy/process issue (GitHub master is behind the
deployed migration chain), resolvable only by pushing the chain to master.
The default_branch column is the existing configurable lever.

* fix(panel): gate Approve & Start to board coordination tasks; stop 404 storm on closed sessions

CEO gate #1 button only renders for a PENDING board coordination/fan-out
task (no project_id, has product_id) — the board-reviewed handoff that
approve_and_start accepts — instead of every PENDING board-team task.
approve_and_start requires PENDING (it re-targets to Main PM without a
status change), so the gate stays on PENDING rather than the unrelated
end-of-work awaiting_ceo_approval state.

Session/message reads now treat a 404 as terminal and never retry it: a
reaped session is gone for good, and retrying every dead session-id is
what produced the growing 404 storm on GET /api/messages. The transcript
loads once (staleTime Infinity, no focus/reconnect refetch) so closed
sessions stay viewable without re-polling.

* fix(orchestrator): role-correct respawn prompt, throttle agentless dispatch, broaden #14 guard

#19 wrong-role prompt on respawn: _get_prompt_for_agent fell through to the
developer prompt for every non-dev/doc/qa role, so a respawned PM or board
agent was told to write code and call verbs it does not own. Route by the
agent's actual role through the existing per-role prompt builders
(developer/qa/documenter/cell_pm/main_pm/product_owner/head_marketing/auditor).
Both callers benefit; _spawn_pending_dev only ever passes developer/documenter/
unknown, so its behavior is unchanged.

#19 spawn-burst: _dispatch_claimed_without_agent looped over every agentless
claimed/in_progress task and could spawn many containers in one tick. Break
after the first respawn so a restart can't trigger a burst, matching every
sibling dispatcher. The release-to-pending path spawns nothing and keeps
draining stale unknown claims.

#14 guard scope: _is_descendant_code_task only matched CODE, so a descendant
DOCUMENTATION or DESIGN task escalated to a board/advisory role was still
stranded on a role with no verb to own it. Rename to
_is_descendant_executable_task and broaden to CODE/DOCUMENTATION/DESIGN — the
cell-executed types a board role cannot own. PLANNING/RESEARCH/ADMINISTRATIVE
route to a PM, not a cell agent, and are left unchanged; root tasks are still
reviewed up the chain.

* fix(docker): add node+pnpm to orchestrator so it pre-installs frontend cell deps

* Added .github workflows

* refactor(services): extract helpers to keep install_dev_deps + developer task-type check under the xenon complexity gate

* chore(github): add launch kit — CI, GHCR release, labels, templates, funding, dependabot npm, community docs

* chore(github): bump_version — drop unused noqa, fix datetime UTC import

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-03 06:35:03 +02:00
Renn F bc5e016d6d fix(panel): align task actions to server contract + collect required audit notes
The panel's human action buttons had drifted from the server request
schemas: wrong field names (qa_notes/reason vs notes), missing bodies
(cancel/complete/submit-pm-review), and a bare-string docs-complete body —
so cancel/pass-qa/fail-qa/escalate-to-ceo 4xx'd and decisions recorded no
audit note. (Agents were unaffected — they go through the gateway.)

- tasks.ts: pass-qa/fail-qa -> {notes}; escalate-to-ceo -> {notes:reason};
  cancel -> {reason}; complete -> {justification}; docs-complete -> {notes};
  submit-pm-review -> {notes}.
- New reusable RequiredNotesDialog (generalizes CeoApproveDialog). Every
  decision action now collects a substantive note before POSTing: cancel
  (>=10), pass-qa/fail-qa/docs-complete/submit-pm-review/complete (>=20),
  matching the server gates. Wired in the task detail page, the actions
  dropdown, and the kanban board.

Verified: pnpm tsc --noEmit and eslint both clean.
2026-05-24 07:10:34 +02:00
Renn F c093996efc fix(ceo-approve): require substantive notes; panel collects them
ceo-approve was bound to QANotes (notes required), so the panel's
one-click approve (posts {}) 422'd. The wrong fix is to waive notes —
that empties the audit record for a production merge. Instead require
substantive notes (>=20 chars, mirroring pass-qa) and make the panel
COLLECT them: a new CeoApproveDialog (mirrors the reject dialog) gates
the 'Approve & Merge' action, and the dashboard approval-queue enforces
the same before POSTing. The CEO sign-off note is now always captured.
2026-05-24 06:59:37 +02:00
Renn F e3def6b3a2 fix(gateway): cell PM completes its own cell task; drop main-PM handoff
submit_up bubbled the cell task to Main PM (_handoff_to_main_pm), but
main_pm_complete rejects any task with a parent_task_id ("only operates
on root tasks"), so the cell->root PR had no one to merge it and the
cell task wedged at awaiting_pm_review. _maybe_advance_parent_to_pm_review
already intends the CELL PM to complete it.

Cell PM now owns cell completion:
- submit_up no longer hands off to Main PM; the cell task stays assigned
  to the cell PM, which is respawned to complete() it. Removed the
  now-unused _handoff_to_main_pm.
- cell_pm_complete resolves the merge target from the parent task's real
  branch_name (shared merge_chain.resolve_parent_branch, also used by the
  PR side-effects) so the cell->root PR merges into feature/main_pm/...,
  not the team-mis-derived feature/<cellteam>/... (same root cause as the
  prior PR-base fix).
- submit_up description + next_hint updated; lifecycle artifacts regen.

Main PM still only completes the ROOT (root->master + escalate-to-CEO).
First run to reach cell-PM bubble-up exposed this.
2026-05-23 05:16:56 +02:00
Renn F c78395f9fc fix(gateway): submit_up opens cell PR before the pm-review transition
submit_up composed submit_pm_review (atomic) then create_pr (side
effect), but VerbRunner.run_intent runs all composes before any side
effect. submit_pm_review rejects without a PR (returns None), then
create_pr deref'd the None task -> 'NoneType has no attribute
branch_name', wedging cell-PM bubble-up in a respawn loop.

Add IntentSpec.pre_side_effects, run before composes. submit_up now
opens the cell->root PR first (persisting pr_number), so submit_pm_review
re-fetches and passes its pr_created gate. Mirrors the dev open_pr ->
i_am_done split. Latent since the lifecycle spec (207aaec); first run to
reach cell-PM bubble-up exposed it.
2026-05-23 02:32:41 +02:00
Renn F aa2e6bc5ed fix: panel logo (#160), diff base fallback (#161), doc branch checkout (#162)
#160 — panel /roboco-logo.png "received null":
    next/image optimizer fails for static public assets in Next.js
    standalone mode. Added `unoptimized` to the sidebar logo Image so
    it serves the static file directly (validated on panel rebuild).

#161 — QA/doc evidence pr_diff_summary empty:
    A leaf dev branch's parent_branch_for is the cell-PM branch, which
    is never pushed (only devs push their leaf branch). diff against a
    non-existent origin/<parent> returned empty. Added
    GitService._resolve_diff_base + _default_branch_ref + _ref_exists:
    diff/list_changed_files fall back to the repo default branch
    (origin/HEAD → master/main) when origin/<parent> is absent.

#162 — claim_doc_task BRANCH_MISMATCH loop:
    The documenter's clone is separate from the dev's; the task branch
    already existed (dev created it) so no checkout ran in the doc
    workspace — roboco_docs_write / commit failed BRANCH_MISMATCH and
    the doc looped. Fixes:
    (a) new GitService.checkout_branch_in_agent_workspace; claim_doc_task
        checks out the task branch into the doc clone (best-effort —
        a checkout hiccup never fails the claim).
    (b) BRANCH_MISMATCH remediate now lists all four role claim verbs
        (i_will_work_on / i_will_plan / claim_doc_task / claim_review).
    (d) give_me_work next-hint is role+status aware via _claim_verb_hint
        (doc→claim_doc_task, qa→claim_review, pm→i_will_plan, else dev).
    Facet (c) (i_am_blocked "Not Found" for doc) was only reachable via
    the stuck-without-checkout path; primary fix removes it.

Smoke-11 reached dev→QA→doc (deepest ever) and validated the prior
6 fixes (panel flood gone, #158/#159/#157 confirmed). These three
clear the doc-phase blockers found in that run.
2026-05-16 00:13:14 +02:00
207aaecd72 Feature: lifecycle canonical spec (#14)
* chore: clean make quality baseline on feature/lifecycle-canonical-spec

Three classes of pre-existing issues blocking `make quality`:

1. Alembic migrations 002/009/011 used runtime introspection
   (op.get_bind() + inspect / bind.execute) without guarding for
   offline (--sql) mode. `alembic upgrade head --sql` is part of
   `make quality`; in offline mode `op.get_bind()` returns a
   MockConnection with no inspection system, so the migrations
   crashed before emitting their SQL stubs. Each migration now
   short-circuits or simplifies in `context.is_offline_mode()` —
   live-DB behavior is unchanged.

2. ruff format drift on three files left over from prior in-flight
   edits (choreographer/_impl.py, content_actions.py, and one test
   file). `ruff format` applied.

3. vulture flagged two unused `tb` parameters in async __aexit__
   stubs in test_task_service_lifecycle_misc.py. The parameter is
   protocol-required but unused by the body — renamed to `_tb`
   (vulture treats underscore-prefixed names as intentionally unused).

`make quality` is now green from this branch's HEAD; subsequent
lifecycle-spec work can use it as the per-task gate.

* feat(lifecycle): canonical spec package + Role/Status/TaskType enums

Foundation for the canonical lifecycle/permissions module. Enums
mirror docs/internal/old/workflows/STATUS_TRANSITIONS.md +
PERMISSIONS.md. Tests pin enum membership against both the
predecessor canon and roboco.models.base.TaskType.

* feat(lifecycle): Decision dataclass with allow/reject/tracing_gap constructors

Single rejection shape every consumer maps to its native format
(Envelope, HTTP code, prompt hint). __post_init__ enforces the
allowed/rejection_kind invariants so a malformed Decision can't reach
a consumer.

* fix(lifecycle): tighten Decision invariants per Task 2 review

Two reviewer findings on the Task 2 Decision dataclass, addressed
in one commit:

1. The docstring promised `allowed=True ⇒ rejection_kind is None
   AND missing == [] AND remediate is None`, but __post_init__ only
   checked the rejection_kind half. A caller could construct an
   allow-shaped Decision with stale missing/remediate fields and
   sneak it past validation. Tighten __post_init__ to enforce the
   full invariant. Add a regression test.

2. tracing_gap defensively copies the missing list (`list(missing)`)
   to isolate the stored list from later caller-side mutation, but
   no test pinned this. Add a regression test that mutates the source
   list after construction and asserts the stored list is unchanged.

Issue 2 from the same review (mutable list vs tuple for `missing`)
is a broader design call deferred until consumers exist; the
defensive copy is sufficient until then.

* feat(lifecycle): Precondition/ActionSpec/IntentSpec/StatusTransition dataclasses

The four dataclasses that hold the canonical tables. ActionSpec and
StatusTransition are direct ports of pre-gateway PERMISSIONS.md +
STATUS_TRANSITIONS.md rows. IntentSpec is the gateway-only addition:
each gateway intent verb declares which atomic actions it composes.

* feat(lifecycle): _STATUS_TRANSITIONS table + STATUS_GRAPH view

Direct port of STATUS_TRANSITIONS.md. Every transition records its
trigger action and (optionally) a role constraint. STATUS_GRAPH is
the precomputed source→{targets} view callers use for reachability
checks.

* fix(lifecycle): pin role_constraint values + clarify Task-5 handoff

Two reviewer findings on Task 4 _STATUS_TRANSITIONS, addressed in
one commit:

1. The original Task-4 tests verified (source, target) pairs but
   not role_constraint contents. A typo in a single role name (e.g.
   forgetting MAIN_PM from escalate_to_ceo) would have slipped past
   them silently. Add test_status_transitions_role_constraints_match_canon
   pinning every non-None constraint and the cancel-block invariant.

2. role_constraint=None on the `claim` rows from PENDING and
   NEEDS_REVISION was load-bearing — it is the explicit handoff
   point between the StatusTransition table (state machine layer)
   and CLAIM_RULES (per-role claim authority, lands in Task 5).
   The original inline comment said this in passing; expand it so
   the design choice is unmissable for a stranger reading just
   spec.py.

* feat(lifecycle): _ATOMIC_ACTIONS + CLAIM_RULES + ROLE_TEAM_RULES tables

Direct port of PERMISSIONS.md. Every task management tool gets an
ActionSpec with allowed_roles, source_statuses, target_status,
self_review_block, and needs_team_match flags. CLAIM_RULES maps each
Role to the statuses they can claim from. ROLE_TEAM_RULES is the
per-slug team restriction.

* fix(lifecycle): tighten ActionSpec contracts per Task 5 review

Three reviewer findings on Task 5's _ATOMIC_ACTIONS table, addressed
in one commit:

1. set_plan.source_statuses widened to {CLAIMED, IN_PROGRESS} but
   every existing caller (i_will_work_on / i_will_plan compositions)
   runs set_plan while CLAIMED, between claim and start. Narrow to
   {CLAIMED} only. If a future "edit plan mid-flight" feature lands,
   widen explicitly with test coverage at that time.

2. needs_team_match was set True only on claim/qa_pass/qa_fail/
   docs_complete. Defense-in-depth says every role-scoped task
   action should re-assert team match (don't rely on the inheritance
   chain through assigned_to alone). Flip to True on: start,
   set_plan, block, pause, submit_verification, submit_qa,
   submit_pm_review, complete, create_subtask. Leave False on
   board/CEO actions and PM cross-cell interventions (unblock,
   resume, cancel) where the cross-cell semantics are intentional.

3. claim.source_statuses is intentionally a SUPERSET of any single
   role's CLAIM_RULES allowance (the table holds the union; CLAIM_RULES
   holds the per-role authority). Add an inline comment above the
   claim ActionSpec so a future reader doesn't conclude the two
   tables disagree — they don't, they encode overlapping facts at
   different grains.

* feat(lifecycle): _INTENT_VERBS table — every gateway verb declared

Each gateway intent verb is now a named composition of atomic actions
plus optional side effects. i_will_work_on = (claim, set_plan, start);
i_am_done = (submit_verification, submit_qa); open_pr is pure side
effects (push_branch, create_pr); etc.

* fix(lifecycle): widen block.allowed_roles to include QA + Documenter

Task 6 review caught a role-set inconsistency: i_am_blocked.allowed_roles
admits dev/QA/doc, but the underlying block.allowed_roles only allowed
dev+PM. Result: a QA or documenter calling i_am_blocked would pass the
IntentSpec gate and then be rejected by the composed ActionSpec gate
when Task 7 wires can_invoke_intent.

Widen block to include QA + Documenter. The semantic case is sound: a
QA reviewing a task can discover an external blocker; a documenter
writing docs may need PM intervention. Predecessor PERMISSIONS.md
restricted block to dev+PM, but with the gateway exposing i_am_blocked
to all worker roles, the underlying atomic must agree.

The deeper unclaim/escalate_up "imperative verb" concern from the same
review (composes=() but mutates state) is deferred to Task 8 where the
validator design lands.

* feat(lifecycle): public lookup functions + Context + preconditions

can_claim, can_invoke_action, can_invoke_intent, valid_next_verbs,
composed_actions_for, intents_for_role, status_after — the entire
public surface every consumer will use. Context carries the
caller-supplied state preconditions need (plan, journal-decision
flag, etc.). Preconditions for plan/commits/no_pr/ownership are
declared once and wired into the relevant IntentSpecs.

* fix(lifecycle): wire PRECONDITION_OWNERSHIP through Context.actor_id

Task 7 review found _p_owns_task reads agent.id but every call site
passes None for the agent arg. Result: getattr(None, "id", object())
returns a fresh sentinel, task.assigned_to == <sentinel> is always
False, and open_pr / i_am_done would reject every owner the moment
Task 9 wires consumers.

Fix: thread identity through Context.actor_id (new UUID field) and
rewrite _p_owns_task to read from the context. Both call sites already
pass the Context — no signature changes elsewhere. Add green-path
test exercising the owner-can-open-pr case the existing tests
missed (the Task 7 plan only tested precondition-failure paths,
which masked the bug).

Plus surface hygiene: STATUS_GRAPH, CLAIM_RULES, ROLE_TEAM_RULES,
and the four PRECONDITION_* constants are now in
roboco.lifecycle.__init__.__all__ so consumers in Tasks 8/9 don't
depend on the implicit `from roboco.lifecycle.spec import ...`
backdoor.

* feat(lifecycle): import-time self-consistency validators

10 validators run at module import; first failure raises
LifecycleSpecError and prevents the package from loading. Covers
status enum coverage, reachability, terminal exits, intent
compositions, status chain consistency, claim-rule role/status
coverage, self-review symmetry, team-rule slug existence, and
StatusTransition action references.

* fix(lifecycle): close validator gaps; resolve BACKLOG-claim and submit_qa IN_PROGRESS-shortcut ambiguity

Three reviewer follow-ups on Task 8's _validate.py, plus two real
data corrections the new action-target-reachability validator
surfaced.

1. Design spec §9 calls for "every ActionSpec.target_status, when
   set, is reachable from each source_status via STATUS_GRAPH" —
   missing from Task 8's 10 validators. Add
   _check_action_target_reachable_from_source.

2. _check_role_team_rules_slugs verified slug existence in
   AGENT_UUIDS but NOT that the cell team in ROLE_TEAM_RULES
   matches the seed. Add _check_role_team_rules_team_match,
   scoped to non-None entries only — None means "exempt from
   team-match enforcement" (cross-cell roles), not "no team in
   org chart".

3. test_validators_pass_on_real_spec was ceremonial. Add
   test_run_all_validators_raises_on_unknown_intent_action,
   a deliberate-break regression that monkeypatches _INTENT_VERBS
   to inject a fake action and asserts LifecycleSpecError raises.

The new action-target-reachability validator caught two real
data inconsistencies between the predecessor canon docs and the
spec tables:

A. claim.source_statuses listed BACKLOG and CLAIM_RULES[*PM]
   listed BACKLOG, but STATUS_GRAPH[BACKLOG] = {PENDING, CANCELLED}
   only. Resolution: PMs use the explicit \`activate\` action to
   move BACKLOG → PENDING, then claim from PENDING. Drop BACKLOG
   from claim.source_statuses and CLAIM_RULES.

B. submit_qa.source_statuses listed IN_PROGRESS, but
   STATUS_GRAPH[IN_PROGRESS] does NOT include AWAITING_QA. The
   intent verb i_am_done composes (submit_verification, submit_qa)
   which forces IN_PROGRESS → VERIFYING → AWAITING_QA — no
   shortcut. Drop the stale IN_PROGRESS entry from
   submit_qa.source_statuses.

Both corrections tighten the canonical state machine to a strict
no-skip transition graph. Pre-gateway PERMISSIONS.md/STATUS_TRANSITIONS.md
disagreements are resolved here; spec.py is the canon now.

* feat(gateway): Envelope.from_decision maps lifecycle Decisions to envelopes

Single shape adapter so verb bodies stop hand-composing rejection
envelopes. Each rejection_kind maps to a specific envelope flavor;
'self_review' folds into 'not_authorized' with a parenthetical hint;
constructing from an allow Decision raises (programmer error).

* feat(gateway): VerbRunner for atomic composed-action dispatch

Wraps spec.composed_actions_for(intent) in session.begin_nested()
so mid-sequence failures roll the DB back. Side effects run AFTER
the savepoint commits. Each atomic action name dispatches to a
TaskService method via a single, exhaustive _dispatch_atomic
mapping. New verbs slot in by adding an IntentSpec entry + a
_dispatch_atomic case if a new atomic is needed.

* refactor(gateway): i_will_work_on uses spec.can_invoke_intent + VerbRunner

Replace the bespoke status-branch dispatcher in i_will_work_on with the
spec-driven flow: load task -> load agent -> build spec.Context ->
spec.can_invoke_intent (and spec.can_claim for per-role status authority)
-> Envelope.from_decision on rejection -> VerbRunner.run_intent on success.

The _i_will_work_on_pending, _i_will_work_on_claimed,
_i_will_work_on_needs_revision, and _start_failed_envelope helpers are
removed; the runner replaces them. Two narrow verb-body re-entry blocks
remain for behaviors the spec does not yet model:

  1. in_progress + same agent -> idempotent heartbeat-only return
  2. claimed + same agent -> _resume_from_claimed (set_plan + start)
     to recover from a stuck mid-claim crash without re-running claim
     against a state the spec excludes.

The behavioral claim guards (already_active / paused / sibling_sequence)
also stay imperative for now -- they're not in the spec yet and migrate
into spec.extra_preconditions in a later task. Per-role claim authority
is enforced via spec.can_claim because the atomic claim action's
source_statuses are the union across roles; CLAIM_RULES narrows.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role x status x task_type='code') combo (112 rows) and
asserts the envelope error matches the spec's Decision (or can_claim's
Decision when the intent gate passes but per-role claim authority does
not). This is the contract that makes spec/verb drift impossible.

Existing tests updated where rejection-message text changed (the spec
now produces the messages, e.g. "role 'cell_pm' may not call
'i_will_work_on'" instead of "PM cannot execute code") or where the
spec's stricter view ("invalid_state" -> "not_authorized" for a dev
trying to claim awaiting_qa) is more accurate. Test fixtures were
updated to wire task.session.begin_nested as a proper async context
manager (required by VerbRunner) and to set agent_for().id so runner-
driven calls line up with assert_awaited_with(task_id, agent_id).

* refactor(lifecycle): push CLAIM_RULES enforcement into can_invoke_action

Task 11's i_will_work_on migration had to call spec.can_claim()
separately after spec.can_invoke_intent() because the claim action's
source_statuses is the union across all claim-eligible roles —
can_invoke_intent alone would let a developer pass for claiming
awaiting_qa (a QA-only state).

The retrofit pattern would repeat in every claim-composing verb
(i_will_plan, claim_review, claim_doc_task). Push the per-role
narrowing inside can_invoke_action when the action is "claim",
using the same not_authorized vs invalid_state disambiguation
can_claim already implemented (status-reserved-for-another-role
returns not_authorized; status-no-role-can-claim returns
invalid_state). Extracted the body to _check_claim_rules_narrow
to keep can_invoke_action under xenon's complexity threshold.

Update _i_will_work_on_gate to drop the redundant spec.can_claim
call. Update test_consumer_parity.py to assert only against
can_invoke_intent's Decision.

Tasks 12-22 will inherit the cleaner pattern: spec.can_invoke_intent
is the single gate; verb bodies don't need per-action retrofits.

* refactor(gateway): i_will_plan uses spec.can_invoke_intent + VerbRunner

Migrates i_will_plan to the spec-driven pattern Task 11 set up for
i_will_work_on. The verb body now: (1) loads task + agent, (2) builds
Context, (3) checks idempotent/recovery re-entry, (4) calls
spec.can_invoke_intent, (5) returns Envelope.from_decision on
rejection, (6) delegates composition to VerbRunner. The
_i_will_plan_* helpers are removed — the runner replaces them.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role × status × task_type) combo and asserts the
envelope matches spec.Decision.

* refactor(gateway): delegate uses spec.can_invoke_intent for role/state gate

Migrates delegate to the spec-driven role/state gate. The chain
validation (main_pm->cell_pm, cell_pm->its team's devs), the
assignee-vs-task_type rule (Cell PMs receive planning-typed only),
the enum coercion, and the parent-lifecycle/cap guards STAY in the
verb body — they encode delegate-specific semantics the spec
doesn't model.

Parity test in tests/lifecycle/test_consumer_parity.py asserts the
spec's role+state rejection is correctly surfaced. Chain/assignee
rejections continue to be tested in test_choreographer_pm_extras.

* refactor(gateway): open_pr uses spec.can_invoke_intent + VerbRunner

Migrates open_pr to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS,
PRECONDITION_NO_PR) handle all three precondition checks; the verb
body delegates side-effect dispatch (push_branch, create_pr) to
VerbRunner.

Idempotent re-entry retained: an open_pr call against a task that
already has a PR (and the caller owns it) returns OK without
re-opening, rather than the tracing_gap the spec would otherwise
produce. This preserves agent ergonomics — two calls in a row
shouldn't surface a misleading "no_prior_pr" hint.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against representative (status x commits x pr_number) combos and
asserts the envelope matches spec.Decision.

* refactor(gateway): i_am_done uses spec.can_invoke_intent + VerbRunner

Migrates i_am_done to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS)
handle ownership and commit-count checks; VerbRunner dispatches
the (submit_verification, submit_qa) atomic chain.

The tracing-gate preconditions (progress entry, journal:reflect,
acceptance criteria) and the field-level submit-qa gates stay in
the verb body — they model gates the spec doesn't yet cover.
Defense-in-depth: those gates run after the spec accepts the
ownership/commits checks.

Parity test in tests/lifecycle/test_consumer_parity.py runs the
verb against (role × status × ownership × commits) and asserts
the envelope matches spec.Decision.

* refactor(gateway): i_am_blocked uses spec.can_invoke_intent + VerbRunner

Migrates i_am_blocked to spec-driven gating. The journal:struggle
write stays in the verb body (it's a side effect outside the
lifecycle action). VerbRunner dispatches the `block` atomic action
via task_service.escalate.

Parity test in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): unclaim and resume use spec.can_invoke_intent

Migrates both verbs to the spec-driven gate. unclaim's verb body
keeps its dispatch (task.unclaim_for_agent) because composes=();
resume goes through VerbRunner with composes=("resume",).

The reassignment-rejection branch (introduced in 19f27b4 for the
2026-05-08 trace's "not your claim" case) stays - the spec doesn't
model "task got reassigned out from under you by an upstream verb,"
and the existing envelope text ("current owner: X - call
give_me_work() to find your current work") is the load-bearing
hint that fixed the original bug. Extracted the shared branch into
_reassigned_rejection / _ReassignedCtx so both verbs reuse it
without duplicating the envelope construction.

Parity tests in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): complete uses spec.can_invoke_intent at the dispatcher

Migrates the top-level `complete` dispatcher to gate role/state via
spec.can_invoke_intent before routing to cell_pm_complete or
main_pm_complete. The two lower-level methods keep their existing
PR-merge / CEO-escalation logic and pre-flight guards (those model
journal:decision preconditions and PR-mergeability checks the spec
doesn't model yet).

The runner pattern is NOT applied here — `complete` has two divergent
runtime paths (Cell PM merges leaf into parent branch; Main PM opens
master PR + escalates to CEO) that don't fit the runner's
single-composition model. Verb-body-owns-dispatch is the right
pattern.

Parity test in tests/lifecycle/test_consumer_parity.py runs the
verb against (role × status) combos and asserts the dispatcher's
spec rejection is correctly surfaced.

* refactor(gateway): escalate_up, escalate_to_ceo, submit_up use spec.can_invoke_intent

Migrates the three PM-side escalation/submission verbs to
spec-driven role/state gating. The verb-specific guards
(journal:decision, escalation_target configured, _submit_up_guard's
ownership + notes-length + subtasks-terminal) STAY in the verb body
- the spec doesn't model these.

escalate_up has composes=() so the verb body owns dispatch via
task.escalate. escalate_to_ceo and submit_up route their
compositions through VerbRunner.

Parity tests in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): qa.py + doc.py role mixins use spec.can_invoke_intent

Migrates the five QA + Documenter verbs (claim_review, pass_review,
fail_review, claim_doc_task, i_documented) to spec-driven gating.

The self-review block lives at the atomic-action layer
(_ATOMIC_ACTIONS["qa_pass"|"qa_fail"|"docs_complete"].self_review_block=True)
and naturally fires when the verb body builds a Context with
actor_slug==original_developer_slug. No verb-body retrofits needed.

The verb-specific helpers (_verify_qa_owner, _qa_pass_gate_check,
_check_i_documented_inputs) STAY — they encode notes-length /
journal:learning / files-list / qa_evidence_inspected gates the
spec doesn't model.

claim_review and claim_doc_task own dispatch via task.qa_claim /
task.doc_claim respectively (not the runner) because those
specialized claim methods keep status at AWAITING_QA /
AWAITING_DOCUMENTATION, which is what the downstream qa_pass /
qa_fail / docs_complete source-status requirement expects.
The spec gate still validates role + claim source-status + task_type
before dispatch.

pass_review / fail_review / i_documented route their compositions
(qa_pass / qa_fail / docs_complete) through VerbRunner.run_intent
inside a savepoint.

Parity tests in tests/lifecycle/test_consumer_parity.py for all
five verbs.

* fix(lifecycle): claim_review and claim_doc_task have empty composes

Tasks 21-22 surfaced a real spec/runtime mismatch: both verbs were
declared composes=("claim", "start"), but the actual implementation
uses task.qa_claim / task.doc_claim which intentionally keep status
at AWAITING_QA / AWAITING_DOCUMENTATION. If the runner ever ran the
declared composition, it would transition the task to CLAIMED then
IN_PROGRESS, breaking the source-status invariants of qa_pass,
qa_fail, and docs_complete.

The spec is the canon — align it to the runtime. composes=() means
"verb body owns dispatch" (same pattern as escalate_up and unclaim).
The spec gate still validates role + AWAITING_QA / AWAITING_
DOCUMENTATION source-status via the role's CLAIM_RULES narrowing,
enforced through special handling in can_invoke_intent, so role/state
safety is preserved.

* refactor(gateway): role_config flow lists derived from spec.intents_for_role

Hand-maintained _DEV_FLOW etc. tuples replaced with calls into the
spec. Adding/removing a role from an IntentSpec.allowed_roles now
automatically updates the MCP manifest. The spec is the canon;
role_config becomes a thin shim that adds the do-tool / write /
subagent / description metadata the spec doesn't carry.

* feat(lifecycle): generators + make lifecycle for deterministic artifact regen

Renders intent-verbs.md, status-transitions.md, panel/lib/lifecycle.json,
and per-role agents/prompts/_generated/lifecycle-{role}.md fragments
from the canonical spec. `make lifecycle` runs the regenerator;
deterministic output enables CI to gate on `git diff --exit-code` after
running it. The agent prompt fragments will be injected at the top of
each role's system prompt (Task 25) so agents see the same verbs the
gateway accepts.

* feat(lifecycle): inject generated prompt fragments + CI drift gate

Each agent's system prompt now starts with the spec-generated
'verbs available to your role' fragment. CI runs make lifecycle
and fails if regeneration produces a diff — drift between spec
and artifacts cannot land on master.

* refactor(gateway): delete verb_gates.py — superseded by lifecycle.spec

verb_gates.is_verb_allowed and verb_gates.valid_next_verbs are now
spec.can_invoke_intent(...).allowed and spec.valid_next_verbs.
Importers updated to consume the canonical spec module directly.
tests/unit/gateway/test_verb_gates.py removed — coverage lives in
tests/lifecycle/test_spec.py.

envelope.with_introspection wraps spec.valid_next_verbs with role-string
coercion + best-effort try/except so malformed task fixtures (AsyncMock
status) and unknown role strings still yield [] instead of raising —
preserves the legacy verb_gates contract.

content_actions content-tool RBAC (commit/notify) is now a pair of
explicit role frozensets in this file. These are content tools, not
lifecycle intents, so they intentionally do NOT live in spec._INTENT_VERBS.

Two existing introspection tests asserted "commit" in valid_next_verbs;
fixed to assert open_pr/i_am_done — commit is correctly absent under
the canonical spec because it is a do-server content tool, not a flow
intent verb.

* refactor(gateway): collapse scattered role constants into spec

The pm_cannot_execute_code_guard and role_typed_claim_guard guards
both modeled rules the spec now handles via can_invoke_action's
CLAIM_RULES narrowing and ActionSpec.allowed_task_types. Drop them
from claim_guards.py — the choreographer's existing skip-flags on
_run_claim_guards are now permanent: those guards no longer fire.
Simplify _run_claim_guards's signature accordingly.

The concurrency-invariant guards (already_active_guard,
paused_tasks_guard, sibling_sequence_guard) STAY — the spec doesn't
model these system-level invariants. sibling_sequence_guard's loop
body extracted into _earlier_blocking_sibling helper to keep the
slimmed module under xenon's --max-modules A average.

* refactor(enforcement): task_lifecycle becomes a thin view of lifecycle.spec

VALID_TRANSITIONS and ROLE_RESTRICTED_TRANSITIONS are now derived
from roboco.lifecycle.spec — no independent tables. The 433-line
file collapses to ~30 lines of view definitions; future changes
go in spec.py. Helper functions exported by the legacy module are
preserved as thin wrappers so existing consumers don't need to
change their imports today.

A small _LEGACY_OPERATIONAL_EDGES table sits alongside the
spec-derived view to cover transitions the runtime exercises but
the spec has not yet absorbed (voluntary unclaim, reaper sweep,
PM-direct completes from in_progress, parallel-doc-PR developer
trigger). It is fenced and clearly documented; once those callers
are migrated to spec-driven dispatch the constant goes empty and
the file collapses to a pure view.

A test in test_task_service_lifecycle_misc.py was rewritten: the
predecessor asserted CEO-only authority over awaiting_ceo_approval
cancels (legacy table behavior), but the canonical spec authorizes
{CELL_PM, MAIN_PM, CEO} uniformly across all non-terminal cancel
sources. The test now exercises the broader spec-defined cascade.

* feat(lifecycle): UNMIGRATED guard pins known-debt consumers

Two pieces of debt surfaced during Task 28's collapse of
enforcement/task_lifecycle.py: (1) ~11 operational edges still in
the shim's _LEGACY_OPERATIONAL_EDGES because the spec's
_STATUS_TRANSITIONS doesn't yet model them; (2) role-gate
disagreements in _LEGACY_ROLE_GATES that the spec disagrees with.

UNMIGRATED is the named-debt set; KNOWN_UNMIGRATED_CONSUMERS pins
the catalog so a contributor adding a new entry must update both
sides. Validator (_check_unmigrated_is_subset) fires at import if
they drift. Test pins the current entries.

Phase 3's terminal invariant is `UNMIGRATED == frozenset()` —
expected when both legacy data carriers fold into spec, at which
point the assertion becomes a permanent regression guard.

* test(lifecycle): tier 3 end-to-end real-DB happy paths

Eight integration tests covering every major lifecycle path:
dev (pending → awaiting_qa), QA pass, QA fail, doc handoff,
Cell PM complete, Main PM escalate-to-CEO, block+unblock,
pause+resume. Each test drives the spec → choreographer →
TaskService → DB stack with only the git layer mocked. Catches
"spec says X, DB constraint says Y" mismatches the unit-tier
parametrized parity suite cannot detect.

* test(lifecycle): tier 4 smoke replay — pin known-bug shapes after spec migration

Synthesized fixture covering the 9 bugs from the 2026-05-08
audit-log trace + the 2 from the 2026-05-09 follow-up trace. Each
record documents (verb, role, task setup, expected post-fix
envelope shape, fix commit, spec invariant). The replay test
parametrizes over the records and asserts the spec / choreographer
behavior now matches the post-fix expectation — locks in the
fixes as permanent regressions.

The original audit log was wiped during cleanup; the fixture is
a documented synthesis, not a verbatim capture. The bug list is
faithful to the prior session's analysis of the trace.

* fix(orchestrator): silence dev-dispatcher noise for non-dev-lane tasks

Dev dispatcher fetched all pending/claimed/in_progress tasks regardless
of assignee role and warned 'role/task_type mismatch' on each pass when
it found cell_pm/main_pm/product_owner/etc. tasks — those belong to
_dispatch_pm_work, not this lane. The 30s warning loop showed up
prominently in the 2026-05-10 smoke run.

Filter at the lane boundary: silently skip when assignee role is not
developer/documenter/unknown. The D-49 misassignment warning still
fires for the legitimate cases (developer assigned a documentation
task, etc.).

* fix(gateway,prompts): unblock the three smoke-run dead-ends

Three issues surfaced by the 2026-05-10 smoke run, fixed together
because they're all blockers for end-to-end task completion:

1. Acceptance-criteria tracing gate was unsatisfiable. Nothing in the
   codebase writes to task.acceptance_criteria_status, so
   _check_acceptance_criteria always returned every criterion as
   missing. Treat a reflect note as the addressing artifact: when the
   agent has written one, the gate clears. Per-criterion citation via
   acceptance_criteria_status is still honored when populated, so the
   schema stays available for future per-criterion tracking.

2. Cell PM runaway re-decomposition. On every wake-up be-pm
   re-decomposed its parent task without checking for existing
   children, producing duplicate dev subtasks. cell_pm.md now teaches
   'list children before delegating' and 'one dev subtask is usually
   enough — QA/Documenter/PM-merge engage automatically'. Added
   anti-pattern entries for re-decomposition and over-decomposition.

3. Main PM exit/respawn loop on claimed-state tasks. The model
   cycled through delegate/resume/escalate/unblock looking for a verb
   that worked on 'claimed', and got cleanly rejected by every one.
   The right verb is i_will_plan (it composes claim+set_plan+start
   and resumes from claimed). main_pm.md now spells this out
   explicitly with a worked example of which verbs reject and why.

* feat(prompts): restore pre-gateway lifecycle scaffolding across all 6 roles

The gateway migration shrank role prompts from ~50 lines to ~15
(commit 534152c for dev; analogous shrinks for qa/doc/cell_pm/main_pm/board
in e12a596, 05ac832, 8dc381b). The verb surface got cleaner but the
prescription for using verbs through the lifecycle disappeared. The
2026-05-10 smoke run surfaced the regression: agents thrash through
verbs hoping one fits, journal sparsely, skip the dev reflect note,
and (for cell PMs) re-decompose on every wake-up.

Each role prompt now restores three sections that the pre-gateway
versions had:

1. State -> Verb table — what to call when respawned in each
   lifecycle status. Eliminates the verb-cycling antipattern: the
   agent looks up its current status and calls the one verb that
   transitions out of it.

2. Mandatory pre-handoff checklist — explicit walk-through of the
   gates the next verb will check, ordered so the agent fixes the
   missing piece before retrying:
   - developer: 7 items before i_am_done
   - qa: 8 items before pass/fail (incl. self-review forbidden,
     read dev journal not just diff, name artifact per criterion)
   - doc: 7 items before i_documented
   - cell_pm: 7 items before submit_up (incl. integration green)
   - main_pm: 7 items before complete(root)
   - board: separate checklists for escalate_to_ceo (PO/HoM) and
     reflect-note quality (Auditor — its only output)

3. Journaling cadence — when to use each of the five scopes
   (note/decision/struggle/learning/reflect). The pre-gateway
   prompts named all five scopes with role-specific examples;
   the post-gateway prompts mention 'reflect' once and skip the
   rest. Restored across every role.

Plus restored the load-bearing rules that got dropped:

- Cell PM: 'A SINGLE subtask flows through dev -> QA -> doc ->
  PM-merge. DON'T split into per-role subtasks.' This is exactly
  what be-pm violated in the smoke run, creating duplicate
  'branch naming subtask' / 'PR workflow subtask' / etc.
- QA + Doc: 'read the dev's journal, not just the diff' — pre-
  gateway forced this via roboco_journal_read_team; post-gateway
  the inline data exists but the agent isn't told to use it.
- Developer: 'every acceptance criterion gets a citation in the
  reflect note' — pairs with the tracing-gate change in 75b667d
  where the reflect note is treated as the addressing artifact.

* feat(foundation): bootstrap foundation/identity.py with Role/Team/RoleLevel

Phase 1 task 1 of the foundation canonicalization plan
(docs/superpowers/specs/2026-05-10-foundation-canonicalization-design.md).

Three enums, no consumers yet — separate tasks migrate the existing
forks (models.base.AgentRole, lifecycle.spec.Role, agents_config role
sets, services/permissions.PM_ROLES) onto this canonical surface.

* feat(foundation/identity): add AGENTS catalog (single source for slug->role+team+UUID)

Resolves head-marketing.team drift (spec §5.1) by setting Team.BOARD
authoritatively. Team.MARKETING remains in the enum for legacy seed
data but no agent claims it; flagged for removal in cleanup.

* feat(foundation/identity): add role-sets + ROLE_LEVEL hierarchy

* feat(foundation/identity): add lookups + public API re-exports

* feat(foundation): import-time validators (uniqueness, role coverage, role-level)

* chore(foundation): verify+align postgres agentrole/team enums with foundation/identity

scripts/verify_postgres_enums.py reads the live agentrole+team enums
from postgres (via asyncpg using roboco.config.settings.database_*)
and compares them against the foundation Role+Team enums. Exits 0 on
match, 1 on drift (with a per-side diff), and 1 with a clear message
if postgres is unreachable so callers like make foundation-check can
treat that as a skip.

alembic/versions/012_align_agentrole_team_with_foundation.py is the
forward-only safety-net migration. It runs ALTER TYPE agentrole ADD
VALUE IF NOT EXISTS 'system' (idempotent on postgres >= 9.6) so any
DB without the recently-added Role.SYSTEM sentinel gets it on next
upgrade. Postgres has no DROP VALUE primitive without a destructive
type recreation, so foundation keeps legacy values (e.g. Team.MARKETING)
to absorb the inverse direction; the migration's downgrade is
intentionally a no-op.

Local verification deferred: postgres is not reachable from this
workstation (role 'roboco' does not exist), so the script could not
confirm the live enum shape. The migration is idempotent and runs
unconditionally on the next alembic upgrade head, and whoever next
runs make foundation-check against a live DB will get the post-migration
proof of alignment.

* refactor(lifecycle): re-export Role from foundation.identity (single source)

* refactor(models): re-export AgentRole and Team from foundation.identity

Removes the parallel Team and AgentRole StrEnum definitions in
models/base.py. They are now bound to roboco.foundation.identity.Role
and roboco.foundation.identity.Team respectively, so AgentRole IS
identity.Role (same Python class object). SQLAlchemy column types
bound as sa.Enum(AgentRole, name='agentrole') continue to work because
identity is preserved across import paths.

Note: foundation.Team drops the legacy 'fullstack' member that lived
on models.base.Team. The two _resolve_team_dir tests that used
Team.FULLSTACK to exercise the 'fullstack' branch now pass the literal
string 'fullstack' instead — same code path, no enum-membership coupling.

Adds two identity assertions to tests/foundation/test_role_reexport.py
verifying AgentRole is identity.Role and Team is identity.Team.

* fix(foundation): correct Team enum — add FULLSTACK, remove QA

The original plan's audit incorrectly identified the models/base.Team
membership. Actual original was 7 values: backend, frontend, ux_ui,
fullstack, main_pm, board, marketing. My plan replaced fullstack with
qa and added system — but qa was never a team (only a role).

Postgres team enum has fullstack (alembic 009), and services/task.py:675
+ services/git.py:779 branch on the literal "fullstack". Without
foundation.Team.FULLSTACK, any Project row with assigned_cell="fullstack"
would fail to round-trip through the SQLAlchemy ORM.

This correction:
- Adds FULLSTACK; removes QA from foundation.Team
- Updates the 8-value test expected set
- Restores tests/integration/test_task_service_misc.py to use Team.FULLSTACK

* refactor(agents_config): derive AGENT_ROLE_MAP/AGENT_TEAM_MAP/CELL_MEMBERS from foundation

* refactor(roles): canonicalize role-sets via foundation.identity

- agents_config.PM_ROLES (5-role: PMs + board + CEO) renamed to
  TASK_CREATOR_ROLES; the name PM_ROLES is reserved for the canonical
  2-role set (CELL_PM + MAIN_PM) defined in foundation.identity.
- agents_config._BOARD_ROLES aliased to foundation.BOARD_ROLES (drops
  main_pm from the set; board A2A handler updated to keep allowing
  board -> main_pm direct messaging via explicit branch).
- services/permissions.PM_ROLES (2-role) re-exported from foundation.

Closes the silent semantic divergence flagged in spec section 3 (HIGH severity).

* refactor(seeds,orchestrator): derive agent catalogs from foundation

- seeds/initial_data.AGENT_UUIDS derived from foundation.AGENTS.
- DEFAULT_AGENTS row generation pulls slug+role+team+id from foundation;
  per-agent presentation strings (display name) stay in this file in
  _AGENT_PRESENTATION dict. The system sentinel remains a literal with
  team=None because the postgres `team` enum has no 'system' value.
- runtime/orchestrator._AGENT_TEAM_MAP and the cell-prefix table replaced
  with foundation.team_for_slug. _AGENT_TEAM_MAP is now a derived ClassVar
  covering every slug (not just management).
- head-marketing.team resolved to "board" (was "marketing" in seed +
  orchestrator, "board" in agents_config — three-way drift, now unified).
- ceo.team resolved to "board" (was None in seed; foundation declares
  board membership so the seed-bootstrapped DB row now reflects that).
- Adds tests/foundation/test_seed_orchestrator_parity.py — gate against
  future drift between seed/orchestrator and foundation.

Closes the identity sub-phase. Adding an agent edits exactly one file:
foundation/identity.py:AGENTS.

* feat(foundation/policy): task_completeness rules + denylist

Implements spec §5.2: field-level completeness rules at create/delegate
time, plus the denylist that catches the literal placeholder string from
the deleted services/task.py:5061-5062 silent fallback ("completed and
reviewed by assignee" — agents copy-paste this from old logs).

CompletenessSpec is data; check() is a pure function; field_hints map
gives the agent the literal answer key for each missing field.

* feat(envelope): add incomplete_input envelope kind for interrogation pattern

Sister to tracing_gap; distinct error code lets agent prompts teach
incomplete_input handling separately from tracing-gap recovery. Carries
missing + field_hints + remediate for the spec §5.2.1 interrogation
pattern; Task 19 will wire the gateway delegate verb to use it.

* feat(foundation/task_completeness): auto-fill helpers (team, priority, parent)

* feat(api/schemas): DelegateRequest enforces TASK_AT_CREATE constraints

Removes silent defaults for nature/task_type/estimated_complexity;
adds min_length=20 to description; requires non-empty acceptance_criteria.
Mirrors foundation.policy.task_completeness.TASK_AT_CREATE so under-filled
delegate calls fail at the request boundary (422) instead of being silently
papered over downstream.

Tests touching delegate calls updated to pass the now-required fields.

* feat(models/task): TaskCreate + TaskCreateRequest enforce TASK_AT_CREATE

* feat(api/schemas): TaskUpdate rejects blanking acceptance_criteria

Golden Rule preservation — acceptance_criteria cannot be set to []/None
via PATCH. Pydantic field min_length doesn't catch explicit None, so a
model_validator(mode='before') guards the patch payload.

* fix(services/task): delete silent acceptance_criteria fallback (skeleton-task root cause)

The fallback at services/task.py:5061-5062 silently replaced empty
acceptance_criteria with ['completed and reviewed by assignee'] -
the proximate cause of every skeleton task in the 2026-05-10 smoke
run. Removed; create_subtask now invokes foundation.policy.task_completeness
and raises TaskCompletenessError on missing fields (spec section 5.2).

Two existing transition tests relied on a 1-char description default
that the new completeness check rejects (description min_length=20);
both updated to pass an explicit valid description. New integration
test pins the rejection contract (empty list + legacy phrase both
raise).

Companion code path at services/gateway/choreographer/_impl.py:1852
(the upstream `or []` collapse) is fixed in the next task.

* fix(gateway/delegate): use task_completeness + Envelope.incomplete_input

Replaces the `acceptance_criteria=inputs.acceptance_criteria or []`
collapse at _impl.py:1852 with a foundation.policy.task_completeness
check that rejects empty / placeholder input via
Envelope.incomplete_input — the spec section 5.2.1 interrogation pattern.

Auto-fill helpers (fill_team_from_assignee + fill_priority_from_parent)
fill the unambiguous fields before the check, then anything still
missing surfaces as a structured rejection with field_hints; the agent
gets a literal answer key for what to provide on retry.

DelegateInputs gains an explicit `nature` field (no default) so the
HTTP boundary can thread DelegateRequest.nature through to the
choreographer. Route handlers (flow_cell_pm, flow_main_pm) forward it.
The hardcoded TaskNature.TECHNICAL fallback in _create_subtask_from_inputs
is removed; the helper now coerces inputs.nature to the enum or raises
TaskCompletenessError if a non-gateway caller bypassed the check.

Closes the gateway-side path to skeleton tasks. Service-layer raise
(Task 18) remains as defense-in-depth for non-gateway callers.

Existing delegate-guard tests updated to pass full payloads — the
prior `title='x', description='y'` minimal stubs now hit the
completeness gate first; the full payloads still exercise the
auth/chain/cap guards downstream.

* feat(api/routes/tasks): POST /tasks uses foundation.task_completeness check

Replace the hand-rolled acceptance_criteria non-empty check in the
POST /tasks handler with a call to task_completeness.check(TASK_AT_CREATE,
data). Route, schema (TaskCreate), and service (TaskCreateRequest) now
all share one canonical notion of 'complete' — the fourth and final
create path is now strict.

Pydantic still rejects structurally invalid payloads (empty AC list,
short title/description, missing enums) with 422. The TC check at the
route boundary additionally rejects denylisted placeholder phrases
('completed and reviewed by assignee', etc.) that pass schema validation
but signal a stub task.

Add tests/integration/test_post_tasks_completeness.py:
  - empty acceptance_criteria  -> 422 (Pydantic)
  - placeholder phrase         -> 400/422 with 'acceptance_criteria' in body

* chore(make): add foundation-check drift gate (mirrors lifecycle-check)

* test(foundation): Phase 1 smoke gate — skeleton-task path returns incomplete_input

Phase 1 closes here: identity catalogs are single-sourced; the silent
acceptance_criteria fallback is gone; gateway delegate returns
incomplete_input with populated field_hints when criteria are missing.

The 2026-05-10 smoke run that produced skeleton tasks no longer can.
Phases 2-4 (tracing, journaling, communications, agent_loop, housekeeping)
get their own plans.

* feat(foundation/policy): journaling scope catalog (5 panel-UI scopes)

* feat(foundation/policy/journaling): role read tiers + protected journals

* refactor(content_actions): derive _VALID_NOTE_SCOPES from foundation.journaling

* refactor(services/journal): derive _SCOPE_TO_TYPE from foundation.journaling

* refactor(enforcement/journal_perms): import read-tier rules from foundation

PROTECTED_JOURNALS + ROLE_READ_TIERS now sourced from foundation.policy.journaling.
The local helpers (_check_protected_access, _check_cell_pm_access,
_check_cell_member_access) are collapsed into a single tier-driven check via
_decide_protected / _decide_by_tier. Pre-Phase-2 GLOBAL_READERS that lumped
CEO/auditor/PO/HoM/main_pm together is split into ReadTier.ALL (ceo+auditor —
includes protected) vs ReadTier.ALL_CELLS (others — excludes protected).
Observable behavior preserved.

* feat(foundation/policy): tracing Requirement enum + check_requirements

19 requirements (16 from pre-Phase-2 tracing_gate + 3 pre-gateway parity:
JOURNAL_NOTE_AT_CLAIM, JOURNAL_DECISION_AT_CLAIM, JOURNAL_DURING_WORK).
GateContext expanded with the new presence flags and journal_during_work_count.
Acceptance-criteria checker keeps the spec §9 item 1 reflect-note shortcut.

* feat(foundation/policy/tracing): VERB_REQUIREMENTS table + verb parity validator

Maps every gateway intent verb to its required-set. Includes the 6 inline
journal:decision callsites (submit_up, complete, unblock, escalate_up,
escalate_to_ceo, delegate) plus the 4 pre-gateway parity additions
(NOTE_AT_CLAIM, DECISION_AT_CLAIM, REFLECT on complete, DURING_WORK).
Validator asserts every spec verb is covered or explicitly waived, and
every Requirement enum value is used by at least one verb.

PLAN added to i_will_work_on / i_will_plan (mirrors spec.PRECONDITION_PLAN
in the tracing layer). SELF_VERIFIED added to i_am_done as a defense-in-depth
backstop (auto-set by the in_progress→verifying transition).

* refactor(gateway/i_am_done): tracing gates via foundation.policy.tracing

Adds JOURNAL_DURING_WORK_AT_LEAST_ONE check (pre-gateway parity P2 —
agents must write at least one decision/learning/struggle entry between
claim and submit). Adds journal.has_struggle_for_task helper.
Replaces the pre-Phase-2 tracing_gate.check_requirements call.

SELF_VERIFIED is filtered from the pre-flight required-set: the spec
composes (submit_verification, submit_qa) for i_am_done and the
auto-run submit_verification flips self_verified=True before submit_qa
runs. The flag therefore acts as a defense-in-depth backstop AFTER the
spec, not before — checking it pre-flight would block the auto-verify
path. SELF_VERIFIED stays in the foundation required-set and is
re-asserted by the spec action's own preconditions.

Test fixtures updated: 9 i_am_done success-path tests now mock
has_decision_for_task=True (or equivalent) so the new during-work
cadence gate is satisfied. NO_PR-token assertion broadened to also
accept the foundation token "pr_open".

* refactor(gateway/qa): pass/fail gates via foundation.policy.tracing

* refactor(gateway/doc): i_documented gates via foundation.policy.tracing

Doc-specific missing-key translations (docs_notes>=min, docs_files_non_empty)
added to the central _build_tracing_gap translator established in Task 9.

* refactor(gateway): unify 6 inline journal:decision checks via tracing.check_requirements

Pre-Phase-2 inline blocks at _impl.py lines ~2230/2394/2442/2574/2814/2895
each ran the same has_decision_for_task + Envelope.tracing_gap pattern. They
now call:

- _check_pm_decision_required(verb, ...) — for unblock, escalate_up,
  escalate_to_ceo, delegate. Each declares only JOURNAL_DECISION in
  VERB_REQUIREMENTS, so a single helper consuming
  tracing.requirements_for(verb) suffices.
- _check_complete_gates — for cell_pm_complete and main_pm_complete.
  Consumes VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT
  + NOTES_MIN_CHARS. The inline _subtasks_not_terminal_envelope is kept
  because its remediation enumerates the non-terminal subtask ids — strictly
  richer than the foundation hint.
- _check_submit_up_gates — for submit_up. Consumes
  VERB_REQUIREMENTS["submit_up"] minus SUBTASKS_TERMINAL (deferred to the
  inline envelope for the same reason as complete).

Also adds the journal:decision tracing gate to the delegate verb
(VERB_REQUIREMENTS["delegate"] = {JOURNAL_DECISION}) — pre-gateway PM.md
required journal:decision before each delegate, but the gateway path had
not yet enforced it. Threaded into _delegate_extra_guards so the verb
body's return count stays under the lint cap.

_build_tracing_gap gains hint translations for journal:decision, notes>=min,
and subtasks_terminal. The body is refactored to a static dispatch table
+ acceptance-criteria batch handler so the branch count stays under the
lint cap.

PM-verb success-path tests updated to provide notes >= 20 chars (the new
NOTES_MIN_CHARS gate); has_reflect_for_task mocks added to a few tests
where they're now load-bearing (AsyncMock truthiness covers most).
6575 tests passing, mypy + ruff clean.

* feat(gateway/claim): require journal:note_at_claim and journal:decision_at_claim

Pre-gateway parity P1, P3: developers wrote a note (scope='note') on
every claim; PMs wrote a decision (scope='decision') on plan. Restored
via foundation.policy.tracing requirements wired through a new
_post_claim_journal_gate helper that runs AFTER the composed
(claim, set_plan, start) sequence completes.

Failed checks return tracing_gap with a remediate hint that tells the
agent to journal then retry. The claim itself stays — the agent
journals and re-issues the verb (idempotent re-entry shortcuts back
to OK once the entry is present).

Adds journal.has_note_for_task helper paralleling
has_decision/reflect/learning/struggle. The PLAN requirement is
filtered out of the post-claim check because spec.PRECONDITION_PLAN
already enforced it before the runner ran — re-asserting at the
tracing layer would emit a misleading hint.

Two new tests verify the gate fires for missing note/decision; existing
success-path tests already mock the journal service via AsyncMock
(returning truthy) so no regressions.

* test(foundation): Phase 2 smoke gate + tracing_gate.py deleted

Phase 2 closes here:
- foundation/policy/journaling.py owns the 5-scope catalog + read tiers
- foundation/policy/tracing.py owns Requirement enum + VERB_REQUIREMENTS
- 6 inline journal:decision checks replaced with unified helpers
- pre-gateway parity restored: NOTE_AT_CLAIM, DECISION_AT_CLAIM,
  DURING_WORK, REFLECT-on-complete
- services/gateway/tracing_gate.py deleted
- enforcement/journal_perms.py read-tier rules canonicalized

Smoke gate 2 enforces: no inline has_decision_for_task remains; every
intent verb has a tracing decision; tracing_gate module is gone.

* fix(foundation/task_completeness): align hint strings with actual enum values

_HINT_NATURE listed 5 values (technical | bugfix | feature | refactor | docs)
but TaskNature only has 2 (TECHNICAL / NON_TECHNICAL). _HINT_ESTIMATED_COMPLEXITY
listed "critical" which Complexity doesn't have. _HINT_TEAM omitted FULLSTACK
(real, used) and didn't note that MARKETING is legacy seed-data. _HINT_TASK_TYPE
was already correct.

Hints now reflect the actual enums in roboco/models/base.py and
roboco/foundation/identity.py — agents reading the gateway's incomplete_input
remediate envelopes will no longer be told to send values the enums reject.

Tests using nature="feature" (DelegateRequest's nature is `str`, not the
enum, so it accepted the fake value silently) updated to nature="technical"
so they exercise a real enum value end-to-end.

* fix(orchestrator): remove dead "critical" complexity branches

Complexity enum has only LOW / MEDIUM / HIGH — no CRITICAL value.
The three "critical" branches in dispatch logic at lines ~3032 / 3331 /
5049 were dead code (the comparison can never be true). Removed.

Surfaced during Phase 2 closeout when the foundation hint string was
audited against the actual enum.

* feat(foundation/policy/communications): Priority + NOTIFY_SENDER_ROLES + ACK_REQUIRED_BY_TYPE

* feat(foundation/policy/communications): CHANNELS catalog (channel topology)

* refactor(agents_config): derive CHANNEL_ACCESS from foundation.communications

* refactor(seeds): derive DEFAULT_CHANNELS / CHANNEL_MEMBERSHIPS from foundation

* refactor(content_actions): derive notify allowlist + priorities from foundation

Replaces _NOTIFY_ALLOWED_ROLES + _VALID_NOTIFY_PRIORITIES literals with
derivations from foundation.communications.NOTIFY_SENDER_ROLES + Priority.

Behavior change: pre-Phase-3 the literal frozenset {cell_pm, main_pm,
product_owner, head_marketing} excluded CEO. Foundation includes CEO
(per spec 5.5). The contradiction with agents_config.NOTIFICATION_PERMISSIONS
(which already granted CEO can_send=True) is now resolved.

* refactor(notification_delivery): requires_ack from foundation.ACK_REQUIRED_BY_TYPE

* refactor(enforcement,agents_config): delete dead notification policy

- enforcement/notification_perms.py deleted (dead at call-graph; only
  the enforcement/__init__.py re-export kept it reachable, and that
  re-export is gone too).
- agents_config.NOTIFICATION_PERMISSIONS dict deleted; agents_config
  .can_send_notifications now derives from
  foundation.policy.communications.NOTIFY_SENDER_ROLES (auditor
  correctly excluded — silent observer per spec §5.5).
- services/permissions.py: _can_role_send_notifications and
  can_agent_send_notifications now derive from NOTIFY_SENDER_ROLES;
  _get_notification_scope encodes the scope rule (cell/all/list)
  locally as a function-of-role and returns list[AgentRole] instead
  of list[slug]; can_notify list-scope branch updated to match.
- enforcement/__init__.py: removed the notification_perms re-export
  and the NotificationPermissionError, get_notification_scope,
  validate_notification_permission names from __all__.

Closes the spec §3 contradiction: gateway content_actions
._NOTIFY_ALLOWED_ROLES (Task 5) and the legacy
agents_config.NOTIFICATION_PERMISSIONS no longer disagree about
whether auditor may call notify(). Both now derive from
foundation.NOTIFY_SENDER_ROLES.

* fix(content_actions): runtime auditor guard in say/dm (defense in depth)

Closes the spec §5.5 gap where the auditor's silent role was enforced
ONLY by manifest exclusion. The manifest pre-filters the tool surface
exposed to the auditor agent, but if anything bypassed it, the auditor
could speak. The new runtime guard in ContentActions.say/dm refuses
with Envelope.not_authorized when the caller's role is "auditor",
regardless of how the call arrived.

* fix(a2a): pass Priority tristate end-to-end (was reduced to boolean)

Pre-Phase-3 path:
  request priority: str -> services/a2a.py reduces to urgent: bool
  -> services/notification.py maps bool back to NotificationPriority
This made Priority.HIGH unreachable through the A2A path.

After this fix the full tristate (NORMAL/HIGH/URGENT) survives end-to-end:

  * services/a2a.py:create_a2a_notification parses metadata["priority"]
    (preferred) or falls back to legacy metadata["urgent"] / config.urgent
    (URGENT-only). Unknown values fall back to NORMAL.
  * services/notification.py:send_a2a_notification now takes
    a2a_context["priority"] (NotificationPriority); a defensive bool/str
    coerce keeps legacy callers from crashing.
  * runtime/orchestrator.py:_build_a2a_prompt reads priority off the
    notification row (the source of truth) instead of a non-existent
    metadata.urgent and renders three tiers: URGENT bold, HIGH softer,
    NORMAL no prefix.

Cosmetic [URGENT] body/subject prefix stays urgent-only; HIGH gets no
prefix but is recorded as HIGH at the NotificationTable.priority column.

Tests:
  * 9 new tests in tests/integration/test_a2a_priority_tristate.py
    pinning the round-trip for HIGH/NORMAL/URGENT through both layers
    plus legacy-bool backcompat.
  * Updated tests/unit/services/test_notification.py::test_send_a2a_notification
    to the new priority= contract.

Closes the spec section 3 contradiction flagged in the audit.

* feat(foundation/policy): agent_loop BudgetPolicy + VERB_RETRY_LIMITS

* refactor(agent_sdk): import budget thresholds from foundation

* refactor(orchestrator): import _PM_RESPAWN_MAX_UNPRODUCTIVE from foundation

* fix(post-tool-budget-hook): exit 1 on loop-halt (was exit 0 / non-blocking)

Pre-Phase-3 the hook printed [Loop] and exit 0'd — agents could ignore it
and keep retrying. The 2026-05-10 smoke run showed i_am_done retried 5+
times within the global 150-tool budget, never hitting a real wall.

Now the hook reads the SDK response's loop_action field (sourced from
foundation.BudgetPolicy.loop_action; default "halt") and exits 1 to
deny the wrapping tool call when the rolling-window loop detector fires
AND loop_action is "halt". Operators can soften via env
ROBOCO_AGENT_LOOP_ACTION=warn for debugging.

Changes:
- BudgetStatus pydantic model: add loop_action: Literal["warn", "halt"]
  (default "halt") so the SDK response carries the policy.
- agent_sdk/server.py: read ROBOCO_AGENT_LOOP_ACTION env override on top
  of foundation default and surface it in _budget_snapshot().
- post-tool-budget-hook.sh: parse .loop_action, exit 1 to stderr when
  loop+halt; falls back to legacy warn-only print if the field is
  missing (older SDK / partial deploy).

* feat(agent_sdk): per-verb retry circuit breaker via foundation.VERB_RETRY_LIMITS

Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke
showed i_am_done retried 5+ times in 2 minutes within the global 150-tool
budget — the agent never hit a real wall.

Now the SDK tracks (verb, task_id) -> deque[timestamp] over a 60s sliding
window. When the count for a verb exceeds foundation.retry_limit_for(verb),
the next attempt receives Envelope.circuit_open with a remediate hint
pointing to i_am_blocked / i_am_idle as graceful exits.

Verbs in foundation.UNLIMITED_RETRY_VERBS (give_me_work, triage,
evidence, etc.) bypass the breaker. Only rejection envelopes
(tracing_gap, invalid_state, not_authorized, incomplete_input) feed
the counter — successful calls do not count.

Wire-up:
- Envelope.circuit_open classmethod + as_dict pass-through
- _SessionState.verb_attempts: defaultdict[(verb, task_id), deque[float]]
- Helpers _record_verb_attempt / _verb_attempt_count / _check_verb_circuit
- POST /verb/attempted: hook posts after a rejected gateway call;
  response carries breaker state + (when open) the wire-format
  Envelope.circuit_open dict the agent should surface to itself
- GET /verb/circuit_status: read-only state probe
- _state.reset() (also POST /budget/reset) wipes the tracker on spawn

* test(foundation): Phase 3 smoke gate + foundation-check extended

Phase 3 closes here:
- foundation/policy/communications.py owns Priority, NOTIFY_SENDER_ROLES,
  ACK_REQUIRED_BY_TYPE, ChannelSpec, CHANNELS, parse_priority
- foundation/policy/agent_loop.py owns BudgetPolicy, VERB_RETRY_LIMITS,
  UNLIMITED_RETRY_VERBS, retry_limit_for
- 6 channel topology fork sites collapsed to one source (CHANNELS)
- Notification sender contradiction closed (CEO included; auditor excluded)
- A2A urgency tristate restored (HIGH reachable end-to-end); A2A
  service now consumes parse_priority instead of inlining branches
  (also drops create_a2a_notification CC from C/13 to A/<10)
- Auditor silent role enforced at runtime in say/dm
- enforcement/notification_perms.py deleted (was dead code)
- 7 hand-set requires_ack callsites consolidated to ACK_REQUIRED_BY_TYPE
- post-tool-budget-hook.sh exits 1 on loop-halt
- Per-verb retry circuit breaker live in agent_sdk (60s sliding window)

make foundation-check now validates communications + tracing + journaling
+ identity drift in one command. make quality green.

* refactor(lifecycle): copy spec.py to foundation/policy/lifecycle.py + shim

Phase 4 Task 1 — relocates the canonical lifecycle spec next to its policy
siblings (task_completeness, tracing, journaling, communications, agent_loop).

The original roboco/lifecycle/spec.py is now an explicit re-export shim;
consumers continue to work unchanged. Subsequent Phase 4 tasks (2-7) migrate
the imports in batches, then Task 8 deletes the shim.

No behavior change — pure code move.

* refactor(services): import lifecycle from foundation (Phase 4 batch)

* refactor(agents,enforcement): import lifecycle from foundation (Phase 4 batch)

* refactor(tests): import lifecycle from foundation (Phase 4 batch)

* refactor(foundation): absorb lifecycle _validate + _generators

Phase 4 Tasks 9 + 10. Moves the lifecycle spec's internal validators
to roboco/foundation/_validate_lifecycle.py and its RAG/prompt artifact
emitter to roboco/foundation/_generators.py.

The lifecycle validators live in a sibling module (not merged with
foundation/_validate.py) because the lifecycle spec imports from
foundation at module load — placing the lifecycle checks alongside the
identity checks would create an import cycle between
roboco.foundation and roboco.foundation.policy.lifecycle (the latter
calls the validators at the bottom of its own definition). The
_validate_lifecycle module defers its policy.lifecycle imports to
function bodies so it loads cleanly when the spec hasn't finished
initialising yet; the per-file PLC0415 exemption in pyproject.toml
documents the reason.

Test files relocated:
- tests/lifecycle/test_spec.py        -> tests/foundation/test_lifecycle_spec.py
- tests/lifecycle/test_generators.py  -> tests/foundation/test_lifecycle_generators.py

scripts/build_lifecycle_artifacts.py now imports the generators from
roboco.foundation; the on-disk artifacts (docs/rag/lifecycle,
panel/lib/lifecycle.json, agents/prompts/_generated/lifecycle-*.md)
regenerate byte-identically.

After this commit, roboco/lifecycle/ contains only the spec.py and
__init__.py re-export shims — Task 8 deletes those.

No behavior change. 6638 tests pass; make quality green.

* refactor(lifecycle): delete legacy roboco/lifecycle/ package

Phase 4 Task 8. All consumers migrated to roboco.foundation.policy.lifecycle
in Tasks 2-7; the internal validators + generators moved to foundation in
Tasks 9-10. The legacy package contained only re-export shims.

Also trims tests/foundation/test_role_reexport.py — the two assertions that
checked the lifecycle.spec shim's object-identity are gone with the shim.
The two models.base shim assertions (AgentRole / Team) are still
meaningful and stay.

Inline docstrings / comments in enforcement/task_lifecycle.py,
services/gateway/role_config.py, services/gateway/content_actions.py,
tests/integration/test_task_service_lifecycle_misc.py and
foundation/policy/lifecycle.py that referenced the now-deleted
roboco.lifecycle.spec module are updated to point at
roboco.foundation.policy.lifecycle.

After this commit, roboco.lifecycle is gone. Lifecycle policy lives only
at roboco.foundation.policy.lifecycle. Adding new lifecycle rules edits
exactly that one file.

* refactor(api): consolidate route-guard role-sets via foundation

Replace hand-written role-name string frozensets in roboco/api/deps.py
(_PM_OR_ABOVE_ROLES, _DEVELOPER_OR_ABOVE_ROLES, _GLOBAL_CELL_ACCESS_ROLES)
and roboco/api/routes/v2/_role_dep.py (require_dev/qa/doc/cell_pm/main_pm/
board/auditor) with foundation-derived expressions over PM_ROLES,
BOARD_ROLES, DEV_ROLES, and Role enum members.

Behavior is preserved: Role is a StrEnum, so the lowercase X-Agent-Role
header still compares equal to its matching member. HEAD_MARKETING stays
excluded from every -or-above set (marketing spokesperson, not approver);
the carve-out is now expressed as (BOARD_ROLES - {Role.HEAD_MARKETING})
instead of an opaque literal.

Adds tests/foundation/test_route_guard_consolidation.py (6 tests) pinning
both the foundation-derived membership and the import contract.

* test(foundation): Phase 4 smoke gate + housekeeping closeout

Phase 4 closes the foundation canonicalization effort (Phases 1-4 spanning
2026-05-10 -> 2026-05-11):

Phase 1 - identity + task_completeness (skeleton-task bug killed)
Phase 2 - tracing + journaling (pre-gateway cadence restored)
Phase 3 - communications + agent_loop (channel/notification/A2A/circuit-breaker)
Phase 4 - housekeeping (lifecycle moved to foundation; consumers migrated)

All cross-cutting policy now lives in roboco/foundation/. Adding a policy
edits exactly one file. The legacy roboco.lifecycle package is gone.
Smoke gates 1-4 enforce: no skeleton tasks, no inline journal:decision
checks, channel topology canonical, A2A tristate preserved, auditor silent
at runtime, lifecycle module path canonical.

make quality + make foundation-check both green.

* fix(mcp/agent_sdk): wire per-verb circuit breaker into response handler

Phase 3 Task 14 added the SDK infrastructure (tracker, endpoints,
Envelope.circuit_open, retry_limit_for) but nothing was actually
recording rejections — the breaker never tripped. This commit wires
the gateway-response path so every rejection envelope (tracing_gap /
invalid_state / not_authorized / incomplete_input) hits
POST /verb/attempted, and if the breaker is open, the envelope is
substituted with the circuit_open response before the agent sees it.

Best-effort: SDK-unreachable / malformed-response failures fall open
(agent sees the original rejection), so the breaker never breaks the
gateway path.

* fix(notification_delivery): retype CEO approval-flow notifications APPROVAL

notify_assignee_of_ceo_rejection and notify_ceo_of_escalation were both
typed NotificationType.TASK_ASSIGNMENT, which the Phase 3 foundation
table (ACK_REQUIRED_BY_TYPE in roboco/foundation/policy/communications.py)
maps to requires_ack=False. Both are approval-flow notifications and
should mandate acknowledgment.

Retyped both to NotificationType.APPROVAL so the table lookup yields
requires_ack=True via ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL].

* test(foundation): move lifecycle parity + smoke-replay tests under tests/foundation/

Phase 4 Task 8 deleted roboco/lifecycle/ but tests/lifecycle/ still held
two files importing roboco.foundation.policy.lifecycle. Mirror the layout
of test_lifecycle_spec.py and test_lifecycle_generators.py (moved in
Phase 4 Tasks 9+10) by relocating them under tests/foundation/ with the
test_lifecycle_* prefix, then delete the now-empty tests/lifecycle/
package.

  tests/lifecycle/test_consumer_parity.py
    -> tests/foundation/test_lifecycle_consumer_parity.py
  tests/lifecycle/test_smoke_replay.py
    -> tests/foundation/test_lifecycle_smoke_replay.py

* build(make): consolidate ci-lifecycle-check into foundation-check

ci-lifecycle-check was a thin wrapper that regenerated lifecycle artifacts
via scripts/build_lifecycle_artifacts.py and gated on git diff. After
Phase 4 it sat alongside foundation-check covering the same drift-gate
intent. Merge the lifecycle-artifact regen + git-diff step into
foundation-check so a single 'make foundation-check' is the canonical
drift gate.

Keep ci-lifecycle-check as a phony alias forwarding to foundation-check
for any external script or CI lane still using the old target name.
Drop the redundant ci-lifecycle-check call from 'make quality'.

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-05-11 02:15:47 +02:00
Renn F 73e1e96851 Many fixes and cleanups 2026-05-09 03:15:09 +02:00
Renn F 3cabee155e chore(lifecycle): remove quarantined state (phantom)
State existed in the lifecycle table and the enum but no verb, route,
or service path ever set it. Removing dead state. If we need
problem-task isolation later we'll add it explicitly with a verb.
2026-05-03 09:52:07 +02:00
Renn F a82a4f9fd4 fix(.gitignore): anchor Python build artifacts; recover panel/src/lib (28 files)
The 'lib/' rule (intended for Python virtualenv at repo root) was matching panel/src/lib/, hiding the entire panel API client + utility tree from git. Anchored Python build-artifact rules to the repo root with a leading slash so they only match at the top level. Adds 28 panel/src/lib files that should have been tracked from day one.
2026-05-02 03:18:47 +02:00
Renn F 254cc93fd5 It gets to documenter but QA fails false positive then it gets blocked because it needs to be awaiting_pm_review to be able to merge. It's almost there. 2026-05-01 05:41:10 +02:00
Renn F 7970020021 AI Providers 2026-05-01 03:09:11 +02:00
Renn F 8e201901c0 I mean, it's at a good place rn... 2026-04-20 15:10:54 +02:00