Commit Graph
99 Commits
Author SHA1 Message Date
401f8a2cc9 feat(board): Board Programs — the complete twelve-program catalog (Phases 1-3) (#699)
* feat(board): Pest Control — the first project-scoped Board Program

The Product Owner hunts latent defects (what the org records but nobody
reads): a weekly cycle — accelerated off-schedule when the trailing-7-day
rework rate crosses pest_rework_threshold, with the cheap dedup/scope gates
evaluated before the metrics queries — opens one held exploration task
against the least-recently-explored opted-in project (deterministic
round-robin; opted_in_projects gains a stable ORDER BY), with server-
assembled evidence in the spawn prompt (rework hotspots, recurring-findings
and waived-minor ledger aggregates, all capped) plus prior-cycle LEARN
context. The PO calls the new PO-only propose_bug_hunt verb once: ≤5 items,
evidence required per item, targets validated against pest_control
participation. CEO decides per item — approve materializes a BACKLOG task
(source pest_control, never auto-starts), reject records the reason; both
feed the LEARN ledger by exploration task id; all-terminal completes the
cycle. Telegram queue pushes carry working Approve/Reject handlers
mirroring the roadmap kind. Doctrine: board.md Pest Control section +
product-owner verb entry + regenerated verb tables.

* feat(panel): Pest Control review queue

Command Center gains the pest review queue (per-item approve/reject with
reason, mirroring the roadmap queue); the Programs card and the project
settings participates-in checkboxes pick the new program up registry-driven
— the settings section renders for the first time now that a project-scoped
program exists.

* feat(board): Periscope — HoM market-research brief program

Weekly org-scoped cycle: a solo HoM spawn researches the market (web
research with mandatory source URLs — uncited findings are rejected) and
files one structured brief via the new HoM-only propose_market_brief verb:
headline, cited findings, threats/opportunities, positioning note, all
soup-checked and screened through the injection guard at persist time
(web-derived text later reaches prompts; flags recorded, content never
dropped). A brief is a report, not a proposal: the verb completes the
exploration in the same call (the x_feature asymmetry), the cycle ledger
auto-closes, and the CEO gets a best-effort notification with no
approve/reject surface (periscope deliberately never joins Telegram's
action kinds). The latest brief is injected into the roadmap exploration
prompt — Periscope feeds Printer, the first cross-role program input.

* feat(panel): Market Briefs tab (read-only)

Business page gains a Market Briefs tab listing Periscope briefs —
headline, cited findings, threats/opportunities — read-only by design; a
report has nothing to approve.

* feat(board): Coroner — event-triggered Auditor postmortems

The first EVENT program: no cron — three best-effort hooks open an autopsy
when a task bounces to its 3rd revision (the audit chokepoint), is
cancelled after work started, or is budget-blocked; all gated on arming +
one-open-autopsy dedup, none can fail the underlying transition. A solo
Auditor spawn reads the incident (server-assembled findings + transition
context) and files one propose_postmortem: incident summary, root cause,
failed stage (validated against the real status vocabulary), and ONE
process change — a playbook-kind change drafts via PlaybookService
directly into the normal pending-curation queue; the briefed draft_playbook
manifest grant was deliberately NOT added, preserving the existing
'auditor curates but never drafts' invariant test. Complete-at-propose
(report asymmetry), cycle ledger auto-closes, CEO notified link-only.
Integrated as a union with Periscope across the shared program surfaces.

* feat(panel): Coroner postmortems card

Read-only postmortems list under Business → Programs — incident, root
cause, failed stage, process change; nothing to approve, the process-change
artifact (a draft playbook) rides the existing curation queue.

* feat(board): Sentinel — Auditor drift-watch quality reports

Weekly org-scoped cycle: a solo Auditor spawn receives a server-assembled
drift context (waived-findings trend, open findings by severity,
conventions-violation hotspots, top spend — all capped, pure ORM) and files
one propose_quality_report: headline, 1-7 area-validated items with
evidence and suggested actions, overall assessment. Report semantics —
complete-at-propose, cycle auto-closes, CEO notified display-only (never on
Telegram's approve/reject surface); items are structured so a later
convert-to-task control is cheap. Integration adopts Sentinel's module-
level dict-dispatch for board-program routing (xenon-driven), folding all
prior programs in; app router mounting extracted to a helper for the same
budget.

* feat(panel): Quality Reports tab (read-only)

Business page gains the Sentinel quality-reports tab — headline, per-area
observations with evidence and suggested actions; read-only, a report has
nothing to approve.

* feat(board): Spackle — gap-fill audit program

Biweekly project-scoped PO cycle over the half-shipped surface area: API
routes without panel surfaces (and vice versa), armed flags without docs,
docs promises the code doesn't keep, dead-end tabs — the inventory diffing
is the PO's own read-tool work, ordered by the spawn prompt with file:line
citations required; the server injects only prior-cycle LEARN and the
rotation target. Rotation is now a shared module-level helper
(pick_rotation_target, parameterized by source) both project-scoped
engines use — pest_control delegates to it, behavior-identical, with a
cross-pollution test proving the two programs' rotations stay independent.
propose_gap_fill mirrors the bug-hunt verb (≤5 items, two-sided evidence
required, participation gate); per-item CEO decide materializes BACKLOG
source=spackle tasks; full Telegram kind incl. approve/reject handlers.
All seven program routers now mount from one helper.

* feat(panel): Spackle gap-fill review queue

Command Center gains the gap-fill queue mirroring the pest-control one —
per-item approve/reject with the two-sided gap evidence rendered.

* feat(board): Scales — monthly portfolio rebalance

Org-scoped PO cycle over the stale backlog: the spawn receives a capped
stale-task snapshot (BACKLOG/PENDING unclaimed >30 days) plus the charter
and prior-cycle LEARN, and files one propose_rebalance — 1-7 items, each a
resolvable task_ref with action reprioritize (validated new priority) or
cancel, rationale required. Per-item CEO decide: approve EXECUTES the
action (audited priority update, or the normal cancel path) — the first
program whose materializer mutates existing tasks instead of creating
them; reject records the reason; LEARN by exploration task id;
all-terminal completes the cycle. Full Telegram decide-kind wiring.
Integrated as the eight-program union (registry, dict dispatch, routers
helper, teardown enumerations).

* feat(panel): Scales rebalance review queue

Command Center gains the rebalance queue — per-item approve/reject with
the action, target task, and rationale rendered.

* feat(board): Mirror — quarterly positioning audit

Project-scoped HoM cycle over messaging surfaces: README claims vs shipped
reality, docs-site promises vs code, charter alignment — the audit is the
HoM's own read-tool work with citations required; the server injects the
charter, prior-cycle LEARN, and the shared rotation target. propose_
messaging_fixes mirrors the gap-fill verb (≤5 items, drift evidence naming
claim + contradicting reality, participation gate); per-item CEO decide
materializes BACKLOG source=mirror documentation tasks; full Telegram
decide-kind wiring. Nine-program union across the shared surfaces.

* feat(panel): Mirror messaging-fixes review queue

* feat(board): Megaphone — HoM standing editorial calendar

Cron cycle (3 days, org-scoped, gated on X credentials — drafting content
nobody can post is pointless): the HoM receives a shipped-this-week digest
plus Unreleased changelog bullets and files one propose_editorial_post
(angle-validated, ≤280, brand voice) that materializes a held x_editorial
draft through the SAME X-queue origination chokepoint release posts use —
zero new approval surface, notifications and CEO decide for free.
Complete-at-propose; cycle auto-closes. Ten-program union.

* feat(panel): x_editorial source labels in the X queue surfaces

* feat(board): Librarian — proactive playbook mining

Biweekly org-scoped Auditor cycle: mines recurring non-private learning
journals (≥2-count grouping with a recency fallback) against the existing
playbook-title inventory and files one propose_playbook_drafts — 1-3
drafts, each with the repeated-pattern evidence that justifies it,
duplicate titles rejected in-batch and against the live store. Drafts are
created via PlaybookService directly (the Coroner precedent — the
'auditor curates but never drafts' do-verb invariant stays intact and
tested) and land in the normal pending-curation queue the Auditor's own
triage already surfaces; no new panel surface. Complete-at-propose;
display-only CEO notification. Eleven-program union.

* feat(board): War Room — release campaign planning

EVENT program with a REAL originator (unlike coroner's stub): a release
publish hooks a campaign brief beside the release-post seam, and the CEO's
run-now originates on demand — the cron loop never fires it. The HoM
designs a 2-6 post arc (teaser → launch → follow-up → spotlight; 280-cap,
future strictly-ascending publish_after, stage vocabulary) and one
propose_campaign call materializes each post as a held x_campaign draft
through the X-queue chokepoint. V1 is manual-cadence by design: publish_
after renders as queue guidance and the CEO approves each post at its
moment — nothing auto-posts, ever; the auto-schedule upgrade is a
documented ceiling. Twelve-program union: full registry complete.

* feat(panel): x_campaign labels + publish-after guidance in the X queue

* feat(board): Barfly — adjacent-conversation replies

Cron cycle (2 days, org-scoped, X-credentials gated): the engine searches
X for conversations where RoboCo is relevant but unmentioned (new OAuth-
signed search_recent on the client; queries + candidate cap configurable),
screens every fetched tweet through the injection guard (stored unclamped
— a clamp was truncating the candidate under the envelope, caught by the
dev's own tests), dedupes via the existing x_seen_mentions ledger (no
migration; also prevents double-drafting against the mentions poll), and
opens one held HoM exploration carrying the screened candidates. propose_
conversation_replies enforces candidate-id-only replies (≤5, 280-cap);
each materializes a held x_barfly draft through the X-queue chokepoint,
threaded via a new in_reply_to seam on post_tweet that only x_barfly
drafts use. The X redraft machinery is now dict-dispatch over per-source
extractors with reply-ref carry for x_barfly. Thirteen-program registry.
War Room's test fakes gained the new abstract search_recent stub.

* feat(board): Dogfood — the PO walks the product

The fourteenth and final registry entry, completing the catalog. EVENT
program (release-publish hook beside the war-room hook + CEO run-now, both
through the same real originator; the cron loop never fires it), project-
scoped with shared rotation. The permission surface is the careful part:
the PO's dogfood spawn — and ONLY that spawn — gets the Playwright MCP
mounted, via a task-scoped fail-closed probe mirroring the video-authoring
precedent (a PO spawned for roadmap/pest/scales never sees browser tools;
tested both ways); the PM agent image bakes chromium unconditionally like
the ux image, the mount stays task-gated in code. The walk targets the
rotation target's live surfaces (panel_base_url only when the target is
the org's own project, honest degradation otherwise); propose_friction_
fixes files ≤5 walked-path-evidenced items; per-item CEO decide
materializes BACKLOG source=dogfood tasks; full Telegram decide kind.
Also: megaphone/librarian/war_room arming keys restored to the settings
validator — their panel toggles would have been rejected (dropped in
earlier unions; the same silent-arming class the drill killed once
already).

* feat(panel): Dogfood friction review queue

* chore(board): final whole-branch sweep fixes

The night's closing adversarial pass over the integrated fourteen-program
registry found ONE functional defect — the war-room test fakes' post_tweet
predated Barfly's in_reply_to_tweet_id kwarg (LSP violation, the only red
in an otherwise fully green gate) — plus doc/test drift, all fixed: the
source-parity test completes to fourteen (spackle/mirror were silently
absent while its neighboring comment claimed full coverage), the PO
identity doc gains its missing Dogfood verb, the auditor quick-list gains
propose_postmortem, three stale comments corrected (rotation docstring,
panel registry header, X source enumerations), the dogfood release-hook
gains the exception-swallow test its four sibling hooks already had, and
the CHANGELOG's Unreleased section documents the whole Board Programs
train. Full make quality: exit 0, all gates green.

* docs: full documentation sweep for the Board Programs train

CLAUDE.md's roadmap-engine entry superseded by the Board Program registry
entry (all fourteen programs, arming, scoping, LEARN, guardrails) with the
role verb tables and playwright row refreshed; docs/rag gains the agent-
facing architecture doc plus full propose_* call-shape sections in the
three board role docs, and corrects the strategy-engine section to shipped
reality (only idle→roadmap is wired); docs/map covers the registry + all
twelve engines with flags, gotchas, and drift notes. The 0.27.0 reference
inventory confirmed only the release-executor's canonical set carries the
version — left for the 0.28.0 cut.

* feat(board): human titles + descriptions on every program surface

Raw registry keys rendered as bare panel labels — an operator reading
x_feature had no idea what enabling or running it does. The registry
dataclass gains title/description (test-enforced non-empty for every
entry, unique titles), the API passes them through, and every surface
renders title-with-description-tooltip instead of the key: the Programs
card (label, toggle hint, run-now toast), and the project settings
participates-in/excluded-from checkboxes.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-25 17:13:32 +02:00
23ae0ca217 fix(gateway): covers_parent_criteria hint that teaches the shape; CEO pause/resume (#686)
* fix(gateway): teach the delegate remediate + PM prompt the covers_parent_criteria shape; allow CEO through the plain pause route

- A child draft rejected for missing covers_parent_criteria now gets a
  copy-pasteable corrected skeleton with the parent's real criteria
  inlined, and the PM delegation guidance shows the field as part of
  every child draft — a PM no longer loops on a rejection that named
  the field but never showed the shape.
- The plain pause route now authorizes the CEO tier like its sibling
  lifecycle routes; agent-side pause restrictions are unchanged.

* fix(gateway): delegate-coverage hint heals and degrades on legacy parents

- The coverage-reject path self-heals a criteria-bearing parent whose
  ids are empty or out of length before rendering the hint, so the
  skeleton always shows real references; the renderer itself also
  falls back to quoted criterion texts for any criterion without an
  id instead of emitting a placeholder or truncating the listing.
- The remediate names both legal reference forms (id or exact text)
  again.
- Route comments state the pause/resume check as deliberately
  CEO-only instead of claiming a precedent whose role set is wider.

* test(gateway): real TaskTable rows in the remediation hint round-trips

mypy over tests/ rejects a SimpleNamespace where unknown_ac_refs takes a
TaskTable; instantiating the ORM row directly needs no session and types
cleanly.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:10:20 +02:00
21910d75ea chore(board): revive dormant board wiring — research key, pitch flow, auditor playbooks (#684)
* chore(compose): pass research key/provider + provisioning token/org through to the orchestrator

ROBOCO_RESEARCH_API_KEY / ROBOCO_RESEARCH_PROVIDER and ROBOCO_PROVISIONING_TOKEN /
ROBOCO_PROVISIONING_ORG were absent from every compose environment stanza, so .env
values never reached the container: research silently ran on the NullProvider
(empty results forever) and any approved pitch died on ProvisioningDisabledError.
.env.example also falsely claimed the provisioning creds are panel-managed.

* feat(board): pitch CEO notification + auditor playbook-draft surfacing

A proposed pitch now nudges the CEO (APPROVAL notification + Telegram link to the
Pitches tab, best-effort — a send failure never fails the verb). auditor_triage
surfaces the oldest pending playbook draft once anomalies are clear — the curation
verbs were granted but nothing ever pointed the Auditor at the review queue; the
scheduled audit prompt names the discovery path.

* docs(prompts): pitch doctrine section + auditor reply-only-dm drift fix

board.md never mentioned the pitch verb, so no board agent ever had a reason to
call it — it gets a dedicated section mirroring the roadmap/spotlight ones, plus
a roadmap-exploration escape hatch (needs-its-own-repo ideas pitch instead).
product-owner.md gains its missing propose_roadmap + pitch entries. The flat
'Auditor has no dm' claims are corrected to the real grant: never initiates,
reply-only in a CEO-opened thread. Doctrine guarded by a prompt-content test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 14:41:14 +02:00
2e889c7009 feat(lifecycle): inherit advanced upstream base on work re-claims (#644)
* feat(lifecycle): inherit advanced upstream base on work re-claims

A re-claim reused a branch cut at an earlier claim, so upstream work
merged since (UX/UI landing on the root after the cell branch was cut)
never reached BE/FE branches — divergence and avoidable conflicts.
_finalize_claim now merges the advanced base into the pre-existing branch
via the dependency-lineage merge: already-ancestor is a no-op, a conflict
aborts at the cut point and leaves a transition note steering the agent
to sync_branch, a clean merge logs an audit trail; never fails the claim.

Double-gated: by role (developer/cell_pm/main_pm — QA/documenter/gate
claims review the branch as pushed and never move it) AND by pre-claim
status (pending/needs_revision only — a PM's i_will_plan re-claim of its
own awaiting_pm_review task must not move a branch that already passed
QA + the PR gate). Fresh cuts already branch from the live remote base.
Cell-PM prompt now orders reading the upstream design docs before
planning.

* fix(lifecycle): extract base-inheritance gate predicate for xenon budget

The four-condition inline gate pushed _finalize_claim to cyclomatic
rank C; the quality gate caps blocks at B. The decision moves to a pure
module-level predicate, byte-for-byte the same logic.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:29:46 +02:00
5b27a443e9 chore(agnosticism): close the audit residue — B6/B8/B10 + three MAJORs (#587)
Thread the deployer's product name through the X reply + feature-spotlight
prompts (B6 leftover; release/video paths shipped in #570); make the
docs-site repo/URL config (ROBOCO_DOCS_SITE_*, defaults unchanged) instead
of a roboco-website hardcode (B8); de-assert our repo from the Main PM
prompt (B10); derive PR labels from the real target branch instead of
literal to-master/to-slave; drop the stale headcount from base.md; and
make the bash-guard's Makefile check require an actual quality/gate/lint/
test target before denying raw package-manager commands (no more
false-remediation loop on Go/Rust Makefiles).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 17:52:17 +02:00
29335f4732 feat(prompter): make the intake actually use its task-history digest (#592)
The history-digest pipeline (PR #297) injected past-task data but nothing
told the intake agent what to do with it: prompter.md now carries an
explicit don't-re-propose section (cite duplicates by short id, reference
precedent in notes, let history inform depends_on sequencing), and
list_recent_for_project excludes cancelled tasks so dead work can't pose
as precedent.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 17:25:41 +02:00
885d6bbe83 feat: CEO-grade A2A — New DM composer, CEO-DM wake, docs scrub (#547)
* feat(panel): CEO New-DM composer and direct-thread replies on the A2A page

* docs(agents): remove dm-the-CEO teaching; fix Board/HoM dead-end escalation recipes

* feat(a2a): CEO-authored DMs wake offline recipients via the a2a_request dispatch path

* fix(a2a,panel): wake only read_a2a-capable roles; case-insensitive header defaults; wider DM picker exclusions

* docs(map): CEO-DM wake mechanics, requires_ack override, A2A composer components; comms-model update

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-18 00:44:00 +02:00
a9dee3b34e feat(agents): force agents to the Makefile — deny raw uv/pip/conda/poetry (CEO #15) (#518)
* fix(prompts): point agents at Makefile, drop raw uv run instructions

backend.md:23-26 literally instructed raw uv run ruff/mypy/pytest (copied from
the human-facing CLAUDE.md), so agents bypassed the Makefile's UV_NO_SYNC=1 +
private UV_CACHE_DIR venv-corruption guard. Replace with make targets across
backend/developer/qa/cell_pm + a universal rule in base.md. Regenerate verbs.md
from the updated regen script (baked instruction now make foundation-check) and
align the Makefile drift message. Ships with the bash-guard deny in the next
commit so agents don't loop fighting the guard.

* feat(bash-guard): deny raw uv/pip/conda/poetry, point at Makefile

When a Makefile is present, deny raw uv run/uv pip/uv lock/add/remove, pip/pip3
install/uninstall, conda install/create/run, poetry run/install/add and remediate
to make quality/gate/lint/test. Skipped when no Makefile (Makefile-less projects
not blocked). ROBOCO_GUARD_SKIP_PM=1 (grok path) nudges exit 0 instead of the
run-canceling exit 2. Overrides the prior bare-uv-run-allowed stance by CEO
direction; the /app-targeted blocks above keep priority.

* feat(grok): deny raw uv/pip/conda/poetry via native --deny + PM-skip nudge

Add _RAW_PM_DENY (uv run/pip install/lock/add/remove, pip/pip3 install, conda
install/create/run, poetry run/install/add) to _deny_rules so grok's graceful
native --deny blocks raw package-manager commands (model adapts to make, run
continues — unlike a hook deny which cancels the run). The bash-guard hook
keeps the compound-command fallback (cd x && uv run) and nudges exit 0 there via
ROBOCO_GUARD_SKIP_PM=1 in the grok hook env, never canceling.

* test(bash-guard): align existing tests with W1 Makefile-gate policy

Raw uv run / pip install are now Makefile-gated (W1, CEO item #15), so two
existing bash-guard invariants reverse:

- test_allows_pytest_even_if_suite_uses_requests keeps its HTTP-injection
  allow-path intent but uses bare `python -m pytest` (raw `uv run` is now
  denied); the deny case is covered by test_bash_guard_makefile_guardrail.
- test_allows_pip_install_in_workspace -> test_denies_pip_install_when_makefile_
  present: a workspace clone carries a Makefile, so bare pip install is now
  denied -> agents use `make` / `uv sync --extra dev`. Makefile-less skips
  stay covered.

Gate: 12994 passed, 439 skipped, 94.81% cov (DB env :55432 user renzof);
the lone flaky integration error passes in isolation (DB-state race, not W1).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-15 04:32:27 +02:00
ba7135ba50 feat(gateway): carry intake technical depth down the chain + widen review coherence scope (#491)
Two structural issues flagged by the CEO:

1. Task technical-depth dilution — intake's rich analysis (file:line
   targets, code examples, rationale) was getting lost as it traveled
   umbrella -> root-subtask -> cell -> dev. The detail IS preserved in
   Task.description; the dilution was in delegation (PMs re-authoring)
   and the intake prompt not demanding depth.

   Fixes:
   - evidence_repo: ancestor_context_for_task walks the parent chain
     (cycle-guarded, depth-capped 16, desc-clipped 1500) and surfaces it
     as parent_context in the evidence payload, so a leaf dev finally
     sees the upstream intake analysis instead of a bare title.
   - evidence_builder: Task.description now rides in the payload;
     EvidencePayload gains description + parent_context (omit-when-empty
     so no null noise).
   - orchestrator: _description_body (capped 4000) injects the
     description into the dev spawn prompt + SessionStart briefing.
   - role prompts (main_pm/cell_pm/developer/prompter): teach pass-the-
     torch, don't-dim-it; prompter now demands file:line/code-examples
     in the_work/notes (reconciled with the no-code-level-ACs-on-roots
     rule). main_pm's brief-not-a-spec scoped: not-a-spec applies to the
     solution only, facts forward verbatim.

2. PR-review/QA scope too narrow — they only checked the AC checklist,
   not whether the change is coherent with project structure/intent.

   Fixes:
   - qa.md + pr_reviewer.md: Coherence & intent rule (intent via
     description+parent_context, coherence with project patterns,
     standards). Criterion-less major findings allowed for intent drift
     (Finding.criterion is optional).
   - parent_context + description wired into the gate/QA/inbound-PR
     evidence builders (fail-open, logged).

Skipped per YAGNI: a technical_spec JSONB column (detail is already in
description) and a criterion_kind enum (criterion is already optional).

All gates green: ruff, mypy (1152), pytest (12883 passed, 94.82% cov),
xenon, vulture, bandit, pip-audit, deptry, alembic, import-linter,
foundation-check.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-13 08:06:57 +02:00
cea3e56628 feat(lifecycle): revision findings ledger — structured failure feedback, persisted and delivered down the chain (#486)
* feat(lifecycle): revision findings ledger — structured QA/PR/PM/CEO failure feedback, persisted and delivered down the chain

Every bounce used to survive only as flattened prose: rounds overwrote each
other in notes_structured, request_changes persisted nothing, two raw
dev_notes appends were silently destroyed by the next handoff note, and the
dev prompt pointed at fields (qa_notes via evidence(), pm_notes) the API
never delivered. Agents re-interpreted and re-discovered every failure
before they could start fixing it.

- task_review_findings (migration 071, append-only): file/line/severity/
  criterion(AC-id-validated)/expected/actual/fix/evidence per finding, with
  origin (qa|pr_gate|pm|ceo), round, and an open->addressed->verified
  lifecycle (waived reserved); new tasks.pm_notes + PmReviewContent give
  request_changes a structured home
- producers: fail_review/pr_fail/request_changes take findings=[...] (prose
  issues shimmed+merged for one release, deprecation-logged); ceo_reject
  validates its reason (no 500), lands an origin=ceo finding, and bumps
  round+audit on branchless coordination roots; guardrails at the verb
  chokepoint (nudge >5, hard reject >10, field caps, traversal-safe file);
  the dev_notes data-loss appends are removed; new task.request_changes +
  task.ceo_reject audit events close rework attribution
- delivery: qa_notes/pr_reviewer_notes/pm_notes carry the deterministic
  [F-id8] rendering; claim briefings, evidence(), the REVISION_REQUIRED
  spawn prompt, PM triage bounced-blocks, and A2A bodies deliver open
  findings; round-N+1 QA and gate reviewers get the full prior ledger;
  panel Findings tab + bounced-xN chip; metrics pm_rejects/ceo_rejects +
  findings counts; vault task notes render a Findings section (fail-open)
- resolution closes for every origin: i_am_done and submit_up/submit_root
  take resolved_findings gated by FINDINGS_ADDRESSED (owner-gated so a
  stale non-owner PM can never mutate the ledger); pass_review/pr_pass/
  complete verify-stamp same-transaction; ceo_approve stamps best-effort
- 24 real-DB integration tests drive the full loop through the real
  choreographer; full suite 12856 green

* docs: revision findings ledger sweep — CLAUDE.md, map, RAG corpus

- CLAUDE.md: new ledger section + corrected request_changes row
- docs/map/review-findings.md (new subsystem map) + surgical updates to
  task-service/pr-gate-review/metrics-observability/vault/panel maps
- docs/rag: producers' findings contract across qa/pr-reviewer/developer/
  cell-pm/main-pm/ceo role docs (the PM docs were missing request_changes
  entirely), verb references, and a new architecture/review-findings.md
  disambiguating ledger findings from convention findings

* test(e2e): resubmit resolves the pr_fail finding per the ledger contract

The scripted pr_fail revision loop resubmitted submit_up without
resolved_findings — correctly rejected now that FINDINGS_ADDRESSED gates
the PM resubmit verbs (green locally, red only in CI since the e2e suite
skips without ROBOCO_E2E_SMOKE=1). The scripted PM now reads the open
ledger row pr_fail persisted (new open_finding_ids arc helper) and
resolves it on resubmit, asserting the open set drains — exercising the
coordinator half of the new contract end to end.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 22:54:42 +02:00
4d52f6ff59 [1f6a06a2] PR-review gate: verify ACs literally and require green CI before pr_pass (#428)
* [a1bde3b9] Add CI-status guard to pr_pass + update pr_reviewer prompt (#417) (#420)

* [a1bde3b9] feat(gateway): CI-status guard on pr_pass + reviewer prompt update

* [a1bde3b9] docs(pr-gate-review, worksession-git): document CI-status guard on pr_pass

Updated two architecture documentation files to reflect the new CI-status guard:

**pr-gate-review.md:**
- Documented _ci_status_guard method: blocks pr_pass on failing/pending/unscheduled/error CI with reviewer-aware pr_fail remediation
- Documented _resolve_ci_status: best-effort GitHub check-runs lookup with fail-open behavior
- Updated _pr_pass_blocked description: now returns (rejection_envelope, ci_note) tuple
- Updated _record_gate_verdict_for/verdict to note ci_status field stamping on pr_pass
- Added ci_note parameter documentation for evidence tracking when no CI is configured
- Updated Logical Tree to show new methods
- Added Config Flags note: CI guard is always armed, fails open on config gaps
- Added two regression risks: check-runs-only limitation, fail-open design

**worksession-git.md:**
- Documented GitService.get_pr_ci_status(project_slug, pr_number): CI status lookup with state classification
- Documented supporting methods: _ci_status_prereqs, _fetch_check_runs, _classify_check_runs, _classify_zero_check_runs
- Each method notes its fail-open behavior and configuration gap handling

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [e8f275d7] test(gateway): lock the 7-AC-to-test map + assert pr_reviewer prompt content (#425) (#426)

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

* [24b4237e] Fix reflow-check, CI-status classification, and noqa suppression (#440) (#443)

* [24b4237e] fix(gateway): classify unreachable/nonexistent CI-status repo as no_ci_configured, remove test noqa, reflow pr_reviewer.md

Split GitService.get_pr_ci_status's PR-head-sha lookup into a dedicated
helper so a config gap (missing project/git_url/token) or an unreachable/
nonexistent repo/PR (network error or 404) classifies as no_ci_configured
(pr_pass passes through and stamps the evidence note) while a genuine
GitHub API failure on a real, reachable repo (any other non-2xx, or an
unparseable body) stays the fail-closed error state. Replaced the
`# noqa: PLR2004` in test_git_pr_ci_status.py with a named HTTP-status
range constant, updated the config-gap tests to assert the new
classification, and added tests for the unreachable-repo and real-repo-
API-error branches. Reflowed agents/prompts/roles/pr_reviewer.md's one
hard-wrapped continuation line so it passes make reflow-check.

* [24b4237e] docs(gateway): update pr-gate-review.md for CI-status classification refactor

Updated the internal architectural map to reflect the new CI-status classification
scheme introduced in PR #440. Configuration gaps (missing project/git_url/token) and
unreachable/nonexistent repos (404 or network error) now explicitly classify as
no_ci_configured and pass through with evidence stamps. Genuine GitHub API failures
on reachable repos classify as error and stay fail-closed (retryable).

- Clarified _ci_status_guard behavior: config gaps/unreachable repos pass through
  with distinct classification; only real API failures stay fail-closed
- Updated Config Flags section to describe the new three-way classification
- Updated Regression Risks section to document the new explicit classification scheme
- Noted that _resolve_ci_status now wraps git.get_pr_ci_status and interprets its result dict

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [1f6a06a2] round-3 fixes: pr_gate back to xenon rank A; 404 means no CI, not error

Eight extracted helpers bring the module average from B(5.05) to A(4.04)
with every external contract untouched (170 gate tests byte-identical).
The CI-status guard now classifies a 404 on the check-runs or workflows
endpoints as no_ci_configured (pass-through with evidence note) —
a repo without Actions is not a transport failure — reserving the
fail-closed error state for network/5xx/auth failures, with pinning
tests for all four shapes. The e2e fake-GitHub router gains check-runs
and workflows routes so the scripted lifecycle exercises the guard's
green-CI success branch end to end.

* [1f6a06a2] merge master; align gate-diff-base tests with the tuple contract

The merged tree is the first integration of the CI-status guard with the
preferred-parent diff-base guard: _pr_pass_blocked now returns
(rejection, ci_note), so the diff-base tests unpack it instead of
asserting on a bare result. Both guards verified live in the merged
pr_gate (preferred_parent threading and _ci_status_guard present).

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 07:38:06 +02:00
7be725cc13 fix(security): disposition all 104 code-scanning + dependabot alerts (#375)
Fix the 4 real CodeQL path-injection alerts (open_conventions_pr trusted the
API-settable project.workspace_path with no containment) plus defense-in-depth
segment validation at the get_workspace_path chokepoint. Bump next 16.1.1->16.1.7
and transitive lockfile deps to clear 24 Dependabot alerts. Close the intake
subagent-ban gap: the Claude intake driver still carried the Task tool and the
prompter prompt told it to fan out research subagents, contradicting the
fleet-wide ban. The remaining 47 CodeQL + 29 Dependabot alerts are dismissed on
GitHub with per-alert justifications (guard patterns CodeQL can't model across
call hops; next 16.2.x blocked by the verified tab-hostage router regression).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-10 00:15:04 +02:00
47c927c598 feat(gateway): root-owned acceptance criteria via declare_coverage (#357)
The coverage gates had no vocabulary for criteria only the root itself
can satisfy (the supersede PR from feature/main_pm/*, closing the
contributor's PR): once a Main PM declared coverage for the legitimate
cell criteria, the idle gate demanded a cell for the impossible ones
too, so they got pushed into a cell task and the cell PM (correctly)
escalated. declare_coverage now accepts the PM's own task: self-declared
criteria count as claimed for the idle gate and satisfied for the
roll-up (the roll-up actor is their owner by construction), surface as
claimed_by=root in the briefing, and both PM prompts say to never hand
a cell a criterion it cannot satisfy inside its own cell.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-09 03:17:20 +02:00
94dd6b6d53 chore(prompts): decomposition minimalism — fewest subtasks the work needs (#348)
CEO doctrine after an axios bump became a planning parent plus two
sequenced same-branch code children: single-concern work is ONE subtask
covering change, verification, and PR; same-branch sequenced siblings
are one task wearing two ids (the gateway serializes sibling code
subtasks anyway, so the split buys zero parallelism); the i_will_plan
sub_tasks list is a checklist, not a delegation quota.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-09 02:25:32 +02:00
Renn F 22572fdee1 chore(prompts): reflow hard-wrapped prose to satisfy the markdown gate
A's vendored fable.md and C's design-bar prose went in hard-wrapped, which
fails make quality's reflow-check (the no-hard-wrap gate). Reflowed to one line
per paragraph via scripts/reflow_md.py. reflow-check now clean.
2026-07-04 07:36:58 +02:00
Renn F da17c49f2d feat(marketing): HoM feature-spotlight X drafts + brand-voice charter (v0.18.0 B)
The Head of Marketing now markets features, not just releases: a default-off
x_feature_spotlight loop periodically spawns the HoM to investigate what shipped
(CHANGELOG, feature flags, docs/map, KB) and draft ONE held marketing post via
propose_feature_spotlight, reviewed in the X post queue.

- New x_feature source (distinct from x_post, fixing panel mislabeling) + a
  panel Feature-spotlight branch.
- brand_voice column on company_goals (migration 061, single head) as the
  CEO-editable voice source, surfaced in Settings and injected into the HoM
  briefing; a VOICE GUIDE baseline in head-marketing.md.
- propose_feature_spotlight verb (HoM-only), mirroring propose_roadmap.

Gated by x_feature_spotlight_enabled (default off; flag-off dormancy proven).
Also fixed two real bugs found mid-build: company_goals API schemas dropped
brand_voice on GET/PUT; the live charter UI is goals-tab.tsx, not the unmounted
company-goals-card.tsx. Full suite green (2935); migration single-head verified.
2026-07-04 07:34:40 +02:00
Renn F 30289333da feat(prompts): FE/UXUI design bar from taste-skill (v0.18.0 workstream C)
Frontend + UX/UI agents now carry a distilled design-taste bar in their team
prompts: the three dials (variance/motion/density) with dense-product-UI
defaults, plus typography/hierarchy, spacing/layout, motion, and AI-tells-to-
avoid rules, scoped to respect a project's existing design system rather than
override it. compose_prompt is unchanged (pure team/role prompt content).
developer.md gets a pointer whose heading deliberately does NOT start
'## Design bar', preserving the backend-dev negative test. New
tests/unit/agents/test_design_bar_layer.py (5 tests green).

Distilled from Leonxlnx/taste-skill (MIT).
2026-07-04 06:05:06 +02:00
Renn F 925e00d483 chore(comms): purge dead say()/channel refs the first sweep missed
The #306 teardown finalize (7cb00611) grepped only `say(` / `channels()`
(paren-anchored) and missed bare mentions, docstrings, error strings, and
inline prompt templates baked into .py. This catches them:

- content_actions.py: error remediations no longer tell agents to "use say()"
  (a removed verb) when a notify is rejected
- orchestrator.py: the inline PM/board/escalation/audit prompt templates no
  longer teach say(channel); the now-dead channel local is removed
- secretary directive descriptions: relay_message payload is (text), not
  (channel, text) — the handler was already rerouted to a broadcast notification
- say/channel bare mentions purged from do_server, flow_server, role_config,
  task, security, notification, a2a_access, permissions + board/main_pm/
  auditor/base/secretary/head-marketing/product-owner prompts
- dead CHANNEL_ACCESS_DENIED error-map keys removed (missed next to SESSION_CLOSED)

Pending (dead-vs-live pass, not in this commit): panel stream.ts channel-perms
client (unimported), agents.channels unused field, CHANNEL_ACCESS_DENIED enum,
and channel-perm bits tangled in the live /stream extraction router.
2026-07-04 05:20:17 +02:00
Renn F 7cb00611e1 chore(comms): finalize #306 teardown — changelog + purge dangling refs
#306 removed the channels/sessions/messages subsystem but left dangling
references. Agents were still told to call removed verbs at spawn, and a
maintenance script referenced a dropped table.

- prompts (roles/identities/teams/base): drop say/open_session/link_session/
  channels() and the dead Channels sections; comms rows now teach A2A
  (dm + read_a2a); renumber the PM workflow steps the removed open_session
  step left behind
- scripts/reset_runtime_state.sql: drop the dropped chunks_conversations table
- models/events.py: mark the retired SESSION_*/MESSAGE_SENT enum members inert
- mcp/{do,flow}_server.py: drop the dead SESSION_CLOSED error-map key
- panel: drop channels_read/write from AgentPermissions; remove dead channel:
  KB source branch
- pyproject.toml: refresh a ruff-exemption example off the removed verb kwargs
- CHANGELOG: record #306 under [Unreleased]
2026-07-04 03:42:04 +02:00
607e13f3dd feat(secretary): wire task name→id search as a Secretary tool (#304)
The GET /secretary/tasks?q= route (name→id resolution) shipped in wave 1
but no tool called it, so the Secretary could read a task only by UUID —
yet the CEO always refers to tasks by name. This adds search_tasks to
both runtimes (Claude SDK build_secretary_options + the grok
roboco-secretary MCP server) over a shared _do_search_tasks helper, so
'the task about X' resolves to concrete ids the CEO can then act on via
read_task or a control_task directive.

_call_backend gains query-param support and now returns the decoded JSON
(object or list) so the search route's list response flows through;
_do_search_tasks wraps matches under 'tasks' and passes error envelopes
straight through. Persona + RAG role doc updated (the RAG doc had
explicitly flagged this gap). Tests cover the helper and both wrappers.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-03 20:07:28 +02:00
3ccc723cd4 v0.17.0 — Wave 3: sandbox DB, DB isolation, mobile UI, cloud auth, X account, roadmap engine (#303)
* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers

Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.

When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.

Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.

* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem

* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)

Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).

ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.

Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.

* feat(panel): full mobile responsiveness pass

Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).

Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.

Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).

* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)

ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.

Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.

Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.

Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.

* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)

ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.

The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.

XPostService.approve (CEO-only) is the sole caller of post_tweet.

Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.

* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)

ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).

The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.

Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).

Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).

* chore(release): 0.17.0

Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.

Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.

Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).

* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes

Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.

* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds

The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.

* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)

The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
  _list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
  MODULE-level _is_held_ceo_source (module, not method, so the
  wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)

Behavior-preserving; full suite green (11902), xenon clean.

* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)

The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.

* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)

Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.

New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.

* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes

Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
  token (header-trust/secure mode, cloud auth off) bounced the user to a
  login page whose backend route isn't mounted -> 404. Now it probes
  /auth/status (bare fetch, no interceptor re-entry) and only redirects
  when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
  surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
  token signed with the quotes included — which never verifies against
  the orchestrator (docker-compose/pydantic unquote the secret). Now
  strips surrounding single/double quotes.

* fix: git-log 500 on '|' in commit message; X queue shows an empty state

- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
  SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
  fixed '|' field delimiter let the subject's pipe shift the split so
  author+date collapsed into one field. Switched to \x1f (Unit
  Separator), which can't appear in commit content. Regression test with
  a piped subject.
- The X Post Queue returned null when empty, so there was no visible
  place for the X drafts. It now renders a discoverable empty state
  pointing at Settings -> X credentials.

* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)

Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
  (+ x_replies_enabled sub-flag), board roadmap engine — new RAG
  architecture pages + role/tool/config-reference updates; new symbols,
  migrations 057-059, panel surfaces, and the get_agent_context
  dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
  (search_past_tasks), Secretary edit access + PM-lighter scope, the
  PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
  routes are reachable by any authenticated agent — they carry a
  _require_ceo gate (wave 2c).

docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.

* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)

The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.

Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-03 19:24:00 +02:00
cfde4369b1 Token optimization levers — claim-scoped briefing, payload caps, role-scoped optimal, notification-spawn cooldown (#292)
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs

* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star

* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only

* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations

* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks

* feat(orchestrator): cross-tick cooldown for notification-triggered spawns

* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env

* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown

* test(mcp): type the mixed-item cap fixture explicitly

* fix(orchestrator): lazy-init the notification-spawn cooldown store

* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)

B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.

B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.

* fix(panel): stop scorecard fetches for fallback-roster placeholder ids

useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).

* Upgraded uv.lock

* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)

B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.

B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.

Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).

B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.

B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.

* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)

Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.

Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.

* style: ruff format for the orchestration sweep

* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate

_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 05:46:01 +02:00
15effce014 Chore: 141 Gaps fill-in (#283)
* Updated uv.lock

* Bunch of fixes we need to verify first..

* feat(megatask): per-cell project map root-subtasks (multi-project, multi-cell)

A MegaTask root-subtask can now target an ad-hoc per-cell project map — a
third targeting shape that mirrors the existing product fan-out root. In
RoboCo a project is per-cell (ProjectTable.assigned_cell); a monorepo is N
per-cell projects sharing one git_url. So 'multi-cell' IS 'multi-project',
and a task may mix per-cell projects across products or include OSS-library
projects not in any product.

Storage: migration 052 adds task_cell_projects (mirrors product_projects;
unique per (task, team)). TaskTable gains a cascade-delete cell_projects
relationship; TaskCreateRequest / TaskCreate / Task response carry the map.

Policy: batch.is_branchless_coordination + is_valid_batch_shape gain a
has_cell_projects param — a root-subtask targets exactly one of project /
product / cell-map; the umbrella still targets none. TaskService passes
has_cell_projects at every predicate call site and persists the rows in
create(). _ensure_branch_for_task cuts feature/main_pm/{root} per distinct
project in the map (via _distinct_projects_for_task); _require_target_or_umbrella
and _validate_batch_membership accept the map shape.

Fan-out: every distinct_project_ids site (task.py branch creation, routes
_project_for_complete + _resolve_project_for_merge, orchestrator
_ambient_projects_for_task, pr_review._project_slug_for, git._project_for_task)
generalizes to first-distinct-project-of-map-or-product. Choreographer
_resolve_subtask_project resolves a delegated subtask's cell from the parent's
cell map. The product-scoped _slugs_for_product intake helper is unchanged.

Intake: prompter._draft_cell_map extracts the per-cell map from the_work[].
_validate_batch_scope counts distinct projects across all drafts' cells
(>=2 min stays; one 2-cell draft satisfies it). create_task_from_draft
persists cell_projects for >=2-cell drafts (project_id/product_id None),
collapses a 1-cell map to the single-project shape, and leaves single-cell
top-level project_id drafts unchanged. _resolve_owning_team routes a
multi-cell map to Main PM (coordination root, like a product root — a cell
PM can't delegate cross-cell). propose_draft/propose_batch tool descriptions
declare the per-cell project_id (both Claude SDK + grok runtimes).

The umbrella stays branchless / pure-coordination / submit_root-rejected;
the CEO-escalation pr_number gate is not widened (the map root is
is_umbrella=False, mirroring a product root, so submit_root supplies it).
Single-cell root-subtasks and everything below them are byte-for-byte
unchanged. Un-run MegaTask waves (multi-cell drafts) become runnable.

* [feature] Panel per-cell project picker + pnpm format infra

MegaTask root-subtasks can fan out across cells (be+fe, fe+uxui). Since a
RoboCo project is per-cell (ProjectTable.assigned_cell), a monorepo is N
per-cell projects sharing one git_url — so multi-cell IS multi-project. The
batch-review card now shows one project Select per the_work entry, scoped to
that cell's repos, instead of one Select bound to a single top-level
project_id. confirmBatch validates each cell's project is in scope and the
batch still spans >=2 distinct projects.

- prompter.ts: CellWork gains optional project_id (the per-cell picker seam).
- batch-review-card.tsx: per-cell Selects (one per the_work entry), scoped to
  the cell's projects; legacy single-cell drafts keep the one-Select path.
- use-prompter.ts: updateBatchDraftProject edits per-cell (entryIndex);  confirmBatch validates every cell; batchFromEvent parses per-cell map.

Also adds the missing pnpm format infrastructure (the panel had no formatter
at all): prettier devDep + .prettierrc.json (default-style config: 80-col,
double-quote, semi, trailing-comma-all) + .prettierignore, plus format /
format:check scripts. Only the 3 changed files above were reformatted; the
~222 pre-existing non-compliant files are left untouched (a wholesale reformat
is a separate explicit decision, not bundled into this feature).

* [fix] MegaTask verification: migration 052 enum + async cell-map read

Two real bugs surfaced running the full gate against a containerized
Postgres (and the orchestrator boot log):

1. Migration 052 crashed a real orchestrator boot with
   'type "team" already exists'. The generic sa.Enum(create_type=False)
   does NOT set the postgres enum's create_type attribute, so op.create_table
   (checkfirst=False) emitted a redundant CREATE TYPE against the pre-existing
   team enum. Switched to postgresql.ENUM(create_type=False) — the postgres-
   native enum whose create_type _check_for_name_in_memos actually reads, so
   the CREATE TYPE is suppressed. Verified: 051->052 upgrade against a DB where
   the team enum pre-existed (the exact path that crashed) now succeeds;
   downgrade 052->051 drops the table and preserves the shared enum; fresh
   upgrade head clean. (Migration 016 has the same latent sa.Enum pattern but
   never re-runs in prod, so it's noted, not touched here.)

2. _ensure_branch_for_task read task.cell_projects (lazy=selectin to-many)
   directly, tripping MissingGreenlet on a freshly-created/unqueried task —
   which then poisoned the async session (PendingRollbackError). Replaced with
   _task_has_cell_map: peeks InstanceState.unloaded (no IO) and reads the
   already-loaded map, falling back to an awaited count query only when the
   relationship is genuinely unloaded. Non-ORM stubs route to the plain
   attribute. Fixes 2 integration tests; the 6 cell-map unit tests still pass.

Also: typed the self stub as Any in test_choreographer_subtask_project
(mypy tests/ wants Choreographer, not SimpleNamespace) — the codebase idiom.

Gate: ruff format/check clean; mypy roboco/ + tests/ clean; full pytest
10371 passed / 388 skipped against containerized pgvector:pg16; vulture clean.
Pre-existing xenon C-rank on reassign (from prior commit 19a474d3, not this
feature) still blocks make quality — surfaced separately.

* [refactor] Extract reassign board-advisory diversion helper (C→B complexity)

`reassign` in roboco/services/task.py hit xenon absolute complexity 11 (a
C-rank block), failing `make quality`'s --max-absolute B gate. The C-rank
originated in 19a474d3 (pre-existing, not this feature branch's work).

Extract the board/advisory → cell-task diversion into
`_maybe_divert_board_advisory_reassign` (complexity 4, A). reassign drops to
9 (B); behavior is byte-for-byte preserved — the helper runs the same
guard + pool diversion + log, returning the diverted task or None so the
caller falls through to the normal handoff. Whole-repo xenon exits 0; the 159
reassign / board-guard tests pass.

Unblocks `make quality` on feature/metrics-granularity.

* [fix] migration 016: postgresql.ENUM(create_type=False) for reused team enum

016_add_products_and_task_product_id used `sa.Enum(..., create_type=False)`
for the reused Postgres "team" enum — the same latent defect that crashed
052 on a real orchestrator boot. On the generic `sa.Enum` the
`create_type` kwarg is silently dropped, so `_check_for_name_in_memos`
never sees it and `op.create_table` (checkfirst=False) emits a redundant
`CREATE TYPE team` that fails with "type 'team' already exists" against a
DB where the enum pre-exists.

Switch to the postgres-native `postgresql.ENUM(..., create_type=False)` —
its `create_type` is a real attribute the guard reads, so the CREATE TYPE
is suppressed (and DROP TYPE on downgrade too). The member list is inert
under create_type=False (it never creates/alters the type), so it stays at
016's original six, reflecting the enum as it stood then, not the
later-widened set.

This never crashed in prod because 016 is never re-run (alembic_version is
past it), but it's the same defect class. Verified on the real boot path:
upgrade to 015 in process A (team enum created by 001), then `upgrade head`
in a fresh process B — 016 applied clean, no DuplicateObjectError; downgrade
016->015 clean, shared team enum preserved.

See project_migration_enum_create_type_gotcha.

* [chore] panel: prettier reformat across the codebase

Apply `pnpm format` (prettier 3.8.5, 80-col / double-quote / semi /
trailing-comma-all) to the 223 pre-existing panel files that predated the
prettier infra added in cb5365a4. Pure formatting — no semantic changes:
multi-line arrays/objects collapsed where they fit, trailing newlines added
(.prettierrc.json), import grouping unchanged.

Verified: `pnpm format:check` clean, `pnpm lint` clean, `pnpm typecheck`
clean, `pnpm test` 113/113 pass (7 files).

* Bunch of runtime fixes for MegaTask and other issues

* Fix different project same PR number collision problem

Fix (two layers):
1. Root cause — pr_merge and rebase_pr_for_task now take a required project_id and scope the lookup where(pr_number == X AND project_id == Y). Required so no caller can forget — the bug class can't recur. All 4 call sites updated (choreographer cell_pm_complete, the rebase-retry, the superseded close_pull_request now passes project_id, and _verb_runner._do_pr_merge).
2. Crash guard — _finalize_cell_complete None-checks the complete() return and returns a clean invalid_state envelope (with a remediate hint) instead of dereffing None → 500 → respawn loop.

* Fix: Make main_pm + task_type=code impossible

* Fix Main PM needs revision can't re delegate

* [chore] Bump local LLM glm-5→glm-5.2 + swap Ollama fleet defaults off minimax

- llm_catalog: OLLAMA_DEFAULT_MODEL minimax-m3:cloud → kimi-k2.7-code:cloud;
  role defaults kimi-k2.6→kimi-k2.7-code, developer minimax→kimi, product_owner/
  ceo kimi→glm-5.2, documenter glm→kimi; GLM 5.1→5.2 comment fix.
- config + .env.example + docker-compose{.yml,.yaml,.registry.yml} + docs +
  memory_distiller + optimal_brain: glm-5:cloud → glm-5.2:cloud.
- panel ai-routing-card: typed SelfHostedModel/boolean annotations; drop the
  stale "Minimax M3 default" string (default is now catalog-driven).
- tests: glm-5:cloud → glm-5.2:cloud in pricing + rate-limit-retry fixtures.

* [fix] submit_root: hard unchanged-PR gate stops the pr_fail re-submit loop

The 2026-06-27 infinite pr_fail loop: a Main-PM root (PR #139) was pr_fail'd,
routed to needs_revision, and re-submitted byte-identical → awaiting_pr_review
→ pr_fail again, forever. The prior hint/a2a steer was ignored by the weak
coordinator model — hints don't stop a model that won't read them. A HARD gate
refuses the re-submit when the assembled root PR's head SHA is unchanged since
the last pr_fail (no new cell work → identical diff); a different SHA ⇒ the
branch advanced ⇒ allow. Every ambiguous case fails open (no prior fail, no
recorded SHA, no pr_number, unresolvable slug, git error, closed PR) — only the
exact-unchanged case is hard-blocked.

- content/models: PrReviewContent.head_sha (optional; JSON col → no migration).
- git: get_pr_head_sha (GitHub pulls API; None on any failure → fail-open).
- pr_gate: pr_fail captures head_sha into the verdict record; pr_pass does not.
- _impl: submit_root runs _submit_root_unchanged_pr_guard after _submit_up_guard;
  _current_root_pr_head_sha resolves slug + current SHA (fail-open).
- pr_review: extract module-level resolve_task_project_slug, shared by the mixin
  and the gate helper (_LegacyChoreographer reaches it via cast to the
  ChoreographerHelpers typed view — it doesn't inherit the helpers mixin).
- tests: test_submit_root_unchanged_pr_guard (11 — refuse/allow/6 fail-open/3
  capture-side, mypy-clean via cc:Any spy idiom, zero type:ignore) +
  test_pr_gate_notifies_pm capture-path stub.

* [chore] mypy tests/: clear all 15 pre-existing type errors so make quality can go green

The branch tip had 15 mypy tests/ errors in files this bundle did not author,
which blocked CI's make quality mypy step (mypy roboco/ tests/) regardless of
the bundle's own commits. Pre-existing is still existing — fix every one:

- test_schemas_v1_flow.py (8): the StrList coercion tests intentionally pass
  SDK-nested list-of-strings input ([[['...']]], {'item':{'$text':'...'}}, int,
  dict). Annotate those literals as list[Any] locals so mypy accepts the
  coerce-able shape; the StrList BeforeValidator still flattens to list[str] at
  runtime. No type:ignore.
- test_pr_gate_records_verdict.py (3): notes_structured is dict|None; narrow
  with 'assert t.notes_structured is not None' before indexing (the existing
  pattern at line 90).
- test_pr_review_hand_format_guard.py (1 site, 2 errors): the _verb_runner()
  spy assertion — use the cc: Any = c alias idiom so assert_not_awaited
  resolves; drops the now-unused type:ignore[union-attr].
- test_pr_gate_notifies_pm.py (1): drop the unused type:ignore[method-assign]
  on the a2a.send reassignment.
- test_content_models.py (1): narrow coerced with isinstance(coerced,
  PrReviewContent) before reading .issues (the base _Content lacks the field).

Gates: rm -rf .mypy_cache && mypy roboco/ tests/ = Success (855 files);
ruff check + format clean; 5 affected suites = 40 passed.

* [fix] fail_qa routes needs_revision back to the dev, never the pool

A dev task in needs_revision must go back to the developer, never the
pool. The pool path let a cell PM re-claim the revision (PMs can claim
needs_revision) — the live 2026-06-27 'needs revision on a dev task sent to
the cell PM' bug.

fail_qa's original_developer marker is the fast path, but it is
unreliable in practice (live observation: never persisted), so the
unassign else-branch was the load-bearing path and it dropped the task
into the pool. Add a work-session fallback (_resolve_revision_dev) that
resolves the developer who actually worked the task — the most recent
work session whose agent is a developer, the QA's own session excluded
— and reassigns to that dev instead of unassigning. Only unassign when
no developer ever touched the task. Self-heals the marker so a
subsequent re-fail takes the fast path and the QA-review index
attributes the work correctly.

* [feature] delegate carries dev-task collision surface (sequencing S1)

The cell/main PM's delegate verb now carries the dev-task collision
surface (intends_to_touch / adds_migration / touches_shared) and an
explicit depends_on override through DelegateRequest -> DelegateInputs
-> _create_subtask_from_inputs -> create_subtask, and create_subtask
forwards sequence / dependency_ids / batch_id / surfaces into the
prepared TaskCreateRequest instead of dropping them (the base create
already persists them at task.py:878-884).

This is the plumbing for the multi-level sequencing model edge kind 3
(dev-task collision DAG). Previously a dev task delegated with a
collision surface or an explicit dependency lost it before persistence
— dependency_ids was always [], so the only dev-task ordering was the
weak assignee-keyed spawn barrier (the live 2026-06-27 out-of-order
break: 40842957 started before 9b3682b8's PR merged). Phase S2 runs
SequencingService over the surfaced siblings and wires the DAG via
add_dependency.

* [feature] wire dev-task collision DAG at cell-PM delegation (sequencing S2)

Pure dev_task_collision_edges in sequencing.py turns a parent's surfaced
siblings into (depends_on_id, task_id) pairs via SequencingService. TaskService.
wire_sibling_collision_dag wires them through add_dependency (idempotent). The
choreographer calls it after each dev-task delegate so the sibling collision DAG
is built incrementally as the cell PM decomposes — file-overlap serializes,
migration chains, shared-last; stable (priority, sequence) ordering keeps edges
from flipping into reverse cycles on re-runs.

* [feature] wire cell-task wave chain + by-osmosis edge (sequencing S3)

Kind 2 (cell-task wave chain): a new cell-task under root-subtask UT_n
depends on every cell-task under every root-subtask in UT_n.dependency_ids
(the kind-1 wave-chain edges), so its branch carries the previous wave's
merged cell work. Re-derived from the root-subtask's deps, not the cell-task's
own dependency_ids (which also carry UX/product-fanout edges the by-osmosis
edge must not pick up). A root may fan to several cell-tasks (different cells),
so the previous wave's cell-task is a SET.

Kind 4 (by-osmosis): the first dev task (sequence 0) under a cell-task depends
on each predecessor cell-task's tail (max-sequence) dev task, so the new wave's
first branch carries the previous wave's fully-merged tail. Subsequent dev
tasks inherit the tail via kind 3 or the merged base.

Both wired from _create_subtask_from_inputs, dispatched on parent.team
(MAIN_PM -> kind 2; cell team -> kind 4). Pure helpers
(cell_task_wave_chain_depends_on, by_osmosis_tail_dev_tasks) unit-tested in
test_sequencing.py; TaskService methods integration-tested. Idempotent +
best-effort throughout (add_dependency dedupes; missing predecessors are
no-ops). Also fixes a latent mypy-tests gap (estimated_complexity required on
direct TaskCreateRequest calls in the S2 tests).

* [feature] sync_branch dev verb — gate-level branch rebase (Phase B1)

Raw shell git is denied to agents (Bash(git:*) base deny), so a developer
whose branch fell behind its base had no gate-level rebase — only the
CEO/PM-only /rebase HTTP route. sync_branch is the dev verb that wraps the
rebase through the gate (traced + evidenced), so the 'everything goes through
the gates' invariant holds.

- lifecycle: IntentSpec sync_branch (dev-only, ownership-gated, composes=(),
  git-only — no DB transition); _next_hint_synced helper.
- GitService.sync_task_branch: rebase task.branch_name onto its resolved base
  via rebase_onto_base (fetch + rebase + force-with-lease push).
- Choreographer.sync_branch + _sync_branch_preflight_rejection: not_found /
  unknown-role / spec-gate / no-branch / protected-base guards, then the git
  op; conflicts abort (no force-push) and steer to resolve-by-hand; git failure
  steers to i_am_blocked.
- HTTP route /api/v1/flow/developer/sync_branch + SyncBranchRequest schema.
- MCP tool sync_branch(task_id) + _TOOLS registration (manifest auto-propagates
  via intents_for_role(Role.DEVELOPER)).

Tests: intent spec (5), choreographer handler (8: happy/conflicts/not_found/
not_authorized/no-branch/protected-base/git-failure/audit), route (1), MCP (1).
ruff + mypy roboco/ tests/ clean; unit suite green (DB-fixture errors env-only).

* [feature] i_am_done behind-base submit gate (Phase B2)

A sibling's PR merging into the parent branch while a dev worked leaves the
dev's branch behind its base — the assembled PR then can't merge cleanly and
the sibling's changes go missing (the 2026-06-27 out-of-order dev-task break).
The behind-base gate refuses i_am_done in that state and steers the dev to
sync_branch (the Phase B1 gate-level rebase verb).

- GitService.is_behind_base: rev-list --left-right --count across
  origin/{base}...origin/{head} → (behind, ahead); fetch-first so origin
  reflects the pushed head. Raises on git failure (consistent with
  rebase_onto_base); malformed stdout degrades to (0,0).
- Choreographer._behind_base_gate: wired into _i_am_done_gate after
  _ensure_branch_pushed. behind>0 → invalid_state remediate→sync_branch.
  Fail-open on git/base-resolution error (flaky fetch can't strand a task at
  the submit gate — the merge layer has its own behind checks). Skipped for
  branchless roots and protected bases (master/main/-prefixed).

Tests: gate (6: refuse+steer/up-to-date/branchless/protected/fail-open-base/
fail-open-git), is_behind_base (6: parse/up-to-date/malformed/argv-form/
requires-branch/missing-project). ruff + mypy roboco/ tests/ clean; unit green.

* [docs] sync_branch prompt + behind-base guidance (Phase B3)

Update every behind-base/rebase guidance surface to reflect the B1
sync_branch dev verb + B2 i_am_done behind-base gate: devs now self-rebase
through the gate instead of escalating a plain behind-base condition; PMs
still escalate cell/root integration branches (they have no rebase verb).

- developer.md: sync_branch in the verb table; 'When your branch is behind
  its base' rewritten — call sync_branch, do NOT i_am_blocked a plain
  behind-base; conflicts → resolve by hand, commit, sync_branch again.
- cell_pm.md: delegate signature gains intends_to_touch/adds_migration/
  touches_shared/depends_on + a 'Collision surface' section (fill it on every
  code subtask so sibling dev tasks that touch the same files sequence into a
  conflict-free order — the 2026-06-27 out-of-order break fix); behind-base
  section steers devs to sync_branch, PMs escalate only the integration branch.
- main_pm.md: behind-base section — dev leaf = dev's sync_branch; cell/root
  integration branch = escalate_up.
- RAG git-errors.md / blocked-tools.md: devs sync_branch, PMs escalate.
- docs/troubleshooting/common-issues.md: leaf self-rebases; integration branch
  still escalates to operator.
- CLAUDE.md verb surface: developer gains sync_branch.
- agents/prompts/_generated/*: regenerated via scripts/regenerate_verb_tables.py
  — adds sync_branch to the dev table AND catches the generated tables up to
  the S1/S2 delegate sequencing params + meltdown-fix note top-level params
  (the derived files had drifted stale vs the already-committed schemas).

Docs/prompts only — no code. ruff + mypy roboco/ tests/ clean.

* [chore] orchestrator: refuse to spawn human-only roles (CEO/prompter/secretary)

A live 2026-06-27 incident saw a CEO agent container spawned. Root cause:
_dispatch_a2a_work iterates every A2A/notification target and spawns it
with no human-role filter, and _is_agent_active('ceo') is always false
(the CEO is never a container), so the 'skip if active' check could never
protect the CEO. Any CEO-addressed notification (board handoff, escalation)
launched a CEO container — the system acting as the human CEO: a trust
violation. The CEO is the human operator; intake (prompter) and secretary
are human-driven chats launched through their own dedicated guarded paths
(_spawn_intake_container / _spawn_secretary_container), never spawn_agent.

Fix: a single chokepoint guard at the top of spawn_agent refuses
Role.CEO / PROMPTER / SECRETARY (raises AgentReadinessError + logs). This
structurally covers every dispatcher present and future, since they all go
through spawn_agent. Plus a defense-in-depth skip in _dispatch_a2a_work so
a human-role target never even calls in (avoids error-log spam; the
notification stays for the human to read in the panel).

Safe: the dedicated human-spawn paths do not route through spawn_agent.
Regression tests: spawn_agent refuses ceo/intake-1/secretary-1, does NOT
refuse a real agent; _dispatch_a2a_work skips CEO/intake/secretary targets
and still spawns real-agent + mixed-target cases.

* [chore] orchestrator: skip human-only assignees in claimed/pm-review dispatchers

Defense-in-depth for the spawn_agent human-role chokepoint (d31d6719).
The chokepoint structurally guarantees no CEO/prompter/secretary container
can ever spawn — every dispatcher goes through spawn_agent. But two
dispatchers resolve an arbitrary assigned_to and spawn it with only a
None/unknown-role filter, so a human-assigned task would reach the
chokepoint and RAISE: caught by the per-dispatcher try/except, but it
aborts that dispatcher's whole tick (stalling other respawns behind the
mis-assigned task) and error-logs every cycle. The other dispatchers are
already safe by whitelist/hardcoded slug (blocker_resolver_slug returns
None for non-PM/non-BOARD; escalation/approval use whitelists; marketing
and audit hardcode their non-human slug).

- _claimed_task_needs_agent: return None for a CEO/prompter/secretary
  assignee — no container to respawn, and do NOT release a human-owned
  task to pending (that would re-route it to a PM). Leave it for the human.
- _dispatch_pm_review_work (assigned branch): skip a human-only assignee
  so a CEO-assigned awaiting_pm_review task neither spawns nor aborts the
  dispatcher's tick.

Audited all target-iterating dispatchers; only these two lacked a filter.
Regression tests cover both skips.

* [F002] retype board-routed MegaTask root-subtasks code->planning on activation

_activate_batch_root_subtasks flipped a held root-subtask to team=MAIN_PM
but left task_type=code (intake only coerces main_pm-team drafts, so a
board-routed code root reached activation still code-typed). The
main_pm+code combo re-introduces the 2026-06-27 meltdown. Mirror
approve_and_start's own retype via main_pm_cannot_own_code so the
activated child is a planning-typed coordination root.

TDD: RED test_activate_batch_root_subtasks_retypes_code_to_planning
watched fail (task_type stayed CODE), then GREEN after the retype.
ruff+mypy clean; 125 batch/umbrella/approve tests green, no regressions.

* [F003,F004,F014] enforce HMAC agent-token gate on do routes + WebSocket streams

F003/F014: /api/v1/do/* only required X-Agent-ID (UUID) — no token check,
unlike the flow routers' role guards. A forged X-Agent-ID passed. Added
require_any_authenticated_agent (token-only; do router serves all roles)
and applied it as a router-level dependency. Binds X-Agent-ID to a verified
HMAC token when ROBOCO_AGENT_AUTH_REQUIRED=true; rejects a forged token
even in dev mode.

F004: /ws/* per-agent streams (channels/agents/sessions/notifications)
never read the nginx-injected X-Agent-Token, so in strict mode an agent on
the Docker network could subscribe to another agent's notifications with
no auth. Added _require_panel_token verifying the CEO panel token against
the CEO identity; wired into all four per-agent streams (system stream
stays operator-only per its docstring). Same strict/dev contract.

TDD: RED tests watched fail (no gate -> 200/accept), then GREEN. ruff+mypy
clean; 399 api/mcp + 29 WS tests green, no regressions.

* [F005,F006] grok auth: directory mount + atomic-write fallback

F005: the single-file bind mount of auth.json pinned the inode, so the
orchestrator's atomic refresh (tmp+rename within ~/.grok) never reached a
running grok container — a long-lived container hung at the login prompt
when the original ~6h token expired. Mount the host ~/.grok DIRECTORY (ro)
at /home/agent/.grok-auth-ro; the entrypoint symlinks ~/.grok/auth.json at
that RO mount so grok + the --check backstop read the live credential (the
directory mount sees the host-side rename) while grok's writable state
(config.toml, sessions/) stays in the image's ~/.grok.

F006: a rotated refresh_token is single-use — xAI invalidates the old one
the instant it issues the new one. If the atomic write failed after the
rotation, the file kept the now-dead old refresh_token and the credential
was permanently lost on the next refresh. _atomic_write now falls back to a
direct write when tmp+replace fails, so the rotated token always lands on
disk (losing the write is catastrophic; losing atomicity is not).

TDD: RED tests watched fail, then GREEN. ruff+mypy clean; 32 grok tests
green, no regressions.

* [F016,F017] choreographer: surface invalid_state instead of None.status 500 on submit_root / i_am_blocked

Both verbs compose a single atomic action whose None return (the verb's
own result) flowed out of run_intent and was dereferenced as t.status,
HTTP 500-ing with no actionable rejection:

- F016 submit_root: submit_for_review returns None when the root->master
  PR was already opened / the task raced out of in_progress. Post-runner
  None-guard extracted into _submit_root_finalize -> invalid_state
  (re-fetch; if awaiting_pr_review the PR is open, wait for reviewer;
  else re-delegate fixes and retry) instead of None.status.

- F017 i_am_blocked: escalate returns None in four cases (no task, no
  agent, no resolvable escalation-target slug, no target agent row) e.g.
  a developer whose role has no PM above it. _run_i_am_blocked_intent
  now guards updated is None -> (t, invalid_state rejection) with
  remediation (re-fetch + escalate to CEO directly / retry) instead of
  the caller deref'ing None.status -> 500 + respawn-loop.

TDD red->green; ruff + mypy clean; gateway suite green (58 passed).

* [F007] choreographer: cell-level unchanged-PR re-submit loop-stopper for submit_up

The root loop-stopper (F016) was root-only; a weak cell PM could re-submit
the unchanged cell->root PR after a pr_fail and loop awaiting_pr_review ->
pr_fail forever (the cell analogue of the 2026-06-27 root loop).

pr_fail stamps the assembled PR's head SHA into notes_structured.pr_review
.head_sha for cell AND root gate tasks alike (the capture is gate-verb-
level, not root-level), so the same structural refusal applies to submit_up:
if the cell PR's current head SHA equals the SHA the last pr_fail recorded,
no new dev work landed on the cell branch -> the diff is byte-identical ->
refuse, do not re-open the gate. Different SHA -> branch advanced -> allow.

- _submit_up_unchanged_pr_guard mirrors _submit_root_unchanged_pr_guard
  (cell-PM remediation: re-delegate to the dev + wait for re-assembly),
  wired into submit_up after _submit_up_guard passes.
- Renamed shared _current_root_pr_head_sha -> _current_pr_head_sha (both
  guards use it; the lookup was never root-specific).
- Every ambiguous case FAILS OPEN (no prior fail, no recorded sha, no
  pr_number, no resolvable project, git/closed-PR None) — only the exact-
  unchanged case is hard-blocked.

TDD red->green; ruff + mypy clean; F007+F016 guard suites green (15 passed).

* [F008] evidence_builder: surface persisted pr_review verdict+issues in the PM task_handoff

The pr_fail a2a steer to the owning PM is fire-and-forget; a PM respawned
into needs_revision later read none of it (build_task_handoff never looked
at notes_structured), saw a generic 'needs revision' with zero concrete
change-requests, and re-submitted the same PR (the 2026-06-27 infinite
pr_fail loop on 9980d0a0 / PR #138). The signal-gap was only partially
closed by the a2a.

build_task_handoff now extracts notes_structured.pr_review
(verdict/summary/issues/head_sha — the slot pr_fail authors on every fail)
into a pr_review field on the handoff, so every PM briefing for the task
carries the concrete change-requests. A prior pr_fail alone now counts as
prior-work-worth-resuming. Type-guarded + capped; absent => no key (no
misleading empty slot).

TDD red->green; ruff + mypy clean; evidence_builder suite green (14 passed).

* [F009] notification: derive requires_ack from ACK_REQUIRED_BY_TYPE, not the True default

NotificationService._create_notification built NotificationTable without
requires_ack, so the column default (True) applied to EVERY notification -
including informational REVIEW_REQUEST / DOCUMENTATION_REQUEST /
A2A_REQUEST / KNOWLEDGE_SHARE (ACK_REQUIRED_BY_TYPE -> False) and every
@mention from MessagingService._notify_mentions. Each false ack-required
inflated the recipient's unacked set and soft-blocked i_am_idle into
respawn churn.

- _create_notification: requires_ack=ACK_REQUIRED_BY_TYPE.get(type, True)
  (unmapped types default True - preserve the action-required bias).
- _notify_mentions: requires_ack=False explicit (MENTION is informational).

TDD red->green (identity is False/is True assertions - the mocked
flush doesn't apply SQLA's insert-time default, so pre-fix the attribute
was None); ruff + mypy clean; notification suite green (18 passed).

* [F010] notification: never dedup informational notifications (knowledge-share data loss)

The purpose-based dedup suppressed a same-purpose (same sender/type/task,
overlapping recipients) notification while a prior one was unacked. For
informational types (KNOWLEDGE_SHARE / MENTION / A2A_REQUEST / BROADCAST +
the pickup-proves-receipt triad) each send carries DISTINCT content (a new
learning, a new mention) and acking is voluntary, so a recipient who never
acks the prior one let the dedup permanently suppress every subsequent
same-sender broadcast - silent learning-broadcast data loss.

The dedup's anti-loop rationale (stop unacked-set inflation soft-blocking
i_am_idle) only holds for action-required signals. Gate the dedup on
ACK_REQUIRED_BY_TYPE.get(type, True): action-required types still dedup,
informational types always create. Unmapped types default True (dedup on).

TDD red->green; ruff + mypy clean; notification + dedup suites green (20).

* [F011] playbook: de-index rejected/archived playbooks from the PLAYBOOKS RAG index

* [F012] release_executor: fail-closed on git add/commit before push

* [F013] release_proposal: Redis SET NX mutex guards the ~40min execute against concurrent approves

* [F015] flow_qa/flow_doc: add i_am_blocked route (manifest-registered escape hatch was 404)

* [F018] claim_guards: treat blocked as active + broaden the guard lookup so a blocked dev can't double-claim

* [F019] git: clear orphaned .git/*.lock files after a timeout-SIGKILL'd mutation op

* [F031] identity: role_for_slug_or_none so defensive skip-guards don't crash the dispatcher tick on stale slugs

* [F032] test: unknown-assignee claim reaches release-to-pending path

F031's role_for_slug_or_none fix made the unknown-assignee release branch
in _dispatch_claimed_without_agent reachable (the human-only guard no
longer raises/short-circuits on a stale slug). Lock that reachability in:
a claimed task with an unknown-assignee UUID past grace returns the slug
(not None) so get_agent_role -> 'unknown' releases the claim to pending
for a role-matched reclaim.

* [F033] orchestrator: capture container_id at startup re-adoption

_readopt_running_agents registered re-adopted ACTIVE instances with
container_id=None. _check_health skips container_id-is-None instances, so
when a re-adopted container later exited the stopped-container handler
never ran and the task stranded under a phantom ACTIVE instance forever.

Add _resolve_container_id (docker inspect -f '{{.Id}}') and store the real
id on re-adopt. Best-effort: a probe failure degrades to None (still
ACTIVE; the reaper's Docker-liveness fallback covers it).

* [F034] orchestrator: re-stamp respawn last_check at restore

_pm_made_rule_following_retry bounds its tracing_gap audit lookup with
since = record.get('last_check'). A stale persisted last_check from before
the restart matched pre-restart tracing_gap rows, falsely resetting the
breaker on the very first post-restart spawn — exactly when a fresh strike
count should be evaluating current state.

_partition_respawn_rows now re-stamps last_check to the restore time on
every restorable entry, bounding the lookup to post-restart gaps only.

* [F035] orchestrator: probe-resume loop actually revives parked agents

_park_provider_unavailable parked the provider + offlined the instance but
never registered a WaitingRecord, so _on_probe_success -> _parked_agents_for
(always filtered on waiting_for=='rate_limit_lifted') returned [] and
resolve_wait revived nobody — recovery fell to the 600s stale-claim reaper
instead of the probe-success path the parking design relied on.

Register + persist a rate_limit_lifted WaitingRecord at park time (mirrors
mark_waiting_long, minus stop_agent — the container is already dead).

Companion reaper guard: _reap_with_service now skips provider-parked
assignees (_assignee_is_provider_parked) so the claim survives until the
probe revives the agent — otherwise the reaper releases the claim to pending
and probe-success respawns on a task the agent no longer owns.

* [F036] orchestrator: read transcript for overload detection too

The SDK server writes model-API errors (529/500/503) to /tmp/sdk-server.log,
not stdout, so an overload marker can appear only in the durable Claude
transcript — the same rationale already applied to the session-limit
detector. _provider_overload_park_target read only docker logs, so an
overload was missed and the agent crash-respawned straight back into it.

Now concatenates the transcript tail before matching, mirroring the
rate-limit path.

* [F037] orchestrator: drop bare error-NNN overload markers

The bare 'error 529'/'error 500'/'error 503' markers were broad enough to
false-match an agent that merely writes about an HTTP status code in its own
notes ('the endpoint returned error 500, retrying'), parking the whole
Anthropic fleet on a non-issue.

The SDK error formatter emits 'API Error: NNN' + a JSON error type, so the
remaining 'api error: 529/500/503' + 'overloaded_error' +
'internal_server_error' markers cover every real overload without that
false-match surface.

* [F038/F039] orchestrator: sign X-Agent-Token on self-API calls

The prior self-PATCH 401 fix only carried X-Agent-ID/X-Agent-Role. Arming
ROBOCO_AGENT_AUTH_REQUIRED=true made the middleware require a signed
X-Agent-Token, so every orchestrator self-call (auto-block / auto-resume /
auto-recover / SLA annotation) 401'd and silently no-op'd — wedging
paused/blocked parents.

Add _system_api_headers() that wraps the base headers with a signed token
for the system identity (issue_agent_token); switch all six self-call sites.
Dev fallback: no secret set => UNSIGNED sentinel + auth not required.

* [F040] orchestrator: finalize grok spawn session on cost-cap kill

_enforce_grok_cost_budget killed + evicted the container without calling
_finalize_spawn_session, so the open agent_spawn_sessions row stayed open
(ended_at IS NULL) and the burned usage/cost was never recorded in the
dashboard.

Call _finalize_spawn_session(exit_reason='cost_cap') BEFORE popping the
instance — it reads self._instances[agent_id] for the model +
usage_session_id, which the pop would lose.

* [F041] park grok exit-78 (auth missing/expired) instead of crash-retrying

A one-shot grok container whose entrypoint ran grok_auth --check and found
the token missing/expired exits 78 (EX_CONFIG). Crash-retrying 3x burns
tokens for zero progress — the agent cannot start without a valid token.
Park the provider with kind=auth_missing (same shape as the 429 exit-75
path) so the probe-resume loop revives the task once grok_auth.refresh_if_stale
mints a fresh token; if still expired, the next exit 78 re-parks (no burn).

Also fixes a latent F035 regression: _park_provider_unavailable now registers
a WaitingRecord, so the bare-__new__ rate-limit park test had to set
_waiting_records + stub _persist_waiting_record (mirrors the overload-test
fixture).

* [F042] isolate concurrent-duplicate conventions cache put in a savepoint

Two task creates for the same project/HEAD can race to populate the
conventions cache; the loser's INSERT fails the partial-unique index with
IntegrityError. A bare session.add + flush poisons the shared session (the
task-create transaction rides the same session), so every subsequent op
raises 'this session is in error state' and task creation crashes.

Run the INSERT in a savepoint (begin_nested) and swallow the IntegrityError:
only the savepoint rolls back, the outer transaction stays usable, and the
winner's row satisfies the next _cache_get.

* [F043] guard escalate_up against resurrecting terminal tasks

escalate_up had composes=() and no source-status guard, so a PM could
escalate a COMPLETED/CANCELLED task and apply_escalation set it back to
BLOCKED — bypassing the state machine's terminal-state invariant.

Defense in depth:
- spec: add PRECONDITION_NON_TERMINAL to escalate_up's extra_preconditions so
  the lifecycle gate rejects terminal tasks (invalid_state) before the
  journal:decision write fires; generalize _check_intent_preconditions to
  honor non-tracing rejection_kind (not_authorized / invalid_state).
- service: apply_escalation (the single write primitive) returns False and
  refuses to mutate a terminal task — covers the HTTP escalate route which
  bypasses the spec gate. escalate() / escalate_up_to_role() return None on
  refusal so the gateway emits a clean invalid_state envelope.
- route: the HTTP escalate route 409s a terminal task BEFORE sending the
  escalation notification (so a finished task isn't yanked back, PM not pinged).

* [F044] pr_pass gate remediation points the reviewer at pr_fail, not i_am_blocked

The pr_pass gate runs the toolchain + conventions guards on the REVIEWER's
workspace, but their remediation text said 'call i_am_blocked' — a verb the
PR reviewer does not have. The reviewer would chase a verb they cannot call
instead of rejecting the PR.

Make the guards reviewer-aware: a reviewer=True flag (passed by _pr_pass_blocked)
switches the remediation to pr_fail(issues=[...]) — the reviewer's reject
lever, sending the PR back to needs_revision for the dev to fix the
environment / validator. The dev (i_am_done) path keeps i_am_blocked, which a
dev does have. _conventions_guard (the pr_pass path) now passes reviewer=True
through to _conventions_rejection.

* [F045] rate-limit: loud activate-failure log + in-memory orphan-probe fallback

The in-verb i_am_blocked(rate_limited) path wrapped RateLimitStateTracker.activate
in a bare contextlib.suppress. A silent activate failure stranded the fleet:
agents were parked in _waiting_records but the provider never entered the tracker,
so the tracker-driven _sweep_rate_limit_probes never probed it and no
_on_probe_success ever resumed them — parked agents stuck in WAITING_LONG.

Fix: (1) replace the bare suppress with a try/except that logs an error event
naming the provider + affected agents; (2) in _sweep_rate_limit_probes, after
probing the tracker-listed set, scan _waiting_records for any rate_limit_lifted
provider the loop did NOT cover and probe it via the time-expiry fallback (empty
state -> probe now) so _on_probe_success resumes the parked agents. The fallback
reads only local memory, so it still resumes when Redis was down at park time
(list_rate_limited_providers failure now falls through to the orphan scan instead
of returning early).

* [F046] pr_gate: guard None runner result on concurrent transition (pr_pass/pr_fail)

_gate_decision dereferenced the verb-runner result without a None guard.
run_intent returns None when a concurrent transition (cancel or a racing
reviewer) moves the task out of awaiting_pr_review between the precondition
gate and the runner's final composed action (the verb runner's documented
last-action source-status contract). The subsequent t.assigned_to /
t.status / _post_gate_review_to_pr(t, ...) dereferences then crashed the
gate with a 500 AttributeError. Add a None guard that surfaces a clean
invalid_state rejection (re-fetch + re-issue) before any dereference; no
PR post or a2a runs against a None task. TDD test_pr_gate_notifies_pm.py (+2).

* [F047] conventions: reviewer-aware block-finding remediation on pr_pass gate

The pr_pass (reviewer) conventions guard reused the dev-path block-finding
remediation: 'add a waiver to .roboco/conventions.yml in your branch'. A
pr_reviewer does not own the assembled cell->root / root->master branch and
has no commit verb on it, so the waiver remediation is unreachable — a false
positive stranded the gate with no self-recovery (the reviewer could neither
commit a waiver nor pr_pass). The fail-open content path is documented
precision-over-recall and stays as-is; the actionable gap is the remediation.

Fix: _conventions_rejection now branches the block-finding remediation on
reviewer=True (mirroring the could_not_run branch from F044). The reviewer
path points at pr_fail carrying the findings as issues so the PR returns to
needs_revision and the DEV fixes the violation or commits the waiver (the dev
CAN commit to the branch); waiver authorship is framed as the dev's action,
not the reviewer's. Dev i_am_done path wording unchanged. TDD
test_conventions_gate_pr_pass.py (+1).

* [F048] notify: reject human-only recipients (prompter/secretary) — no agent ack path

notify() only checked the SENDER role. The recipient was resolved by
NotificationService._resolve_recipients, which drops only unresolvable slugs
— it does not exclude human-only roles. The prompter (intake-1) and secretary
(secretary-1) are seeded agent rows, so they resolved, and an ack-required
ALERT addressed to them sat permanently unacked (no agent auto-acks it),
polluted the panel's pending-ack view, and — via the dedup query's
~acked_by.contains — permanently suppressed any later same-purpose
notification from the same sender to that human role. The knowledge-share
path already excludes all three human-only roles; the general notify path
did not.

Fix: a recipient-role guard in notify() via _reject_disallowed_recipient
(folds the new check into the existing CEO-dependency-block return slot so
notify stays under the PLR0911 return limit). Rejects prompter/secretary
with not_authorized; the CEO is human too but acks via the panel, so it stays
an allowed recipient (its only disallowed case, a dependency-block page, is
preserved). TDD test_notify.py (+3: reject prompter, reject secretary, allow
CEO).

* [F049] merge_pull_request: idempotent on already-merged PR (mirror _merge_with_retry)

* [F050] merge_pr_for_task: verify caller pr_number matches task's recorded PR

* [F051] open_conventions_pr: refuse dirty tree + verify checkout-base landed

* [F052] pr_target: scope task lookup by project_id (mirror close_pull_request)

* [F053] _token_for_project: log decryption failure (key rotation) with project slug

* [F054] learnings index: enforce shareable on every shared retrieval path (private-leak fix)

* [F055] messaging: recover from concurrent channel auto-create race via savepoint + re-fetch

* [F056] messaging: lock group row before session check-then-create to prevent active-session orphan race

* [F057] playbook: index/unindex as a post-commit step so the RAG corpus never leads the status transaction

* [F058] release-readiness: non-empty bump plan on first release

_canonical_bump_files derived the bump set from the previous
chore(release): commit. On the first release there is no such commit,
so it returned [] -> assess set version_bump_plan=[] -> the executor
published a tag with no files bumped (a no-op masquerading as X.Y.Z).

Fall back to the version-reference scan when no prior release commit
exists: the files currently embedding the version are exactly the set a
first release must bump, and the set the first release commit then
records as canonical for subsequent releases. Read-only derivation; the
CEO-approval gate and fail-closed executor are untouched.

* [F059] self-heal: hold fix tasks for CEO Approve-&-Start (restore dispatch gate)

The module docstring promised self-heal fix tasks 'wait for the CEO's
Approve-&-Start', but _originate created them confirmed_by_human=True and the
orchestrator dispatched them at once — a self-heal fix that re-broke CI would
trigger another cycle, open another auto-dispatched fix, and loop with no CEO
gate on dispatch.

Restore the documented gate:
* _originate opens the task confirmed_by_human=False (held for the CEO).
* The orchestrator holds a self-heal task out of both the PM and dev dispatch
  paths until confirmed_by_human flips True.
* approve_and_start (the CEO's start gate) sets confirmed_by_human=True so the
  held task finally dispatches (idempotent for board/intake tasks already True).
* list_pending_for_agent scopes the give_me_work hold to self-heal
  (source != self_heal OR confirmed_by_human) so an already-alive PM can't grab
  it pre-approval — while ordinary delegated subtasks (confirmed_by_human=False
  by default, where the delegation IS the authorization) still dispatch.

The 'never self-deploys' guarantee (no merge) is unchanged.

* [F059] fix DB-integration test auth + retype self-heal root code→planning

conftest test-DB defaults matched the project's own running postgres
(roboco/roboco @ localhost:15432, the docker-compose roboco-postgres
service with CREATEDB) instead of the OS user on localhost:5432 which has
no such role — every db_session test failed with InvalidPasswordError
instead of running.

Once the DB connection worked, the self-heal origination DB test went RED
with MAIN_PM_NO_CODE: the self-heal root was task_type=CODE owned by
main_pm, the combo the main_pm_cannot_own_code guard rejects. The Main PM
coordinates the fix (delegates the code work to a cell dev); it has no
code verb. Retyped CODE→PLANNING and rewrote description/AC to
coordination-level.

* [F060] emit reversal audit row on claim-branch-failure rollback

The forward task.claimed audit row is flushed before the branch-creation
attempt, and AuditService commits on its own connection, so the rollback's
flush reverts the task row but not that audit row — the journey's last
event stayed task.claimed while the task reverted to its pre-claim status,
diverging from real state and corrupting downstream cycle-time/bottleneck
metrics. The rollback now emits a CLAIMED->original reversal audit row
(only when the forward transition was made) attributed to the claimant.

* Removing completely unnecessary files (for the repo they are unnecessary)

* [F061] audit status-transition rows now written in-session (F061/F073/F075)

_emit_status_transition_audit now writes AuditLogTable rows into
self.session synchronously (session.add) instead of dispatching
AuditService.log_task_event fire-and-forget on its own connection.

The audit row now commits/rolls back atomically with the status
transition in the caller's transaction, closing three facets at once:
- F061: audit commit no longer decoupled from the transition commit
- F073: a committed transition can no longer have NO audit row
  (the row rides the same transaction; a swallowed persist can't drop it)
- F075: a transition rolled back inside a verb savepoint no longer
  leaves a phantom audit row (the row is in the savepoint too)

log_task_event is now called only from this helper (narrow blast
radius verified); revision_count increment stays at this single
chokepoint. Cycle-time/bottleneck reconstruction from task.<status>
events is no longer silently corruptible.

Tests: test_emit_status_transition_audit_writes_in_session_atomically,
test_finalize_claim_rollback_emits_reversal_audit, escalation-audit
tests retargeted to in-session AuditLogTable rows.

Also: _canonical_bump_files grep-looseness follow-on (F058) -- filter
by subject, not body; git log --grep matches any message line, so a
non-release commit whose body references chore(release): shadowed the
real release commit. Test
test_canonical_bump_files_ignores_body_only_chore_release_match.

* [F061] drop type:ignore from audit-emit tests

Convention: no type:ignore/noqa. The F061 in-session audit-emit
tests used '# type: ignore[assignment]' to assign a MagicMock to
AsyncSession.add, and the F060 test assigned to .flush the same way.

Rewritten to hold a local 'session: MagicMock' variable (mypy sees
its auto-children as MagicMock, so .add.side_effect / .flush assign
cleanly with no suppression). Verified via 'mypy tests/' that both
files are now type-clean (the F060/F061 commits had skipped tests/
in mypy, masking two method-assign errors).

* [chore] clear all 64 pre-existing mypy errors in tests/ (no type:ignore)

Convention: no type:ignore/noqa, and pre-existing violations still
violate. The make-quality gate runs 'mypy roboco/ tests/', but the
prior commits' gates only ran mypy on production files, masking 64
type errors across 15 test files (method-assign, unused-ignore,
no-untyped-def, attr-defined, union-attr, has-type, index, misc).

Fixed without any type:ignore:
- method-assign (svc.session.X = / svc.method = AsyncMock()): hold a
  local 'session: MagicMock'/'AsyncMock' and assert on it, or stub via
  object.__setattr__ / monkeypatch / a typed '_bind' helper returning
  Any, or alias 'cc: Any = c' (the pattern the file already used).
- unused 'type: ignore[assignment]' (real code was method-assign):
  removed; replaced with the no-suppression patterns above.
- 'Callable[...] has no attribute assert_*': keep a typed local ref to
  the AsyncMock and assert on the local, not the method-typed attr.
- no-untyped-def: annotate helper params (Any / pytest.MonkeyPatch).
- attr-defined / index / union-attr: type the helper as Any, narrow
  with an 'is not None' assert, or add the missing attr to a fake.
- has-type / return-value: fix the declared return type to the tuple
  the function actually returns.
- PLC0415 inline imports: hoisted to top-level.

test_pr_gate_notifies_pm._stub_gate_path converted fully to the
'cc: Any = c' alias (it already used it for one attr) so its five
'# type: ignore[method-assign]' suppressions are gone.

mypy tests/: 64 errors -> 0 (538 files). ruff check tests/: clean.
All 84 tests in the touched files pass.

* [chore] remove all remaining type:ignore suppressions from tests/

Converts 115 `# type: ignore[...]` suppressions across 23 test files to
no-suppression patterns (helper-return widening to Any, local Any aliases,
cc:Any aliases, cast at narrow call sites, typed fixtures) so the hard
no-type:ignore convention holds across tests/. No test logic or assertions
changed — only mock-wiring mechanics and type annotations.

Gate: ruff check tests/ clean; mypy tests/ (538 files) clean; 176 changed-file
tests pass. Zero real suppressions remain (the 7 grep hits are 3 hygiene-
checker string-literal test inputs and 4 prose mentions in comments).

* [F062] work_session.merge_pr: idempotency + active-status guard

merge_pr unconditionally set pr_status=merged, pr_merged_at, merged_by,
status=COMPLETED on whatever session it loaded — the only session-terminal
transition in WorkSessionService lacking both the active-status guard
(complete/abandon) and the terminal-idempotency guard (close). Two failure
modes: (1) a retried merge after a successful-but-unconfirmed GitHub merge
overwrote merged_by/pr_merged_at with the retry's actor/timestamp, corrupting
the merge audit trail; (2) merge_pr on an ABANDONED session resurrected it to
COMPLETED, undoing the single-active abandonment. Mirrors close()'s guard:
if status != ACTIVE, return the session unchanged. Both git.py callers await
merge_pr and discard the return, so the no-op is safe. TDD: 3 tests
(happy-path + both modes).

* [F063] workspace._clone_repo: rmtree half-configured clone on failure

If _configure_git raised CalledProcessError before its `remote set-url`
scrub, .git/config kept the tokenized auth URL (the project PAT) and
_assert_no_pat_leak never ran. The except clauses raised WorkspaceError
without removing the workspace, so the next ensure_workspace's health
short-circuit (valid .git with HEAD + objects) skipped past the leak —
mounting the agent on a workspace whose .git/config let it read+exfiltrate
the PAT. Both clone-failure except clauses now rmtree the workspace before
raising, so a half-configured clone is destroyed and ensure_workspace
re-clones from scratch. TDD: 2 tests (configure-failure leak + timeout).

* [F067] flow_main_pm: add missing /triage route

main_pm's manifest advertises triage (lifecycle.intents_for_role(MAIN_PM)
includes it via _PM_ROLES, alongside triage_all) but flow_main_pm.py had no
POST /triage route, so a main_pm agent calling triage hit a raw 404 that
bypassed the per-verb circuit breaker. Added the route mirroring flow_cell_pm's
/triage — wires to the existing team-scoped choreographer.triage (uses pm.team,
works for any PM role; Main PM gets its own team's blocked/awaiting tasks).
Fix direction: add-route, NOT remove-from-manifest — the manifest is spec-correct
(intents_for_role by construction); removing triage would contradict the spec
and leave main_pm with only cross-team triage_all. TDD: test_triage_route_exists_and_dispatches.

* [F068][F069] mcp servers: classify all rejection shapes + envelope 404s

F068: the do/flow-server circuit breaker only counted rejections whose
`error` field was a STRING in _CIRCUIT_REJECTION_KINDS. A 422 validation
failure (no `error` field, a `detail` list) and a 500/HTTPException
(dict-shaped `error` from the exception handlers) both bypassed the breaker
→ unbounded retries on a storm of either. Added _classify_rejection(payload)
(shared, applied to both servers) mapping all three shapes to a counted kind:
string error (existing), dict error → substring-mapped code
(*DENIED*/*AUTHORIZED*/*FORBIDDEN*/*PERMISSION*→not_authorized,
INVALID_INPUT/*VALIDATION*→incomplete_input, *NOT_FOUND*→None parity, else
→invalid_state), 422 detail→incomplete_input. The dict TypeError defence lives
in the classifier (isinstance, never dict-in-frozenset).

F069: a manifest-registered verb whose HTTP route is missing got FastAPI's raw
`{"detail":"Not Found"}` 404 body — a non-envelope payload the breaker
couldn't classify, so a storm bypassed it. _post now synthesizes an
invalid_state Envelope rejection (with a remediate hint → i_am_blocked/i_am_idle)
for a 404 status, routed through _record_and_check_circuit so the breaker counts
it. A 404 that carries a real Envelope (error field present) is surfaced as-is,
preserving test_flow_post_returns_envelope_on_404. TDD: 422/dict/404 tests in
both server test files; updated test_dict_shaped_error_does_not_crash to assert
the SDK is now called with not_authorized (replacing the pass-through assertion
that encoded the bug).

* [F064][F065][F066] websocket: non-blocking fan-out, finally-disconnect, idle timeout

F064: the bridge forwarder awaited every conn.send_text in a gather with no
per-connection queue and no send timeout — one slow WS client back-pressured
ALL event delivery to ALL clients (head-of-line blocking on the listen loop).
Each connect_* now registers a _ClientConnection (bounded asyncio.Queue(256) +
sender task); broadcasts enqueue via put_nowait (drop + structlog warn on
QueueFull) and return immediately. The sender drains the queue with each send
wrapped in wait_for(SEND_TIMEOUT=10s). Unregistered legacy sockets (set
directly into a subscription set, bypassing connect_*) get a timeout-bounded
fallback send task held in _pending_sends (ruff RUF006). disconnect cancels +
drops the sender.

F065: route handlers caught only WebSocketDisconnect with no finally — a
non-clean exit (anyio closed-resource, CancelledError, transport error)
propagated without manager.disconnect, leaking the dead socket into every
subscription set forever. Added finally: manager.disconnect(websocket) to all
5 handlers (disconnect is idempotent).

F066: no server-side heartbeat/idle timeout — a half-open socket from a dead
container blocked receive_text forever and was never reaped. receive_text now
wraps in wait_for(IDLE_TIMEOUT_SECONDS=90s); on TimeoutError, log + fall
through to the F065 finally. Named module constants (no config.py precedent for
WS tuning; callers/tests patch them).

TDD: 22 new tests across 3 files (handler cleanup, idle timeout, send queue),
non-flaky across repeats; 1 existing test adapted with a yield for the new
async fan-out (assertion unchanged). ruff/mypy clean, 421 unit/api tests pass.
No type:ignore/noqa.

* [F022][F023][F024][F025][F026] api: scrub secrets from 422 log, gate a2a/dashboard/orchestrator routes, SSE session-per-query

- middleware: redact known credential fields (git_token/api_key/token/...)
  from the 422 request-validation log line; response body unchanged
- a2a: require_any_authenticated_agent on /message/send + /message/stream;
  subscribe_to_task opens a short-lived session per poll instead of holding
  one asyncpg connection for the full SSE lifetime (pool exhaustion) + auth
- dashboard: gate auditor flag/report mutating routes to Auditor or CEO
- orchestrator: router-level CEO gate on all control routes (spawn/stop/...)

TDD; ruff/mypy clean; 449 unit/api tests green; no type:ignore/noqa.

* [F030] conventions: typescript-scoped custom rules now apply to .tsx files

The validator tags a .tsx file as language 'tsx' (the JSX grammar needs
that tag, distinct from plain 'typescript'), but a custom rule scoped to
'typescript' — the language the scan reports for a React+TS repo — silently
skipped every .tsx file. The two suffix maps were NOT unified: the 'tsx'
tag is load-bearing (grammars.py picks the JSX grammar on it; hygiene.py
keys on it), so unifying would make .tsx fail to parse.

Fix is in check_custom: a one-directional dialect map _DIALECT_OF =
{'tsx': 'typescript'} — a typescript-scoped rule fires on a .tsx file,
but a tsx-scoped (JSX-only) rule still does not fire on plain .ts.

TDD; ruff/mypy clean; 80 unit + 38 integration conventions tests green.

* [F029] websocket: remove broken /api/permissions/check loopback from channel stream

channel_stream called validate_channel_access, which HTTP-loopbacked to
GET /api/permissions/check — a route that does not exist. Every call 404'd
-> False -> the channel stream closed with WS_1008_POLICY_VIOLATION for
EVERY client, so the real-time channel stream was dead. Removed the
function, its call site, and the now-unused httpx + settings imports.

Post-F004 the panel-token gate is the channel-stream authorization (the
CEO panel is the sole WS client and may view every channel), so the
broken loopback is removed rather than replaced with an in-process check
the CEO always passes. The legitimate enforcement.validate_channel_access
(slugs, in-process static ACL) is a different function and is untouched.

F027 is resolved-by-F004 (no code change): all three per-agent streams
gate on _require_panel_token first, so only the authorized CEO panel can
connect — 'any viewer subscribes to any target' is closed.

TDD; ruff/mypy clean; 530 unit/api+enforcement+RBAC tests green.

* [F078] release_executor: deadline every subprocess (git/make/gh/clone)

A hung git/make/gh/clone would block the CEO-gated release loop
indefinitely. Wrap each proc.communicate() in asyncio.wait_for via a
shared _await_proc helper; on expiry proc.kill() the child and return a
non-zero rc (124) so every caller's fail-closed branch fires. Mirrors the
quality-gate _run_one kill-on-timeout idiom.

Deadlines are generous (30min gate / 10min clone / 5min push+gh) so a
legitimate slow op is never wrongly aborted — floor-assertion tests pin
the floors to guard exactly that logical regression. Green path returns
the real rc unchanged.

* [F072] reaper: deadline docker inspect/exec + harden _check_health sweep

A hung Docker daemon (or a stuck container FS) froze the single asyncio
event loop: the reaper runs inline before every dispatch tick and shares
that loop with every background sweeper. Bound each docker subprocess
with asyncio.wait_for; on expiry proc.kill() the child and either raise
(inspect / resolve_container_id — callers apply their own fail-direction)
or return None (the gateway probe — inconclusive, caller declines to act,
matching its existing probe-failure contract). Deadlines generous
(10s inspect / 30s exec) so a legitimate slow docker call is never
wrongly aborted; floor-assertion tests pin the floors.

Also harden _check_health's per-agent loop so one agent's hung inspect
skips that agent, not the whole sweep — preserving the check-all-agents
invariant the timeout-then-raise would otherwise break (without this, a
hung daemon means no agent gets health-checked any tick).

* [F076] say/dm: handler guard rejects all 4 no-comms roles, not just auditor

The say()/dm() defence-in-depth guard only rejected auditor, but CLAUDE.md
mandates the same no-agent-comms invariant for pr_reviewer (posts findings
on the PR), prompter and secretary (human-only, note + evidence). For those
three the manifest was the only gate, so a call bypassing the manifest
(direct API POST, test harness, future routing change) would not be refused
at the handler — admission depended on the agent's slug happening to be
absent from the channel/a2a matrix. Extend the guard to a _NO_COMMS_ROLES
frozenset (auditor + pr_reviewer + prompter + secretary), matching the
explicit role-frozenset gates on commit/notify/pitch/playbook/open_session.
Role-appropriate remediation per role. The claimed defence-in-depth now
covers 4 of 4 silent roles, not 1 of 4.

* [F070] drain fire-and-forget _bg_tasks on shutdown (bounded, data-preserving)

Orchestrator.stop() cancelled only the named loop tasks + agents, then
returned, abandoning in-flight _schedule_bg work. An in-flight
_persist_respawn_record upsert dropped at shutdown meant the last few
gate-mutation strikes never reached the DB; restore_respawn_tracker() on
the next start repopulated a stale lower count and the dispatcher re-burned
the full 4-spawn strike threshold against a still-wedged task — the exact
re-burn the durable tracker exists to stop. Audit-log writes (load-bearing
for cycle-time/rework metrics) were similarly dropped.

Add _drain_bg_tasks(): bounded wait (5s default) lets short DB writes
commit before exit (data preserved), then cancels any stuck task past the
deadline so a hang can't wedge shutdown. return_exceptions=True so one
failing bg task doesn't crash the drain. Wrap the stop_agent loop in
try/except + logger.exception so one bad agent can't skip the drain
(re-introducing the data-loss tail). Floor test pins the deadline >= 3s
so a too-short change can't silently drop a legitimate slow write.

* [F071] abort non-blocking intake/secretary spawn on mid-spawn shutdown

The non-blocking spawn (start_intake_session / start_secretary_session)
schedules _spawn_intake_container_guarded / _spawn_secretary_container_guarded
via _schedule_bg. Those run docker run and only register in _instances at the
END. If shutdown arrived between docker run and the registration line, the
container was started but the orchestrator had no handle — stop() iterates
only _instances, so the container was orphaned (leaked, manual docker rm).
Worse, the F070 drain could let the spawn coroutine complete the
registration AFTER stop() already iterated _instances, landing a live
container into a shutting-down registry nothing tears down.

Add a post-docker-run shutdown guard in _spawn_intake_container and
_spawn_secretary_container: re-check self._running after _run_container_cmd
returns; if the orchestrator began shutting down, remove the just-started
container (by its deterministic name) and raise _SpawnAbortedDuringShutdown
WITHOUT registering. The guarded wrappers catch that BEFORE except Exception
and close the live relay silently (shutdown is not a user-facing failure,
no error pushed to the SSE stream). The F070 stop() drain awaits the bg
spawn coroutine, so the abort surfaces cleanly.

TOCTOU-safe: between the _running check and the _instances assignment there
is no await (config + instance construction are sync), so once the check
passes, registration completes before the event loop can interleave stop().
The normal running path is unchanged (sanity tests pin it).

* [F074] per-agent advisory lock closes claim TOCTOU

_run_claim_guards read the agent's other tasks via unlocked SELECTs
before claim() took its row lock, and claim()'s FOR UPDATE locked only
the TARGET row — so two concurrent i_will_work_on by the SAME agent on
TWO DIFFERENT pending tasks each locked their own row, each read an
empty in_progress set, each passed already_active, each claimed+started
→ the agent ended with two in_progress tasks (the in-process asyncio
Lock is lost on orchestrator-restart split-brain, so it wasn't a
DB-level guarantee).

Fix: TaskService.acquire_claim_lock takes a transaction-scoped
pg_advisory_xact_lock keyed by hashtextextended(agent_id). The gate
acquires it BEFORE the guard reads (for non-coordinator roles only) so
the second concurrent claim's read sees the first's committed
in_progress task and is rejected. Tx-scoped → auto-releases on
commit/rollback, can't outlive the request.

Coordinator exemption (the key logical-regression guard): cell_pm /
main_pm do NOT take the lock — the PM coordinator concurrency feature
lets a PM plan+delegate many roots in parallel, and a per-agent lock
would serialize those claims and regress it. Matches the existing
_COORDINATOR_ROLES already_active/paused guard exemption. A hash
collision only causes benign false serialization, never a false
negative.

Tests: unit (dev acquires lock before guard read; coordinator does
not) + real-PG integration (same-agent serializes, different-agent
does not, releases on rollback).

* [F021] handle SSE transport errors so the intake composer isn't stuck

openStream registered listeners for the server-sent event kinds but not
the EventSource's own transport-level error. The 'error' kind IS in
LIVE_EVENT_KINDS, so a server-sent event:error (JSON MessageEvent) was
handled — but a dropped connection / dead session fires a plain Event
with NO data, which JSON.parse(undefined) swallowed in the try/catch,
so the stream 'stayed open' (EventSource loop-reconnected a session that
no longer existed) and isSending stayed true — the composer was
permanently disabled.

Fix: route the 'error' event by payload. A MessageEvent with string
data is a server-sent error → handleEvent (unchanged). A no-data Event
is a transport error → handleTransportError: clear streamingId/activity,
set isSending false, add a 'connection lost' error message, keep a
draft/batch preview up (so the human can still act on a proposed card)
else land on 'chatting', and close the dead stream so EventSource stops
loop-reconnecting.

Tests: renderHook + a jsdom EventSource double that fires a transport
error (plain Event, no data) vs a server-sent error (MessageEvent +
JSON). RED: transport error left isSending true; GREEN: resets to
false, surfaces the message, closes the stream. The server-sent-JSON
path is unchanged. Full panel suite (129) green; eslint/typecheck/prettier clean.

* [F081] Approve dialog: label notes required (>=20 chars), not optional

The CEO Approve dialog's notes label fell into the default branch
('Notes (optional') for the approve action, but approve actually
requires substantive notes >= 20 chars — enforced client-side
(toast error on < 20) and server-side. So the CEO was told 'optional'
and only learned the real requirement from a toast after hitting
submit with empty notes.

approve and start both require >= 20 chars; reject only requires a
reason. Collapse the label to two branches: reject -> 'Reason for
rejection (required)'; everything else (approve + start) ->
'Approval notes (required, >= 20 characters)'. The approve
placeholder now also signals intent ('Why this is ready to ship...').

Tests: render the queue, click Approve, assert the notes label says
'required' + '20' and does NOT say 'optional'. RED: label read
'Notes (optional)'; GREEN: 'Approval notes (required, >= 20
characters)'. eslint/typecheck/prettier clean.

* [F082] surface release-proposal query failures instead of silent hide

The card collapsed any non-404 backend failure (500 / network drop) onto
`!proposal` and returned null, so the CEO had no idea the release-proposal
endpoint was unreachable. Distinguish the cases: isError + a Retry affordance
vs the 404 null empty state that stays hidden. Mirrors PrReviewQueue.

* [F083] clear stale usage snapshot when /ws/system leaves connected

The hook synced wsState into the store but never dropped usageData when the
stream dropped, so on reconnect wsState flipped to "connected" before any
fresh USAGE_SNAPSHOT arrived and UsageOverviewPanel rendered the prior
session's totals/cost as if they were live. Clear usageData whenever state
leaves "connected" so the panel falls back to the polling summary until a
new snapshot lands. Connected->connected is a no-op clear skip.

* [F084] scope per-control disable to the in-flight mutation, not all

FeatureFlagsCard disabled every switch while any one flag toggle was pending,
and PlaybookReviewQueue disabled every row's Approve while any one approve was
pending — so the operator couldn't act on an independent control during a
slow round-trip. Gate the disable on the in-flight mutation's variables
(matching key / id) so only the control being mutated locks; the others stay
usable. The same-flag double-tap protection is preserved.

* [F085] reject submitting both project_id and product_id

validate() only checked 'at least one of project/product', so the dialog let
both be submitted together. The server silently lets product_id win at routing
and drops project_id, recording a misleading, never-used repo. Add a validator
that refuses the ambiguous submit with a clear error. The at-least-one rule and
the single-pick submit paths are unchanged.

* [F020] kanban: confirm admin-override drags that skip lifecycle preconditions

A drag on the operator kanban routes the status move through the admin
status-override, which bypasses the in-band lifecycle validator entirely.
That override is intentional (it's how an operator recovers a wedged task)
but it also let a careless drag skip material preconditions silently —
completing a task with no open PR, QA-bypassing, finishing docs on a task
whose docs aren't complete.

Leave the override intact but make the bypass explicit: compute the
preconditions the dragged move would skip (open PR, docs complete,
self-verified + commits + progress for submit-qa, visible non-terminal
subtasks for coordination-root targets) and, when any are skipped, hold the
move behind a confirmation dialog that lists exactly what's being skipped.
Precision over recall — only warn on what the panel can verify from the
task and its in-list children; never fabricate a 'satisfied' claim, and
stay silent on benign transitions that gate on nothing we can check.

The admin status-override capability is preserved (Confirm still fires it);
this only surfaces the bypass instead of letting it happen silently. Does
not touch the master-merge invariant — the board's updateTask is the
operator override, not the Main-PM merge path.

* [F086] prompter: restore parked cell content on project toggle off/on

rebuildCellWork appended a blank {summary:'', items:[]} entry for a newly-
selected cell, so toggling a cell's project OFF then back ON in the MegaTask
review card discarded the agent-authored per-cell summary/items — the entry
was dropped on toggle-off and re-added blank on toggle-on.

Park each draft's last per-cell content in client-only BatchProposal state
(parkedCellWork, keyed by draft index — never sent to the backend; confirm
ships only title/drafts/project_ids/route, and it ride-alongs into the
localStorage persist slice so the restore survives a reload mid-review).
rebuildCellWork gains an optional priorByCell map: a re-added cell with no
live entry restores its parked summary/items (with the new project_id) in-
stead of blanking; a live entry still wins over a stale parked copy so an
in-place edit is never regressed. parkCellWork is the pure merge seam
(prevParked seeds, live work overwrites) the setBatchDraftProjects updater
calls — kept pure so the updater stays a thin caller.

Tests: rebuildCellWork restore/blank-fallback/live-wins + parkCellWork
retain/overwrite/merge (6 new), 19 GREEN. eslint/typecheck/prettier clean.
No wire-payload change, no regression to the fill/drop/one-repo-per-cell
invariants.

* Updated domain

* [F087,F088] enforce panel token on live-chat bridges (Phase 5)

Add a CEO-bound, header-token-only gate (require_panel_token) at the route
level of the prompter_live + secretary_live bridges, which were the only
panel-facing API surface that ran unauthenticated. It mirrors the WS
_require_panel_token and _check_agent_auth_token contracts: in dev
(ROBOCO_AGENT_AUTH_REQUIRED unset) a missing token is allowed; a
presented-but-forged token is rejected even in dev; in prod nginx already
injects the CEO-signed X-Agent-Token on /api/ for GET + POST, so the SSE
stream (EventSource can't set headers) and the POSTs are now checked instead
of anonymous. Applied to start/stream/status/messages/stop on both routers;
preview_live_batch switched from CurrentAgentContext+noqa to the route-level
gate (genuinely auth-only). confirm/confirm-batch/re-interview keep
CurrentAgentContext (they use agent.identity). The container->relay /events
callback is intentionally left ungated (internal Docker network, opaque
session id) — gated by a test sentinel so Option B (spawn+SDK token wiring)
is a deliberate future decision. No panel/nginx/spawn/SDK changes; master
merge invariant untouched. 22 new TDD auth tests, 492 api tests green.

* [F089] honest WorkSession agent_id nullability across the read path

The work_sessions.agent_id column is nullable=True with ondelete=SET
NULL — deleting an agent nulls the FK on every session it ever held. The
ORM annotation lied (Mapped[UUID] non-optional), the converter papered
over the lie (typing_cast to a non-optional UUID), and the response
model rejected None outright (WorkSessionResponse.agent_id: UUID). A
session whose agent had been deleted crashed the GET endpoint with a
pydantic ValidationError instead of serializing agent_id: null.

Make the read path honest end-to-end:
- WorkSessionTable.agent_id: Mapped[UUID | None] (matches the column).
- WorkSessionResponse.agent_id: UUID | None (serializes null, no crash).
- session_to_response passes agent_id via typing_cast('UUID | None', ...)
  to bridge SQLAlchemy's UUID[Any] to stdlib uuid.UUID while preserving
  None-ness (the cast stays for the same mypy-plugin reason every other
  field uses one; it no longer narrows away None).

WorkSessionCreate.agent_id stays UUID — at create time the claiming
agent is always known. The unused WorkSession pydantic read model is
left as-is (never materialized from a DB row). task.py:_needs_revision_dev
already None-guards ws.agent_id via to_python_uuid (returns None -> skip).

* [F090] drop auditor from write_roles on main-pm-board / board-private

The auditor is a silent, read-only observer on every channel, but the
channel catalog (roboco/foundation/policy/communications.py) listed it
in write_roles for main-pm-board and board-private 'for parity' with the
legacy CHANNEL_ACCESS table, while the actual silent-observer rule was
enforced only at the say/dm guard (content_actions._NO_COMMS_ROLES) and
PermissionService.can_write_channel's auditor short-circuit.

That left the catalog-only enforcement path — the HTTP messaging route
(messages.py send_message -> validate_channel_access) — authorizing an
auditor write that both the say/dm guard and PermissionService would
have blocked. A reader of the catalog also believed the auditor could
post to those channels, which is false.

Fix: remove Role.AUDITOR from write_roles on both channels (main-pm
+ board remain writers; ceo remains a writer on board-private). The
auditor stays in read_roles, so its silent read is unchanged. silent_roles
is left empty (matches the announcements precedent: auditor reads via
read_roles, not the silent bucket) — the DB seed and silent_observers
field are untouched.

Logical-regression check: the auditor's read access on both channels
is byte-for-byte preserved (still in read_roles, so validate_channel_access
read returns True via the direct list); the legitimate writers (main-pm,
product-owner, head-marketing, ceo) are untouched; CHANNEL_ACCESS is
derived from the spec so the foundation/seed drift tests self-adjust;
PermissionService.can_write_channel already short-circuited auditor to
False everywhere, so no behavior change there; AUDITOR_SILENT_ACCESS is
unchanged (auditor not added to silent_roles -> no DB silent_observers
change -> no group-access behavior change); the say/dm _NO_COMMS_ROLES
guard is unchanged. Tests: 3 new in test_channel_access.py — auditor
write on main-pm-board/board-private now raises ChannelAccessDeniedError
(RED before: returned True), auditor read still True, main-pm/ceo still
write.

* [F091] warn at spawn time when host grok auth.json is missing

GrokCliProvider._append_grok_auth_mount silently skipped the mount when
the host ~/.grok/auth.json was absent. The spawn still succeeded (docker
run returned 0 — the container was created), so the operator had no
spawn-time signal that the agent was doomed: the entrypoint's
`python -m roboco.llm.providers.grok_auth --check` backstop then
refused to start (exit 78) and the failure only surfaced later via the
container's log markers.

Fix: emit a spawn-time WARNING (module logger) naming the missing file
and the remediation (`grok login` on the host, or set
ROBOCO_HOST_GROK_DIR) when the mount is skipped. The spawn outcome is
unchanged — the container still starts and the existing exit-78 -> park
flow (F041) still catches it — but the operator now sees the missing
credential immediately instead of diagnosing a later exit-78.

Logical-regression check: the mount-present path is byte-for-byte
unchanged (auth.json exists -> the -v bind is appended, no warning); the
spawn still succeeds when auth is absent (no raise — the existing
test_grok_spawn_omits_auth_mount_when_absent still passes: no mount, no
crash); the exit-78 entrypoint backstop and the orchestrator's
exit-78-park handling (F041) are untouched; a module-level logger adds no
side effects. Tests: new test_grok_spawn_warns_when_auth_absent uses
caplog to assert a WARNING mentioning auth.json + `grok login` is
emitted on a missing-credential spawn (RED before: no warning; GREEN
after). 102 grok tests green; ruff/mypy clean.

* [F092] decode JWT exp when refresh omits expires_in

xAI's refresh-token response sometimes omits expires_in. Without it the
new access token kept the stale pre-refresh expires_at, so is_valid /
--check forever rejected a fresh token — and the refresh loop re-rotated
the single-use refresh token every tick, killing the credential (F006).

The access token is a JWT whose exp is the authoritative expiry: decode it
when expires_in is absent. Fallback to the documented ~6h TTL + a structlog
warning when the JWT exp is unreadable, so a fresh token is treated as live
instead of stale.

* [F093] serialize concurrent live-chat spawns under a per-agent lock

The intake and secretary agent ids are each a single fixed id, so two
concurrent start_intake_session / start_secretary_session calls raced on
the container name (docker run --name roboco-agent-<id>) and the
_instances[<id>] write: both passed the reap-prior check before either
registered, both ran docker run, and the last _instances write won,
orphaning the other container + its relay.

Add _intake_spawn_lock / _secretary_spawn_lock (asyncio.Lock) and wrap the
_spawn_intake_container / _spawn_secretary_container bodies so the second
start waits for the first to fully register before its own reap-prior check
runs. Distinct from self._lock (which stop_agent takes) to avoid a
reentrancy deadlock: the spawn body holds the spawn lock then calls
stop_agent (acquires self._lock) — lock order is always spawn_lock ->
self._lock, never the reverse.

* [F094] add a persistent-probe-failure escape hatch to provider parking

_on_probe_failure only incremented the failure counter and, at 10 failures,
sent a one-shot CEO notification. It never cleared the tracker, never gave
up, never fell back to time-expiry. _do_probe returns False for any non-2xx
AND any httpx error, so a permanently unreachable probe endpoint (removed
API key, network partition to the probe host, misconfigured base URL) kept
the provider parked forever — every agent on it gated by
_provider_spawn_parked, their tasks reaped to pending but the spawn gate
queuing every spawn, sitting pending forever. The only recovery was the
operator manually clearing the Redis key.

Past _PROBE_GIVE_UP_THRESHOLD (30) persistent failures, fall back to the
same time-expiry optimism the unprobeable-provider path uses (_do_probe
returns True when there is no probe URL): clear the park and resume parked
agents. If the provider is genuinely still down the real workload attempts
re-park via the 429/5xx path, so this is bounded burn — strictly better
than a silent forever-strand. Kept above the CEO-notify threshold (10) so
the operator still gets the notification first.

* [F095] orchestrator: parked-provider spawn short-circuits before expensive prepare

spawn_agent ran the full _prepare_agent_spawn (writes blueprint/settings/
briefing/MCP files, ensures the image, registers a STARTING instance) every
dispatcher tick only to bail at the after-prepare parked-provider check —
wasting all that file I/O while the provider stayed parked and leaving a
STARTING instance registered then downgraded to OFFLINE.

Move the parked check before _prepare_agent_spawn: resolve the route cheaply
via _resolve_agent_route (only provider_type is needed) and bail with a
minimal unregistered OFFLINE instance. The existing-running check stays
first (inside the lock) so a live agent is never replaced; a TOCTOU
re-check guards the unlocked window before prepare; the after-prepare
check is kept as a rare-race defense (a park landing during prepare).

* [F096] orchestrator: serialize fire-and-forget respawn persists per commit order

_persist_respawn_record is fire-and-forget per gate mutation; a respawn loop
fires count 1->2->3->4 in quick succession, scheduling one persist per
increment for the same (agent_slug, task_id). The ON CONFLICT DO UPDATE upsert
is row-level race-free, but the fire-and-forget tasks can still COMMIT out of
order: a slow stale persist (count=2) scheduled first can resolve AFTER a fast
fresh one (count=4) scheduled second, leaving the durable row at the stale low
count and re-burning the strike threshold on restart.

Fix: acquire self._respawn_persist_lock (new asyncio.Lock) as the FIRST await
in _persist_respawn_record, so acquisition order = task creation order (FIFO
ready queue) = logical schedule order, and commits land in that order. The
durable row always ends at the latest logical value. The lock lives in the bg
task, so the dispatcher hot path never blocks; persists are best-effort and
a slow one queuing the rest just delays the durable catch-up (in-memory record
stays authoritative).

* [F097] orchestrator: back off grok re-park retry_after within a rate-limit episode

_probe_target returns (None, {}) for grok — the grok CLI's xAI endpoint is
closed and the SuperGrok OIDC access token is not a valid bearer for the metered
api.x.ai, so a real probe would either no-op or strand grok parked forever.
_do_probe treats url-is-None as success (time-expiry optimism), so once the
60s retry_after passes the probe loop optimistically clears the grok park, a
cleared park dispatches a fresh grok agent that hits the still-active xAI 429,
exits 75, and re-parks — a flat ~90s crash-retry cycle for the whole xAI
rate-limit window (each cycle costs container startup + a rejected grok call).

Fix: track _grok_repark_count + _grok_last_park_at in _park_grok_rate_limited
and back the re-park retry_after off exponentially within one episode
(60 -> 120 -> 240 -> ... capped at 2**4 = ~16min cycle) so the churn dampens. A
gap past _GROK_REPARK_EPISODE_GAP_S (25min, > the capped cycle) means no re-park
for that long => the rate limit actually lifted => a fresh episode resets the
count to the base 60s, so recovery latency isn't penalized across episodes.
The first park in a fresh episode is unchanged at 60s.

* [F098] orchestrator: keep waiting record through a re-park during probe-success resume

resolve_wait deleted the waiting record (in-memory + durable) BEFORE calling
spawn_agent. A re-park in the window between the probe-success clear and the
spawn — the provider's rate limit lifts then immediately re-limits, or a second
provider limit lands — bails spawn with an OFFLINE instance (the parked-provider
short-circuit). Deleting the record first orphaned the agent: with no record
the probe-resume loop can never revive it and the spawn gate bails every tick,
so the agent is lost until the operator intervenes.

Fix: spawn first, then tear down the record only once a container actually
launched (instance.state == ACTIVE). On an OFFLINE bail the record stays so the
next probe-success re-attempts the resume. On a spawn EXCEPTION the record is
torn down + re-raised so the probe loop doesn't keep re-resuming a task that
moved to a different state (e.g. readiness refused -> task auto-blocked) —
matching the pre-fix behavior where the record was deleted before the spawn.

* [F099] wire pr_pass/pr_fail self_review block in the spec gate

The pr_pass/pr_fail ActionSpecs carry self_review_block=True, but
_gate_preflight never populated Context.original_developer_slug, and
actor_slug was read off agent.slug — which GatewayAgentView does not
carry, so it was always None in production. The block was structurally
dormant: a reviewer who was also the original developer of the
assembled PR could pass (or fail) their own work. The service-layer
_validate_not_self_review backstop only covers qa/documenter, not
pr_reviewer, so the spec gate is the only defense.

Set actor_slug=str(reviewer_agent_id) (GatewayAgentView has no slug,
so the UUID is the identity) and original_developer_slug from the
original_developer marker (a UUID stored as a string). Both resolve to
UUID strings, so the spec's string-equality comparison fires when the
reviewer IS the recorded original developer.

The marker is never set on assembled coordination tasks (only on
dev-leaf tasks at QA/doc claim), so the block stays dormant by design
in production — but the gate is now correctly wired to fire if the
marker were ever set to the reviewer. Zero production behavior change;
the dormant-in-production state is pinned by the no-marker test.

* [F100] atomic Redis probe-failure counter via server-side Lua

increment_probe_failures / reset_probe_failures did a non-atomic
get_state (GET) -> mutate -> set (SET) in Python. A concurrent
activate() re-park writes a FRESH episode blob (probe_failures: 0 +
fresh activated_at / retry_after / affected_agents / kind); if the
stale increment's SET landed after the fresh activate's SET, the stale
blob overwrote the fresh episode metadata AND un-reset the counter
(clobbering the new episode).

Redis single-threads a Lua EVAL, so a server-side read-modify-write
is indivisible: activate's SET is serialized entirely before or after
the script, never interleaved between the script's GET and SET. The
two scripts mutate ONLY probe_failures, so every other episode field
survives the bump. activate stays a single atomic SET (a fresh episode
resetting the counter to 0 is correct semantics).

* [F101] enforce PR-open state gate on gateway open_pr (parity with HTTP path)

* [F102] make project_id mandatory on pr_target (close cross-repo pr_number collision)

* [F103] make project_id mandatory on close_pull_request (close cross-repo collision)

* [F104] fail-closed on conventions resolution errors (block gate no longer silently disabled)

* [F106] compound (timestamp, id) keyset cursor for message pagination

get_messages used strict timestamp inequalities with a non-deterministic
order_by(timestamp.desc()), so equal-timestamp messages were cut by limit
on one page and excluded (strict < T / > T) from the next — they vanished
across pages. Bundled the (timestamp, id) pair into a MessageCursor dataclass
so the next page resumes exactly past the cursor's id at the shared
timestamp (or_: strictly-older OR same-timestamp-smaller-id for before; the
mirror for after), with a deterministic order_by(timestamp.desc(), id.desc())
so the last-item cursor is unambiguous. id is None for a legacy timestamp-
only cursor (strict inequality, prior behavior). The route builds cursors
from the flat before/before_id + after/after_id HTTP params; the schema now
carries the tie-breaker ids. Also clears PLR0913 (cursors replace the
before_id/after_id params).

* [F107] defer Redis bus publish until DB commit (no phantom notifications)

deliver() and _persist_and_deliver() ran inside the caller's open
transaction: the notification row was flushed but not committed, yet
NOTIFICATION_SENT was published to the Redis bus immediately. A commit
failure (DB hiccup, constraint, asyncpg error) rolled the row back while
connected WebSocket clients had already received a push for an id that
no longer existed — a phantom notification (notify_get -> NotFoundError).

Added a deferred-publish (transactional-outbox) helper: defer_bus_publish
enqueues the event on session.info and registers one-shot after_commit /
after_rollback listeners on session.sync_session the first time it is
called for that session. On commit, the after_commit listener schedules
the async drain via asyncio.create_task on the running loop (the listener
fires synchronously inside await AsyncSession.commit, so the loop is
active); the task handles are stashed on the session so callers/tests can
await them. On rollback, after_rollback drops the pending queue — a
rolled-back txn emits nothing. deliver() now builds the per-recipient
events up front (data materialized to strings, so deferral is safe even
if the ORM object later expires) and defers each; the delivered_at DB
marker stays in-tx (rolls back with the row). The bus block stays
best-effort (try/except + log) so a bus-init failure never propagates or
rolls back the notification row — matching the prior inline semantics.

This fixes every deliver/_persist_and_deliver caller at once (the two
cited in F107 plus the orchestrator + task.py deliver sites), since they
all commit the session afterward (the deferred publish fires on that
commit; the row is durable by the time the event goes out).

* [F108] atomic replace_chunks: single-txn delete+insert closes reindex race

* [F109] playbook curation status guards: approve/reject draft-only, archive approved-only

* [F110] draft slug TOCTOU: catch IntegrityError on flush -> ConflictError (no 500)

* [F113] collapse WorkSession creation to the validated service path

_create_work_session_if_needed constructed WorkSessionTable directly,
duplicating WorkSessionService.create's validation (existing-active
check, single-active-per-task supersede, project/task existence). The
two sites had drifted. Route through WorkSessionService.create instead,
mapping ConflictError to the idempotent 'if needed' None. Remove the
now-dead _supersede_other_active_sessions (create's
supersede_active_sessions_for_task replaces it).

Fix three pre-existing RED tests surfaced by the sweep (all confirmed
failing on the F110 commit before this change):
- test_fail_qa_work_session_fallback_excludes_qa_session: inserted two
  ACTIVE work_sessions per task, violating uq_work_sessions_one_active
  _per_task (migration 047). The QA session is now ABANDONED — still in
  the fallback query's result set (the query filters by task_id +
  agent_id, not status), so the exclude filter (agent_id != qa_id) is
  still exercised and the dev is resolved.
- test_ceo_reject_routes_coordination_task_to_main_pm /
  test_ceo_reject_routes_batch_umbrella_to_main_pm: ceo_reject emits an
  audit row keyed to CEO_AGENT_ID, but the tests never seeded the CEO
  agent row (fk_audit_log_agent_id_agents). Seed the CEO agent (get-or-
  create, mirroring test_ceo_reject_writes_handoff_journal).

* [F114] single-claimant guard on pr_gate_claim

pr_gate_claim delegated straight to _qa_or_doc_claim, which overwrites
claimed_by / active_claimant_id with no single-claimant check. Two
reviewers race-claiming the same awaiting_pr_review task would
last-write-wins overwrite the first claim, and the first reviewer's
subsequent pr_pass / pr_fail would actor-mismatch against the new owner
(wasting a review cycle). The orchestrator's gate dispatcher already
prevents double-reviewer-dispatch in normal flow (one task -> one team
-> one reviewer + is_agent_active + per-tick spawned set), so the race
is only reachable via direct concurrent API calls (defense-in-depth).

Add a role-aware single-claimant guard in pr_gate_claim: lock the row
FOR UPDATE (serialize concurrent claims, mirroring the dev claim path),
then refuse only when the task is already actively claimed by a
DIFFERENT PR-reviewer. The gate task is owned by the PM at entry
(submit_for_review does not clear ownership, unlike submit_for_qa), so
the guard must distinguish a PM/dev owner — which the first reviewer
legitimately overclaims — from a competing reviewer claim; checking the
existing claimant's role (pr_reviewer) does exactly that. A re-claim by
the same reviewer is idempotent (skipped by the != check). The gateway
claim_gate_review handler already maps a None return to a clean
invalid_state envelope ('it may already be claimed; give_me_work for
the next'), so no gateway change is needed.

TDD: 3 integration tests in test_task_service_basics.py — reject a second
reviewer race-claim (returns None, first claim intact), allow the first
reviewer when the PM owns the root (regression guard for the
PM-owns-at-entry model), idempotent re-claim by the same reviewer.
Confirmed the reject test RED first (race-claim succeeded, overwriting
reviewer1).

* [F115] sample monorepo per (repo,workflow)/(repo,command) not per repo

The CI-watch and dep-update loaders collapsed a monorepo's cell-projects
to one canonical entry per repo (slug-sorted-first), so a repo whose cells
each carry their OWN ci_watch_workflow / dep_update_command had only the
canonical cell's workflow/command sampled — a red on another cell's
workflow or drift on another cell's lockfile was missed (under-count).

Refactor the shared one-per-repo collapse into _projects_one_per_key, keyed
by repo identity for external-PR discovery (unchanged: one review per PR per
repo), by (repo, effective workflow) for CI-watch, and by (repo, command)
for dep-update. Each distinct workflow/command is now sampled once; the
engines' per-git_url fix-task dedup still prevents duplicate fix tasks for
the same repo. _projects_one_per_repo now delegates to _projects_one_per_key.

key_fn uses a string annotation (Callable lives under TYPE_CHECKING, like
the existing Coroutine/Iterable annotations at lines 4193/5279).

* [R115] originate ci_watch/dep_update fix tasks as PLANNING coordination roots

The Main-PM-code-impossibility guard (commit e202ce39, Thread 4 of this
audit) made team=MAIN_PM + task_type=CODE impossible — a Main PM coordinates,
it does not write code. But the ci_watch and dep_update engines still
originated their fix tasks as task_type=TaskType.CODE assigned to main-pm,
so task_svc.create raised MAIN_PM_NO_CODE and NO fix task was ever opened
— a regression introduced by the earlier audit fix (confirmed: the engine
tests pass at e202ce39~1 and fail at HEAD).

Mirror the hardened self_heal_engine precedent (self_heal_engine.py:197)
which already uses task_type=TaskType.PLANNING for its Main-PM coordination
root with an explicit 'decompose the fix and delegate the code work to a
cell dev — the Main PM does not write the fix itself' description. Both
engines now originate PLANNING coordination roots with matching delegation
guidance in the description + acceptance criteria. confirmed_by_human
stays True for both (they ride the normal delivery flow without the CEO
gate, unlike self-heal — intentional per the architecture).

The dedupe/open-cap queries (list_open_ci_watch_tasks /
list_open_dep_update_tasks) key on source + non-terminal status + git_url,
NOT task_type, so the type change does not break dedup (still one open fix
task per repo).

The two source-test fixtures (test_ci_watch_source / test_dep_update_source)
created CODE+MAIN_PM tasks directly to exercise the listing queries — same
guard violation; switched to PLANNING (the queries assert on source/status,
not task_type, so the fixture type matches the engines' corrected type).

* [F116] hold the read-clone lock across the dep-probe local clone

dry_upgrade_changes_lockfile called ensure_read_clone (which syncs the
read clone under the _meta-conventions lock then releases it) and ran
'git clone --local --no-hardlinks <read_clone>' OUTSIDE the lock. A
concurrent ensure_read_clone -> _sync_read_clone (fetch + hard-reset to
origin's default branch) could mutate the read clone's working tree /
object db mid-clone, racing the clone and producing an inconsistent or
failing probe.

Split _probe_lockfile_change into _clone_local_into (the local clone,
run under the read-clone lock) + _probe_lockfile_on_clone (the upgrade +
git status, run without the lock on the now-independent copy). The probe
acquires _ensure_lock_for(slug, '_meta-conventions') — the same lock
ensure_read_clone syncs under — and holds it only for the clone step; the
upgrade operates on the full --no-hardlinks copy and never touches the
read clone, so the lock is released before it to avoid blocking
conventions reads for the upgrade duration.

The tiny gap between ensure_read_clone releasing the lock and the probe
re-acquiring it is safe: any concurrent _sync_read_clone completes under
the lock before the probe acquires, so the clone reads a stable state.

* [F117] stop the orchestrator in lifespan shutdown BEFORE closing the DB

The lifespan shutdown closed OptimalService + the DB, and only THEN did
bootstrap's finally block call orchestrator.stop() — so stop() ran with
the DB already closed. stop() drains fire-and-forget _bg_tasks writes
(respawn_tracker upserts, audit-log rows) and stop_agent finalizes work
sessions / agent state, all needing the DB still open; closing it first
silently dropped those final writes (the durable PM-respawn counter's
last few strikes, the metrics-bearing audit trail tail).

Move orchestrator.stop() into the lifespan shutdown path, BEFORE
close_optimal_service + close_db, guarded by a new get_orchestrator_or_none()
safe accessor (no crash when no orchestrator is wired — tests,
skip_orchestrator). bootstrap's finally-block stop() becomes an idempotent
safety net: stop() gains a _stopped flag (getattr-guarded so __new__-
constructed test instances still stop) so the double-call is a clean no-op,
not a re-stop of already-stopped agents / re-drain of an empty bg set.

* [F118] coerce a lone-string where_to_look into a list

where_to_look is a list-typed handoff field like consequences/next_steps
but was the only one NOT in the _wrap_scalar_in_list field_validator. A
well-intentioned where_to_look='src/api/' 422'd at the route with no
remediation envelope, and the agent's retry loop tripped the do-server
circuit breaker — the exact failure mode the other list fields were
hardened against. Add it to the mode='before' validator so a lone string
is wrapped into a one-element list before type coercion.

* [F119] sender reaps dead sockets on send error instead of waiting for receive idle timeout

* [F120] release a stopped agent's claimed task immediately on budget-kill/shutdown

* [F122] name the already-open PR in submit_up's None-state remediate

submit_up's create_pr pre-side-effect opens the cell→root PR BEFORE
submit_for_review runs (its pr_created gate requires it — lifecycle.py:1338-1343).
When submit_for_review returns None (a concurrent state change raced the task
out of in_progress between the precondition gate and the composed action), the
old remediate ('check task state — must be in_progress with PR ready') hid
that the PR was already open on GitHub — an orphaned external artifact the PM
could not reconcile. Mirror submit_root's F016 None-envelope remediate: name
the open PR, point the PM at re-fetch + reconcile, and note create_pr is
idempotent so a re-issue re-attaches to the existing PR (no duplicate). Pure
message improvement — zero behavior change; reordering is off the table
(create_pr must precede the pr_created gate).

* [F124] re-check dependency state before releasing a dependency-blocked claim

The unmet_dependency guard read dependency state via an unlocked SELECT, then
fired release_dependency_blocked_claim (a state mutation: claimed/in_progress
-> pending, clears branch_name, abandons WorkSession) as a side-effect BEFORE
returning the rejection. An upstream dependency that reached a terminal state
(completed/cancelled) in the microseconds between the read and the release left
the task NEEDLESSLY released — its branch cleared + WorkSession abandoned +
assignee bounced, only to be re-dispatched + re-claimed when the dependency-
completion re-dispatch fired a moment later.

Re-check unmet_dependency_ids immediately before the release and skip it
(returning None — proceed) when the upstream just completed. Dependencies are
monotonic (unmet -> met only; terminal states never reopen), so a fresh read
that now finds them met stays met: safe to proceed without releasing. The
'still unmet' path is byte-for-byte the prior behavior (no regression). The
cross-task residual window (upstream completes between the re-check and the
release) is not closable by a row lock on the dependent, but the re-check
narrows the window from [first read -> release] to [re-check -> release], and
in the common case the first read already sees met (no guard fires). No
committed-work loss either way (a dependency-blocked task has none; the branch
ref + commits persist across the branch_name clear).

* [F125] serialize same-parent delegate via per-parent advisory lock

The delegate sibling-dedup guard read the parent's existing subtasks via an
unlocked get_subtasks SELECT (the dedup read) then created the subtask (the
write) with no DB serialization between them. Two concurrent delegate calls
for the same parent (PM re-delegating while a reaper re-dispatches, or two
orchestrator ticks racing) each read a duplicate-free sibling set, each passed
the dedup guard, and each created a subtask — the parent got the duplicate the
guard exists to prevent (the smoke-run runaway pattern).

Fix: a PostgreSQL transaction-scoped advisory lock keyed by the parent task
id (seed 1, disjoint from the per-agent claim lock's seed 0), acquired at the
top of the delegate body before the first get_subtasks read (the briefing
context read AND the dedup sibling read) and held through create_subtask's
flush + the outer request commit. The second concurrent same-parent delegate
blocks until the first commits, then its dedup read sees the committed
sibling and is rejected.

Per-PARENT (not per-agent): a coordinator PM legitimately delegates many
subtasks under one parent in quick succession and plans many roots in
parallel — a per-agent lock would serialize all of a PM's delegates and
regress the PM coordinator concurrency feature. The per-parent lock
serializes only same-parent delegates (the dedup invariant is per-parent)
and leaves different parents untouched.

TDD: red-first ordering test (lock acquired before first get_subtasks read
and before create_subtask) + no-regression test (create still runs).

* [F127] per-task advisory lock prevents open_pr milestone double-emit

open_pr's idempotent re-entry guard (pr_number is not None) read t.pr_number
from an unlocked fetch. Two concurrent same-task open_pr calls (the
alive-but-unresponsive respawn race) both fetched pr_number=None, both passed
the guard, both ran the runner (GitHub 422 ensures one PR), and both reached
_record_milestone_progress -> a double-emitted 70% 'opened PR #N' entry.

Fix: acquire_task_lock (pg_advisory_xact_lock, seed 2) before the fetch, held
through the runner + milestone + request commit. The second concurrent call
blocks until the first commits, then its fetch sees the committed pr_number
and the idempotent guard short-circuits without re-emitting. Per-task (single-
active-task guard means same-task concurrent open_pr is only the bug case).

* [F128] require active claim on explicit-task content posts

_verify_explicit_task_ownership checked assigned_to, which is stale
across a reap/handoff (persists until reassignment; active_claimant_id is
cleared on release). A reaped agent could keep posting say/dm/note to its
former task. Add the active-claimant check when assigned_to == caller;
assigned_to=None keep its existing allow (read-side inspection between
reassignments uses evidence, which has its own ownership path).

Existing 'active owner' test mocks passed assigned_to=agent_id without
active_claimant_id; production sets both together on claim, so the mocks
were incomplete. Updated to set both — realistic, not a behavior change.

* [F129,F130] harden quality gate _run_one exit status + timeout cleanup

F129: _run_one returned 'proc.returncode or 0', masking a None returncode
(communicate returned without a recorded exit code — process killed
out-of-band) as 0 / success. Treat None as a non-zero failure (fail-closed).

F130: on timeout, _run_one killed the subprocess but never awaited wait()
— communicate() was cancelled so it never closed the stdout/stderr pipes,
leaving a transient zombie + leaked FDs. Await wait() after kill() to reap
the process and close the transports.

* [F132] timeout the conventions validator + reap on hang

_run_conventions_validator awaited proc.communicate() with no timeout —
a hung subprocess (tree-sitter deadlock, huge repo) hung the
i_am_done/pr_pass gate forever and orphaned the python subprocess on
orchestrator restart. Wrap communicate() in wait_for(120s); on timeout
kill+wait the proc and fail closed (could_not_run=True → block gate
refuses the submit), matching the validator's own fail-loud philosophy.

* [F135] re-check activity before sweeper closes a session (TOCTOU)

sweep_timed_out_sessions read last_activity_at once at the candidate
SELECT, then closed. A message landing in that window refreshed
last_activity_at in the DB, but the sweeper closed on its stale in-memory
value — closing a just-used session. Re-read last_activity_at fresh right
before the close and skip if the session is no longer timed out.

* [F136] cancel startup indexing task on OptimalService.close()

close() cancelled only the periodic update task, then cleared the plugins.
The startup _indexing_task (background auto-index, slow Ollama / large repo)
could still be mid-flight at shutdown and write against closed/cleared
plugins. Cancel and await _indexing_task FIRST (its tail starts the periodic
task, so ordering also prevents a late periodic spawn), then the periodic
task, then clear plugins.

* [F139] scope active_task_owns_branch to the polled project

active_task_owns_branch did an unscoped WHERE branch_name = ? — a cross-project
branch_name collision (UUID-derived 8-char prefixes, theoretical) made the
internal-PR reviewer skip the WRONG project's PR (project A's leftover PR
skipped because project B happened to have an active task with the same
branch). Pass project_id (in scope at the orchestrator call site) and add
TaskTable.project_id == project_id to the WHERE. Correct for single-project
tasks and MegaTask multi-repo batches alike: each root-subtask carries its own
project_id matching its own repo, so a branch on project A's repo is owned
only by a task whose project_id == A.

* [sweep] strip Fxxx audit-ID tokens + trim bloated comments/docstrings + add behavior-change docs

Post-audit sweep over the 135 audit-fix commits since 19a474d3:

1. Stripped every # Fxxx: audit-ID token from comments AND every Fxxx token
   from docstring openings across 211 blocks / ~626 lines. The CEO flagged
   these twice: audit-issue IDs in code confuse future devs/agents. The
   descriptive text is preserved; only the Fxxx token is removed (and bloated
   narrative blocks trimmed to 1-3 lines keeping the one non-obvious invariant).
2. Trimmed bloated comments/docstrings to the concise standard (1-3 lines).
3. Added missing behavior-change docs for the audit-fix batch: prompts/roles
   (documenter, pr_reviewer, qa), user-facing docs (api auth, websockets,
   agent-gateway, megatask, merge-model, task-lifecycle, grok, resilience,
   conventions, panel, security, troubleshooting), and the RAG corpus (cell-pm,
   main-pm, pr-reviewer, qa roles; conventions; messaging-tools; escalation;
   megatask; task-claiming workflows).

Comment/docstring/prose ONLY — zero code-line edits (verified: the diff
contains no def/class/return/if/for/await/assignment/call lines). Gates green:
ruff format + ruff check clean, mypy clean on roboco/. The only pytest failures
are the pre-existing sync_branch tracing-decision gap (B1, 250be5c2) — not
sweep-caused and tracked separately.

* [fix] register sync_branch in VERBS_WITHOUT_TRACING

sync_branch (B1, 250be5c2) is a git-only rebase+force-push verb (composes=(),
no DB transition, side_effects=()) but was never registered in the tracing
parity tables, so test_every_intent_verb_has_a_tracing_decision failed.
Mirrors open_pr: a mechanical git op with inline preconditions (ownership),
no journal/plan rationale required.

* chore(release): 0.14.0

* [fix] resolve 16 mypy errors across 9 test files (make quality gate)

type-clean the test files so make quality (mypy roboco/ tests/) is green:
- Any-typed locals for the two TypeError-asserting scoping tests (bypass
  the required-arg check without getattr/ruff B009)
- Any-typed view for the shutdown-drain _drain_bg_tasks override (bypass
  mypy method-assign without setattr/ruff B010)
- cast("uuid.UUID", ...) / cast("UUID", ...) for SQLAlchemy UUID[Any]
  returns (TC006-quoted), config=None for AgentInstance stubs, None-narrowed
  await_args, Iterator return on a yielding fixture, UUID annotation on the
  _task helper. No type:ignore / noqa.

* [docs] regenerate lifecycle artifacts for sync_branch + branch-keyed submit_root gate

The committed artifacts were stale: lifecycle.py grew the sync_branch verb
(B1) and the branch-keyed submit_root gate description (B2/B3) but the
generated markdown/json were never regenerated. make foundation-check
enforces artifact==generator(lifecycle.py); regenerating restores that.
No source change — pure generator output.

* [refactor] reduce xenon C-rank blocks to A (behavior-preserving)

Extract helpers / flatten conditionals in 11 blocks that rated C(11)+
under xenon --max-absolute B, dropping pr_gate.py module rank B->A in
the process. Pure move-and-call refactors: each extracted helper holds
the original logic verbatim and the caller delegates to it; no control
flow, return values, or side effects changed.

Sites: validators._extract_strs, sequencing.dev_task_collision_edges,
evidence_builder.build_task_handoff, intake_driver._coerce_draft,
task.claim_task_for_agent (2 guards), prompter.create_task_from_draft
(validate+assignee), pr_gate._gate_decision (3 helpers),
orchestrator._handle_stopped_container + _reap_with_service,
_impl._create_subtask_from_inputs + complete.

_impl helper returns tuple[TaskNature, list[str]] to preserve mypy
narrowing of acceptance_criteria at the TaskCreateRequest site.

Also fix vulture: rename unused __aexit__ param tb->_tb in
test_conventions_cache_put.py (was hidden while xenon short-circuited
the gate).

* [security] bash-guard uv run --active deny + CodeQL path-traversal fixes

Fix 1 (be-dev-1 brick prevention): bash-guard now denies 'uv run --active'
and 'uv run'/'uvx' against /app targets. In the agent container
VIRTUAL_ENV=/app/.venv is baked globally, so 'uv run --active' always
resolves onto the image-baked MCP-gateway venv and uv rebuilds it,
deleting /app/.venv/bin and bricking every MCP server spawn. Bare
'uv run' (workspace .venv, cwd-relative) is untouched.

CodeQL fixes:
- docs.py: replace bypassable '..' substring guard with a
  resolve-and-contain helper (_resolve_contained_path). An absolute
  path made pathlib reset (base / '/etc/passwd' == '/etc/passwd'),
  letting read_doc/delete_doc reach arbitrary files. Applied to both
  sinks.
- orchestrator.py: _safe_agent_path_segment at the spawn_agent
  chokepoint (rejects traversal-shaped agent_id before any fs op) and
  inside _remove_container (slug guard before the log-dir mkdir,
  defense-in-depth).
- agent_sdk/server.py: /usage/sync transcript_path now resolved and
  contained under ROBOCO_TRANSCRIPT_DIR with a .jsonl suffix requirement
  (was Path(raw) — unauthenticated endpoint could stat arbitrary files).

TDD RED->GREEN across all four; make quality green (4890 passed).

* [fix] enum-parity gate: drop false-green mask, skip empty/unmigrated DB

The foundation-check gate ran the enum verifier behind
`|| echo "(skipped — postgres unreachable)"`, which swallows ANY
non-zero exit — including real drift — and prints 'All quality gates
passed'. On a host with a dockerized but empty/unmigrated `roboco` DB
(0 tables: the agentrole/team enum types don't exist), the verifier
connected, found every foundation value 'missing', exited 1, and the
mask relabeled it 'skipped' → false-green.

Fix:
- scripts/verify_postgres_enums.py: move skip semantics INTO the script.
  Distinguish unreachable (skip, exit 0), DB-not-migrated/both-enum-types-
  absent (skip, exit 0), real drift (exit 1), match (exit 0). Extract
  pure enum_drift + should_skip_for_unmigrated helpers + a type_exists
  probe so an empty DB is 'no migrated target', not drift.
- Makefile: drop the `|| echo` mask — real drift now fails the gate.

TDD RED->GREEN (10 tests); make quality green (10906 passed).

* [security] docs path guard: reject '.'/empty segments for clean 400

_resolve_contained_path used an '..' substring ban, which (a) left rel='.'
passing the guard — read_doc/delete_doc then got the base DIRECTORY itself
and raised IsADirectoryError (500) instead of a clean ValidationError, and
(b) false-rejected legit filenames containing '..' like 'v1..v2.md'.

Replace the substring ban with a raw-segment check (rel.split('/')) that
rejects any '.', '..', or empty segment. Path(rel).parts was the wrong tool
— pathlib collapses '.' and empty segments on 3.13, hiding them. The split
check catches '.' / 'a/./b' / 'a//b' / '..' / 'a/../b' while allowing
'v1..v2.md' ('..' inside a filename, no bad segment). The post-resolve
parents-containment check (the real defense) is unchanged.

TDD RED->GREEN (4 new tests); make quality green (10910 passed).

Follow-up to the CodeQL path-traversal review: the two CodeQL 'High' alerts
on this guard are false-positives-on-the-fix (resolve-and-contain already
contains the bypass); this hardening closes the one genuine low residual
(rel='.' -> 500) the review surfaced, which CodeQL did not flag.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-29 05:38:21 +02:00
fe6c8e387f docs: sync prompts/RAG/CLAUDE + bump to 0.11.0 (run-hardening wave) (#254)
* docs: sync prompts/RAG/CLAUDE + bump to 0.11.0 for the run-hardening wave

Documentation + version sweep for everything shipped since 889f3689 (the 0.11.0
wave: MegaTask + #249-#253 run-hardening). Closes the doc drift behind the live
incidents — agents had no branch-behind-master guidance, so a Main PM invented
a bogus "rebase subtask".

Agent guidance (the headline gap):
- main_pm / cell_pm / developer prompts: a task branch is made current at CLAIM;
  there is NO rebase/pull/merge verb at the agent layer. Never create a "rebase
  subtask" or improvise git surgery; escalate a behind-base branch
  (developer: i_am_blocked; PM: escalate_up). "A rebase subtask is always a mistake."
- board prompt: Board has no unblock verb; a blocked task assigned to it is a
  mis-assignment -> escalate_to_ceo immediately, never sit on it (respawn loop).
- developer prompt: the shared clone is git-reset on a fresh claim; push/open_pr
  target the task branch by name regardless of the current checkout.
- RAG (git-errors, blocked-tools, pr-creation): branch-behind-base, "src refspec
  does not match any", and non-fast-forward recovery -> escalate, don't improvise.

CLAUDE.md: 9 shipped behaviors synced (session-limit parking, one-active-work-
session + migration 047, push/PR-by-name + origin ref recovery, fresh-claim
workspace reset, Board never owns a coordination root, verb-runner per-action
INVALID_STATE re-check, note fire-and-forget RAG indexing,
ROBOCO_GATEWAY_HEALTH_ENABLED flag, learnings not broadcast to human roles).

Version 0.10.0 -> 0.11.0: pyproject, roboco/__init__, config.app_version +
agent-image-tag example, panel/package.json, uv.lock, README/deploy examples;
CHANGELOG [Unreleased] cut to [0.11.0] - 2026-06-24.

* docs(site): document session-limit parking + the branch-behind-base operator flow

User-facing docs site updates for the 0.11.0 wave (the run-hardening behaviors
that are operator-visible):

- models/resilience.md: the Claude session-limit (5-hour usage window) parks
  and auto-revives like an overload, not just per-request 429s / 5xx overloads.
- troubleshooting/common-issues.md: same session-limit note on the parked-
  provider entries; plus a new "task stuck on a branch behind its base" entry —
  agents have no rebase verb so they escalate it; the operator rebases from the
  panel Git tab (auto-rebase-at-spawn is the roadmap cure).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 18:13:03 +02:00
889f3689e7 MegaTask (#248)
* feat(batch): batch_id + collision descriptor columns

Sequenced batch intake ("Mega task") foundation: tasks.batch_id (indexed)
groups a batch of top-level tasks created together; intends_to_touch (text[]),
adds_migration and touches_shared (bool, NOT NULL default false) are the
per-task collision surface the SequencingService will read to wire dependency
waves. Mirrored on the Task model + TaskCreateRequest and wired through
TaskService.create. Migration 046 (real upgrade->downgrade->upgrade verified
vs a throwaway pgvector PG); a non-batch task declares no surface (defaults).

Task 1 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): flag + draft collision descriptors

Default-off ROBOCO_BATCH_INTAKE_ENABLED (config + FEATURE_FLAGS + panel card);
the propose_draft tool doc + the TS DraftProposal gain the per-task collision
surface intends_to_touch / adds_migration / touches_shared. The draft is a loose
dict so the descriptors ride it through the relay intact (test asserts the
forwarded payload); the analyzer (Task 3) reads them to wire dependency waves.

Task 2 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): deterministic collision-sequencing analyzer

SequencingService.analyze turns a batch's per-task collision surfaces into a
dependency DAG + execution waves — correctness in CODE, not agent judgment.
Rules in order: file overlap serializes (more-important first), migrations form
a serial chain (no concurrent Alembic heads), touches_shared runs last, cell
contention warns (never serializes); then dedupe, existence + cycle check, and
Kahn topological layering. Pure (no DB/services); SequencingError on a cycle or
out-of-range edge.

Golden test reproduces the CEO's hand-sequenced 4 waves of the 11-item
guard-core-app batch (the effort that deadlocked the Main PM): S6 alone last,
the R1/R3/R4 migration chain, R2/R3/S8 serialized on the shared threat service,
S1/S2/S7 in one parallel wave.

Task 3 of the 0.11.0 sequenced-batch-intake plan.

* chore(batch): brand the user-facing surfaces "MegaTask"

The user-facing name is MegaTask: the feature-flag label is "MegaTask intake",
the panel flag-card and the config description lead with MegaTask. Internal
names stay technical (batch_intake_enabled, batch_id, SequencingService).

* chore(batch): drop the feature flag — MegaTask is a core intake scope

MegaTask is additive and opt-in by its own nature (the Prompter proposes a
batch only when the CEO asks for several tasks; single-task intake is
unchanged), so there is no risk surface a flag protects — 'don't create a
MegaTask' is the off switch. Remove batch_intake_enabled from config, the
FEATURE_FLAGS registry, the panel flag card, and its tests. MegaTask will be
a third scope option in the Intake modal (single-cell / multi-project /
MegaTask), not a toggle.

* feat(batch): MegaTask identity predicate + orchestrator branchless recognition

The single source of truth for the umbrella's exemptions: pure
is_batch_umbrella / is_batch_root_subtask / is_branchless_coordination
(foundation/policy/batch.py) — an umbrella has a batch_id and is top-level; a
root-subtask shares the batch_id but is parented. The orchestrator's
_is_coordination_task now consults is_branchless_coordination, so a MegaTask
umbrella is recognized as doing no git of its own (git-exempt at spawn-readiness
/ stuck-detection) exactly like a product fan-out root. Non-batch behavior is
identical (the predicate reduces to the old no-project+product check; the
orchestrator coordination suite stays green), and the umbrella branch is inert
until the create path exists.

First slice of the MegaTask umbrella enforcement (branchless guard).

* feat(batch): branchless umbrella guard across the git-exemption sites

A MegaTask umbrella does no git of its own — every git-exemption site in
TaskService now consults the shared is_branchless_coordination predicate
instead of an inline product-only check, so the umbrella's exemptions
cannot drift between sites:

- the claimed->in_progress branch gate (GitContext.is_coordination) lets
  an unbranched umbrella reach in_progress and delegate;
- _ensure_branch_for_task short-circuits an umbrella to "" instead of the
  misconfigured raise (the claim path ignores the return, treating it as
  branchless);
- CEO-reject routing sends a rejected umbrella to the Main PM in PENDING
  (needs_revision is developer-claim-only and would deadlock it).

Covers both shapes via the predicate (product fan-out root OR umbrella);
a batch root-subtask keeps its own branch/PR. Adds orchestrator
recognition tests for the umbrella plus claim/branch/reject integration
tests.

* feat(batch): umbrella assembles no PR; completes branchless

submit_root now hard-rejects a MegaTask umbrella up front (a preflight
that also folds in the unknown-role refusal to stay within the
return-count budget): the umbrella spans many projects with no single
master, so each root-subtask opens and is reviewed on its own PR — the
umbrella never enters the in-path review gate. The Main PM completes it
directly once every root-subtask is terminal.

Umbrella completion needs no new code: it is branchless (no branch_name),
so _main_pm_complete_guard already accepts it from in_progress, checks
all_subtasks_terminal, and main_pm_complete walks it to awaiting_pm_review
and escalates to the CEO with no PR creation — exactly the product
fan-out root path. Adds the submit_root-reject and umbrella-completion
gateway tests; pins batch_id=None on the normal-root submit_root test
(a MagicMock auto-attr would otherwise read as an umbrella).

* feat(batch): MegaTask create path — umbrella + sequenced root-subtasks

PrompterService.confirm_live_batch turns N confirmed drafts into a real
MegaTask: it builds each draft's collision surface, runs the pure
SequencingService to get conflict-free waves, creates the branchless
umbrella (batch_id, no project/product), then one root-subtask per draft
(own project, parent=umbrella, sequence=wave index, descriptors), and
wires the analyzer's edges through add_dependency so the existing
dependency-gate runs the waves in order. The route picks the start path
like a single confirm: 'board' holds the root-subtasks in BACKLOG for the
batch review; 'main_pm' creates them PENDING so wave 0 dispatches at once.

create_task_from_draft gains a BatchPlacement (parent/batch/sequence/
team_override) and forwards the collision descriptors; the exactly-one-
target rule (here and the TaskService.create invariant) is relaxed for an
umbrella, which legitimately targets neither. New route
POST /live/{session}/confirm-batch + BatchConfirmRequest mirror the single
confirm. Adds the structural-invariant + board-hold + empty-batch tests.

* feat(batch): release MegaTask root-subtasks on CEO approval; board awareness

The board route holds a MegaTask's root-subtasks in BACKLOG so the work
waits for the batch review. approve_and_start (CEO gate #1, board->Main PM)
now releases them via _activate_batch_root_subtasks: each held child flips
BACKLOG -> PENDING + team=main_pm so the dependency-gate dispatches wave 0.
No-op for a non-umbrella; idempotent (children past BACKLOG untouched).

The Product Owner and Head of Marketing identity prompts gain a MegaTask
section so they review the whole batch + wave plan and adjust scope before
sign-off (they review drafts; the umbrella is their unit). Also extracts
the create() target invariant into _require_target_or_umbrella to keep the
method under the complexity gate after the umbrella exemption. Adds the
umbrella-approval activation test.

* feat(batch): multi-project intake scope for MegaTask

A MegaTask spans several possibly-unrelated repos, so the intake chat can
now be scoped to an explicit project list (not just one project or one
product). StartLiveRequest gains project_ids; /live/start threads it
through start/spawn_intake_session -> _spawn_intake_container ->
_clone_intake_scope. The multi-repo clone machinery already existed for
products; _intake_scope_slugs now also resolves an explicit project_ids
set (split into _slugs_for_project_ids / _slugs_for_product), cloning each
repo with the first as the primary cwd and the siblings readable. Scope
validation is now 'exactly one of project_slug / product_id / project_ids'
via the shared _require_one_intake_scope. Adds scope-resolution, spawn,
and route tests for the MegaTask path.

* feat(batch): propose_batch intake tool (MegaTask multi-draft hand-off)

The intake agent can now hand the panel a whole MegaTask in one tool call.
Both intake paths gain propose_batch alongside propose_draft:
- Claude (intake_driver): a propose_batch tool registered on the in-SDK
  MCP server + allowlisted; the driver intercepts the ToolUseBlock and
  emits ONE StreamChunk(kind="batch") carrying {drafts:[...], title}.
- grok (intake_server): a propose_batch tool that POSTs a "batch" relay
  event via the shared _post_event helper (post_draft/post_batch).

A batch carries N drafts, each the propose_draft shape PLUS its own
project_id (a MegaTask spans unrelated repos) and collision surface so the
analyzer sequences the waves. The prompter prompt documents the MegaTask
scope + when to call propose_batch. Adds Claude-normalize and grok-relay
tests for the batch path.

* feat(batch): MegaTask intake panel — third scope, batch review, waves

The panel now drives a MegaTask end to end. The intake modal gains a
third scope, 'MegaTask', beside Single cell and Board-led: a multi-project
checklist (a MegaTask spans several possibly-unrelated repos), validated
to at least two. start() sends project_ids; use-prompter accumulates the
agent's single propose_batch hand-off as a 'batch' SSE event into a
BatchProposal and lands in a new batch_preview state.

A new BatchReviewCard lists every proposed task with its target project +
collision-surface badges (migration / shared) and offers one start path
for the whole batch — Board review & Start or Approve & Start — wired to
confirmBatch → POST /confirm-batch. The success card shows the sequenced
result: N tasks in M waves (+ any advisory notes). prompter.ts gains the
DraftScale 'megatask' + the BatchConfirm payload/result types; the SSE
client allows the 'batch' kind. Panel typecheck + lint + 113 tests green.

* docs(batch): MegaTask across changelog, CLAUDE.md, site, and RAG

The four documentation obligations for the MegaTask feature:
- CHANGELOG: an Unreleased entry covering the umbrella model, sequencing,
  multi-project intake, propose_batch, and the create/approval path.
- CLAUDE.md: a MegaTask section (identity predicate, umbrella/root-subtask
  hierarchy, sequencing rules, intake + create path, board activation).
- Published site: a user-facing company/megatask.md (scopes, waves, the
  umbrella, the two start buttons) + nav entry; a pointer added to the
  intake chapter of the Tour.
- RAG corpus: workflows/megatask.md so the Main PM (and any agent) can
  retrieve the umbrella's branchless / no-PR / completion rules at runtime.

The runtime concurrent-migration guard is intentionally NOT added: the
analyzer already chains migration-adders into dependencies and the
dependency-gate serializes them, so a separate guard would be dead code.

* feat(batch): batch_id guardrail + wave preview + batch_id on TaskResponse

Guardrail (CEO): a batch_id is denied on any task that is not a well-formed
MegaTask member. is_valid_batch_shape permits batch_id only on an umbrella
(no parent → must target neither project nor product) or a root-subtask
(has a parent → exactly one target); TaskService.create enforces it AND
verifies a root-subtask's parent is the batch umbrella (same batch_id,
top-level). This closes a latent hole: is_batch_umbrella is true for a
batch_id + no-parent task even with a project, so a stray batch_id could
have spoofed the branchless branch-gate / no-PR exemption. (The public
task API never exposed batch_id for write; this guards the service layer.)

Wave preview: PrompterService.preview_batch + POST .../preview-batch
compute a MegaTask's waves from the proposed drafts WITHOUT creating
anything, so the panel can show the sequencing before confirm. Extracted
_sequence_drafts as the single source shared by preview and confirm, so
the previewed waves are exactly the ones wired.

TaskResponse now carries batch_id so the panel can badge the umbrella.

* feat(batch): MegaTask review — project editor, wave preview, persistence, badge

Closes the panel gaps in the MegaTask review experience:
- Per-task project editor: each proposed task gets an inline project
  Select (updateBatchDraftProject), so a task the agent put in the wrong
  or no repo can be fixed before launch — not only by re-chatting. Launch
  stays blocked until every task has a project.
- Wave preview: on a batch proposal the panel fetches POST .../preview-batch
  (no task created) and shows the conflict-free wave plan, so the human
  reviews the sequencing before confirming.
- Refresh durability: the MegaTask review (batch + waves + projectIds) is
  persisted, so a browser reload mid-review restores it like a single draft.
- MegaTask badge: TaskResponse exposes batch_id, the panel Task type
  carries it, and the task table badges the umbrella row 'MegaTask'.

Panel typecheck + lint + 113 tests green.

* test(batch): stub task carries batch_id for task_to_response

task_to_response now serializes batch_id (TaskResponse field), so the
_stub_task SimpleNamespace fixture must provide it — without it the reader
hit AttributeError, failing the 8 task-schema serialization/enrichment
tests. Test-only; the real TaskTable carries the column (migration 046).

* fix(batch): close MegaTask audit gaps — completion crash, analyzer cycle, guardrails

An adversarial multi-agent audit of the feature surfaced 20 verified gaps;
this closes the backend ones.

HIGH:
- Umbrella completion crashed. escalate_to_ceo hard-required a pr_number,
  which a branchless umbrella never has, so main_pm_complete dereferenced
  None. Both pr_number gates now waive a MegaTask umbrella (escalate_to_ceo
  + the awaiting_pm_review->awaiting_ceo_approval lifecycle gate via a new
  GitContext.is_umbrella), and main_pm_complete guards a None return. The
  completion test had mocked escalate_to_ceo, hiding it — now a real
  service test covers the waiver.
- The collision analyzer could fabricate a cycle (a touches_shared +
  adds_migration draft overlapping another migration draft) and raise
  SequencingError — a bare ValueError that escaped as an opaque 500. The
  migration chain is now shared-last-aware (never contradicts rule 3), and
  _sequence_drafts translates SequencingError to a clean 400.

MEDIUM:
- Collisions are now project-scoped: two repos can't collide on a
  coincidental path or serialize independent migrations (DraftSurface
  carries project_id; rules 1/2/3 respect it).
- The batch_id guardrail ran only at create. update() + the PATCH
  null-clear path now re-assert is_valid_batch_shape, so a mutation can't
  break a member's shape and spoof the branchless exemption.
- A draft missing title/acceptance_criteria now raises ValidationError
  (was a bare KeyError -> 500).
- confirm_live_batch re-asserts every draft targets a scoped project and
  the batch spans >=2 distinct projects (project_ids added to the request).
- Route-level tests for confirm-batch / preview-batch.

LOW: strict multi-repo clone (fail loud on any unresolvable project);
malformed/empty propose_batch surfaces an error chunk (Claude) / refuses
to POST (grok) instead of silently acking; dropped malformed drafts are
counted and surfaced; stale grok intake docstrings updated.

* fix(batch): MegaTask panel + doc audit gaps

Frontend half of the audit fixes:
- The confirm payload now carries project_ids (the schema requires it), and
  the panel re-checks every task targets one of the scoped repos before
  launching, naming the offending task.
- The Review-MegaTask project picker is filtered to the scoped repos and
  the per-task validity (border + launch gate) keys off scoped membership,
  so a task can only be (re)pointed at an in-scope project — also fixing the
  case where the agent emitted a non-UUID / unknown project.
- Dropped malformed drafts are surfaced as a chat error so the human knows
  the batch shrank instead of silently confirming fewer tasks.
- Doc wording: a wave releases on the previous wave's terminal state
  (normally a merge; a cancellation releases it too), not strictly 'merged'.

* test(batch): lock the CEO's EXACT 4-wave hand-sequencing as the golden bar

The golden test asserted the constraints (S6 last, the migration chain, the
shared-threats serialization, S1/S2/S7 parallel) but not the full wave
partition. The bar for MegaTask is 'reproduce my exact waves or it's not
done', so assert the exact 4-wave partition the analyzer produces for the
guard-core-app batch:
  wave 1: R1 R2 S1 S2 S3 S5 S7  ·  wave 2: R3  ·  wave 3: R4 S8  ·  wave 4: S6
Confirmed unchanged by the audit's analyzer fixes (no migration is shared;
single project).

* fix(batch): tolerate a stub task in assert_batch_shape_intact

The batch-shape re-validation read task.batch_id directly, but update()'s
partial-caller contract is exercised with a SimpleNamespace stub that has no
batch_id column → AttributeError. Use getattr(..., None) for batch_id and the
shape fields so the guard no-ops on any task lacking the column (a stub, or a
non-batch task) while still enforcing on a real batch member.

* fix(orchestrator): authenticate internal API self-calls with the system identity

The dispatcher httpx clients were built without an agent identity, so the
orchestrator's self-PATCHes to /api/tasks/{id} (auto-block, auto-resume,
auto-recover, SLA annotation) were rejected 401 "Missing X-Agent-ID" and
silently no-op'd. The auto-resume that lifts a PM's paused parent could never
write, so paused/blocked parents stayed wedged and stranded their dependents
(the fe-pm/be-pm respawn churn seen in prod).

Header propagation was inconsistent across the separate AsyncClient call-sites:
only the main dispatch client carried the system identity; the readiness and
sweep clients did not. Hoist the identity into a shared _SYSTEM_API_HEADERS
constant and apply it to every API-facing dispatcher client. The system role
holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
path those write routes use. The external provider-recovery probe client is
intentionally left untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 01:15:57 +02:00
Renn F f1b197def0 docs(conventions): dev-first framing + flag-enable + accuracy sweep
The conventions standard is dev-first by design — the developer receives the
architecture map + per-task constraints at spawn and owns conforming code from
the start; QA and the PR reviewer are the downstream net. Make that explicit in
the developer prompt and add a dedicated conventions section to the RAG
developer doc (it previously only mentioned the gate, reactively).

Also reconcile the docs with the hardened behavior: env-reference now shows the
flag is off by config default but on in the compose orchestrator block (left off
in the registry), mirroring toolchain matching; and the RAG standard's example
comment no longer implies a misplaced helper blocks (it warns).
2026-06-22 21:20:42 +02:00
Renn F 17ec52d1b7 feat(conventions): generalize defaults, backfill old projects, adopt the standard in-repo
Harden the architectural-conventions standard so it works out-of-the-box on
any project and resolves for projects that predate it, and make RoboCo pass
its own gate.

General defaults (apply to every project, not just one with a tuned file):
- The auto-scan excludes test and documentation trees (tests/, docs/) — those
  legitimately define fixtures and aren't enforced code.
- Helper placement seeds at warn, not block: `helper` matches any top-level
  function, too blunt a signal to hard-block a route file's small private glue.
  Misplaced model/route/component stay block; the body-level thin_routes check
  remains the real fat-handler guard.
- thin_routes no longer counts transaction-lifecycle calls (commit/flush/
  refresh) as data access — an explicit `db.commit()` after delegating to a
  service is a valid pattern.
- no_lint_suppressions exempts a small allowlist of structurally-unavoidable
  framework codes (ruff TC001-TC003, pydantic prop-decorator); bare or other
  suppressions still flag.
- CLAUDE.md rule-lifting skips bare common-word tokens that would match
  everywhere (e.g. "commit"), keeping only specific identifiers.
- The ambient prompt block lists only constrained modules and truncates at a
  line boundary with a "+N more" pointer instead of cutting mid-line.

Backfill: the standard previously read the committed file + repo scan from
project.workspace_path, a field only a manual API call set — so an older
project (or one whose workspace was cleared) showed an empty "missing" map no
matter what was pushed. The service now ensures a dedicated, default-branch
read clone on demand (WorkspaceService.ensure_read_clone) and resolves from
it, persisting the resolved path + real HEAD. The panel tab, the spawn-time
ambient block, and the per-task constraints all resolve the committed standard
with no manual setup.

Adopt in-repo: relocate the inline request/response models from the system and
*_live route modules into roboco/api/schemas/ so the codebase passes its own
placement gate, and ship a canonical .roboco/conventions.yml. no_models_in_routes
and modular_cohesion are now clean and enforced at block.

Docs updated across the user guide, the agent-facing RAG standard, the
developer and pr_reviewer role prompts, CLAUDE.md, and the changelog. New unit
tests cover the scan exclusions, helper-warn, the suppression allowlist, the
commit exemption, and the resolve/backfill path; the conventions + project
integration suites pass against Postgres.
2026-06-22 18:15:19 +02:00
Renn F 3baac6dcd8 docs(conventions): document the modularity checks across RAG, prompts, and lifecycle diagrams
Make the docs and role prompts match the shipped modularity enforcement. The standards doc gains a Modularity section (cohesion / thin routes / thin components / god class, scan-derived + language-aware); the developer prompt tells agents to write modular code (thin routes that delegate, one concern per file, components that delegate to hooks) and that block-level findings refuse i_am_done; QA + PR-reviewer prompts note the modularity findings in evidence / the pr_pass block. Also fixes the two lifecycle diagrams (usage.md, roboco/models/README.md) that omitted the awaiting_pr_review gate.
2026-06-22 14:38:51 +02:00
16789c1ca7 Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge

* feat(conventions): tree-sitter Python classifier + placement checks

* feat(conventions): TS classifier, hygiene/custom checks, runner + CLI

* feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration

* feat(conventions): repo auto-scan + scaffold draft renderer

* feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore)

* feat(conventions): auto-scaffold on project registration (flag-gated)

* feat(conventions): TaskDescription.constraints + auto-baseline attach

* feat(conventions): ambient architecture-map injection at spawn

* test(conventions): subprocess CLI smoke for the agent-image entrypoint

* feat(conventions): block i_am_done on block-level convention violations

* feat(conventions): block pr_pass on unresolved convention violations

* feat(conventions): surface convention findings into QA evidence

* docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer

* feat(conventions): panel Conventions tab + flag toggle + parity

* test(conventions): end-to-end block, fix, and waiver through the gate

* refactor(conventions): extract pr_pass guards to keep pr_gate under the gate

* style(conventions): format the baseline-constraints attach in task.create

* test(conventions): type-annotate test helpers for the full mypy gate

* build(conventions): ignore types-PyYAML in deptry (mypy-only type stub)

* docs(conventions): document the standard in CLAUDE.md + PM prompt awareness

* fix(conventions): baseline constraints are non-suppressible (dedup-append)

* feat(conventions): scaffold on first workspace clone (threaded workspace)

* feat(conventions): multi-project ambient map for PO/Intake (per-product)

* feat(conventions): persist findings + violations-feed route (migration 044)

* feat(conventions): panel violations feed in the Conventions tab

* test(conventions): intake-spawn mock accepts the ambient layer kwarg

* fix(docker): ollama-init best-effort pull, gate startup on cached models present

A degraded/slow ollama registry made the model manifest re-check fail under
set -e, so ollama-init exited 1 and blocked the orchestrator's
service_completed_successfully gate — taking the whole stack down even though
both models were already cached. Pulls are now best-effort; success is gated on
the models being present, so a flaky registry can't down a cached deployment.

* refactor(content): drop dead TaskDescription.with_baseline_constraints

The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-22 12:37:46 +02:00
Renn F 77c94b74da docs(prompts): announce the note-section obligations to agents
The obligations added in 8cf69781 (dev_notes@i_am_done, quick_context@delegate,
pr_reviewer_notes@pr verbs, auditor@i_am_idle) were only discoverable at
runtime via the gate's remediate field. Surface them upfront so agents satisfy
them on the first call instead of looping into a tracing_gap:

- base.md: the gap-key reference gains rows for dev_notes>=min /
  quick_context>=min / pr_reviewer_notes>=min (parity with the journal rows).
- developer.md: a note(scope='handoff') step before i_am_done, and dev_notes
  added to the i_am_done precondition list.
- cell_pm.md / main_pm.md: fill quick_context (done+next) before the first
  delegate (it persists across the whole queue).
- auditor.md: must record an observation before i_am_idle.

The pre-write cases (dev_notes, quick_context, auditor) carry the real loop
risk; pr_reviewer / doc notes are satisfied by the verb's own argument, so the
base.md row alone suffices for those.
2026-06-21 20:45:29 +02:00
Renn F 11c1d9eee7 feat(content): structured PR-review findings + generated GitHub comment 2026-06-21 03:47:17 +02:00
fa3e25e656 feat(grok): pluggable agent providers + Grok on the official grok CLI (#218)
* feat(providers): pluggable agent providers + Grok (xAI) backend

Add a roboco/llm/providers/ seam — an AgentProvider lifecycle ABC and a
ProviderRegistry keyed by ModelProvider — so the orchestrator can drive
agent backends other than Claude Code.

The first non-Claude backend is GrokProvider for xAI's grok-build-0.1.
xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok
agent runs an OpenAI-protocol runtime pointed at https://api.x.ai/v1
rather than the ANTHROPIC_BASE_URL injection the other providers use. It
reuses the orchestrator's existing mount/auth assembly, so it inherits the
same MCP gateway + tool-manifest wiring as every other agent by
construction, and passes its prompt via env (never an argv positional).

The change is purely additive: only GROK routes through the registry;
Anthropic / Ollama Cloud / self-hosted spawns run the existing
_spawn_container path unchanged.

Includes:
- ModelProvider.GROK (migration 038) + a seeded Grok provider row
  (migration 039) + a grok-build-0.1 catalog entry
- GET/PUT /api/providers/grok-key to store the xAI key (Fernet-encrypted,
  reusing the existing provider-key machinery)
- ClaudeCodeProvider reference adapter over the current spawn
- unit tests for the registry, GrokProvider (gateway wiring, no
  ANTHROPIC_* leak, prompt-injection safety, failure paths) and routing

The dedicated roboco-agent-grok image and the exact OpenAI-protocol CLI
invocation are the remaining piece to finalise with xAI.

* feat(providers): native Grok runtime — opencode image, config gen, panel key

Complete the native Grok (xAI) path so grok-build-0.1 runs as a real
RoboCo agent, not just the provider seam.

- roboco-agent-grok image (docker/agent-grok.Dockerfile): FROM agent-base
  + opencode (the OpenAI-protocol runtime). One image serves every role;
  role behaviour comes from the mounted manifest / mcp-config / system
  prompt, exactly as on the Claude path.
- Entrypoint renders opencode.json at spawn from the GrokProvider env
  contract + the mounted Claude Code mcp-config.json
  (roboco.llm.providers.opencode_config): translates RoboCo's gateway
  servers (roboco-flow / roboco-do / ...) into opencode's mcp block,
  declares the xAI OpenAI-compatible provider + model, and wires
  permissions + instructions. Pure, unit-tested translation.
- Orchestrator registers GrokProvider with the registry-qualified image
  (_qualify_agent_image) so it resolves in local and registry deploys.
- Compose (both files + the registry compose) gain an agent-grok-image
  builder service.
- Panel: a Grok (xAI) API key card on the AI Providers page, plus the
  grok ModelProvider value.

KNOWN PARITY GAP (opencode runtime): the bash-guard PAT-scrub and the
transcript-based usage/cost capture are Claude Code hooks and do not
transfer to opencode. bash permission is operator-tunable
(ROBOCO_GROK_BASH_PERMISSION) so a deployment can fail closed until a
security/usage-parity opencode plugin lands. That plugin and live E2E
validation are the remaining work to finalize with xAI.

* ci(release): build + publish the roboco-agent-grok image

Add roboco-agent-grok to the release workflow's image build/publish map so
registry deploys carry the Grok runtime image (parity with every other
agent image). Split from the feature commit because pushing a workflow
change requires a workflow-scoped token.

* fix(migration): commit the grok enum value before seeding (autocommit_block)

CI's "Apply database migrations" failed with asyncpg
UnsafeNewEnumValueUsageError: alembic runs the whole upgrade in a single
transaction, so migration 039's INSERT used 'grok' in the same transaction
that 038 added it — which Postgres forbids. Splitting into two migration
files did not help (one transaction spans both). Wrap the ALTER TYPE ADD
VALUE in op.get_context().autocommit_block() so the value commits before 039
(and any later migration) uses it. Still renders in offline --sql, so the
enum-migration-parity test is unaffected.

* feat(grok): price grok-build-0.1 + secret-scrub opencode plugin

- pricing.py: add grok-build-0.1 rates ($1/1M input, $0.20 cached, $2/1M
  output), verified against xAI's published pricing. Grok is a priced
  non-Anthropic model, so cost computes the moment usage is captured.
- secret-scrub.js: an opencode tool.execute.before plugin porting the
  security-critical bash-guard deny rules (git network ops, credential-file
  reads, /proc env, internal-host HTTP, roboco.* imports, ROBOCO_AGENT_ID
  forgery, env dumps, destructive rm) to the opencode runtime — restoring the
  guard the Claude Code hook can't provide there. Throwing denies the call
  (confirmed by opencode's env-protection example). Wired into the generated
  opencode.json plugin array + baked into the grok image.

Deny logic verified via node (9 deny + 5 allow cases). UNVALIDATED against a
live opencode runtime: confirm it fires in the live E2E spawn before a Grok
dev-agent touches a real repo; the bash permission is operator-tunable as a
second gate.

Cost CAPTURE (distinct from pricing) is intentionally NOT built yet: opencode's
plugin hooks expose model info but no token/usage object, so the capture path
is unconfirmed and needs the live spawn to settle.

* feat(grok): read opencode session usage for cost capture

Confirmed by inspecting a local opencode run: opencode persists per-session
usage in SQLite at ~/.local/share/opencode/opencode.db — the `session` table
carries cost + tokens_input/output/reasoning/cache_read/cache_write. xAI's
response usage object (prompt_tokens, completion_tokens,
prompt_tokens_details.cached_tokens, completion_tokens_details.reasoning_tokens)
maps directly onto those columns.

Add opencode_usage.read_session_usage / cost_for_session: read the opencode DB
and price the tokens via roboco.billing.pricing (our cost stays authoritative;
opencode's own `cost` column is kept for reference). Tested against a fixture DB
mirroring the real schema (single session, summed sessions, missing/empty DB).

Remaining wiring (for the live spawn): mount the opencode data dir on grok
spawn + call cost_for_session at reap to record the usage rollup.

* fix(grok): correct opencode provider (Responses API), stdin, reasoning cost

A live opencode run against api.x.ai/v1 surfaced three real bugs:

1. Provider package — grok-build-0.1 is driven via the OpenAI Responses API
   (opencode calls model.responses()). @ai-sdk/openai-compatible is
   chat/completions only and errors "responses is not a function". Switch the
   generated opencode.json provider + the grok image to @ai-sdk/openai.
2. Headless hang — `opencode run` blocks after init without a TTY; close stdin
   (`< /dev/null`) in the entrypoint so it proceeds to the model call.
3. Reasoning-token cost — grok-build-0.1 is a reasoning model; reasoning tokens
   bill as output but opencode stores them in a separate column. cost_for_session
   folds tokens_reasoning into output (else ~22x undercount).

Verified end-to-end against a real session row (input=6120, output=1,
reasoning=226, cache_read=1856): our pricing reproduces opencode's stored USD
cost ($0.0069452) exactly. Tests anchored to that real row.

* feat(grok): first-class xAI/Grok routing mode (UI + backend)

The Routing-mode toggle had Anthropic / Ollama / Self-Hosted / Mix but no way
to route the whole org to Grok. Add it end to end:

- backend: apply_mode("grok") + _apply_grok (GLOBAL default -> grok-build-0.1) +
  derive_mode "grok" detection; ApplyModeRequest/ModeResponse accept "grok".
- panel: a "Grok" routing-mode card (between Anthropic and Ollama, gated on the
  xAI key) + flipToGrok; a Grok group in the per-agent mix dropdown +
  catalogGrokOnly + a grok ProviderBadge variant; the mix-save key check and
  the AI-routing description now cover Grok.
- tests: integration derive_mode/apply_mode "grok" cases (+ grok provider row
  in the fixture).

Gated: ruff + mypy clean; panel typecheck + lint clean.

* feat(grok): reasoning-effort by role (cut grok-build cost on cheap roles)

grok-build-0.1 reasons heavily by default and reasoning bills at the output
rate (a live "say ok" call emitted ~300 reasoning tokens, ~85% of its cost).
Confirmed live that opencode's `--variant minimal` cuts reasoning ~54%
(298 -> 136 tokens, same prompt).

GrokProvider now picks reasoning effort by role: code-quality roles (developer,
qa, pr_reviewer) keep full reasoning; coordination / docs / board roles
(cell_pm, main_pm, documenter, product_owner, head_marketing, auditor, prompter,
secretary) run "minimal". It's passed to opencode via the entrypoint's
`--variant`. Operators can force one effort for ALL grok agents with the
ROBOCO_GROK_REASONING_EFFORT env (minimal | high | max, or default/full).

Tests cover the role map, the env override, and the spawn env wiring.

* style(panel): show the Grok (xAI) key card above the Ollama card

* fix(grok): stop opencode subagent-stream hang at the config layer

The Grok pr_reviewer wedged in_progress forever: opencode's default agent ran
with the subagent `task` tool enabled, spawned an Explore subagent on
grok-build-0.1 whose model call opened an SSE stream that went idle, and the
run hung with no timeout.

- Hard-disable opencode's subagent `task` tool in the generated opencode.json.
  No RoboCo role uses opencode-internal subagents — work flows through the
  gateway verbs — so removing the tool kills the hang trigger outright.
- Set provider.xai.options.timeout + chunkTimeout (operator-tunable via
  ROBOCO_GROK_REQUEST_TIMEOUT_MS / ROBOCO_GROK_CHUNK_TIMEOUT_MS) as the
  defence-in-depth backstop; chunkTimeout aborts an idle stream.
- Bundle the permission + timeout + subagent knobs into an OpencodeGuards
  dataclass (keeps the builder under the arg-count gate).
- Drop the dead ROBOCO_AGENT_TOOLS spawn env (it had no consumer); opencode
  tool restriction lives in the rendered config now.

* feat(grok): reaper watchdog kills wedged opencode containers

The heartbeat reaper deliberately skips a task whose assignee holds a live
ACTIVE container, so a Claude agent deep in a long edit/test cycle isn't
churned out from under live work. A wedged opencode container breaks that
assumption: it stays ACTIVE while firing no gateway verb, so its heartbeat
never advances and the live-instance skip would shield its task forever — the
exact way the Grok pr_reviewer parked in_progress.

Add a longer grok-idle kill threshold (ROBOCO_GROK_IDLE_KILL_SECONDS, default
900s, well past the stream chunk timeout). A GROK instance idle past it is
force-removed (its logs dumped to disk first) and evicted from the instance
registry, so the same reaper pass then releases the task. Only GROK runtimes
are eligible — a quiet Claude agent keeps the heartbeat-skip protection.

* feat(grok): guard interactive roles from GROK routes (interim)

intake (prompter) and secretary run a held-open chat session driven by the
Claude Agent SDK. GROK has no interactive runtime yet, and a GROK route for
those slugs would be spawned with the route creds injected as ANTHROPIC_*
against api.x.ai/v1 — the wrong protocol — producing a silent, empty reply
(the blank intake we observed).

Downgrade a GROK route for intake-1/secretary-1 to the Anthropic default with
a logged warning. The one-shot delivery roles route to GROK unchanged. This
guard is replaced by the real interactive fork once the opencode interactive
driver lands.

* feat(grok): capture one-shot Grok usage/cost from the opencode store

A GROK agent runs opencode, not Claude Code: it has no SDK /usage/status
server and writes no Claude transcript, so _resolve_final_token_usage found
nothing and every Grok agent finalized at 0 tokens / $0 — the opencode_usage
reader existed but had no caller.

- Mount a per-agent opencode data dir ($DATA/opencode/<agent_id> →
  /home/agent/.local/share/opencode) so opencode.db is captured, and mount the
  same host dir into the orchestrator (/data/opencode) in all three compose
  files so the finalizer can read it back — the opencode analogue of the
  mounted Claude transcript.
- _resolve_final_token_usage branches on provider_type: GROK reads opencode.db
  via opencode_usage (reasoning folded into output, billed at the output rate)
  and skips the SDK/transcript path. A 0-token read logs a WARNING so a silent
  mount failure isn't mistaken for a real zero-cost run.
- ROBOCO_OPENCODE_DATA_DIR overrides the in-orchestrator path for local runs.

* feat(grok): make interactive spawns first-class on AgentProvider (additive)

The AgentProvider ABC modelled only the one-shot lifecycle (spawn/stop/
health_check/remove), so the interactive intake/secretary roles could never
route through a provider. Add an opt-in interactive surface:

- supports_interactive class flag (default False).
- InteractiveSpawnSpec: the resolved AgentConfig + session id + role-specific
  image + optional HMAC token — everything a provider needs without importing
  orchestrator internals.
- spawn_interactive(spec): a non-abstract default that declines via
  ProviderError, so every existing one-shot provider is unchanged.

Pure scaffolding — no provider opts in yet (GrokProvider flips the flag when
its interactive driver lands). Zero behavioural change.

* feat(grok): Grok-native interactive runtime (opencode serve) — container side

Builds the Grok analogue of the Claude intake/secretary live-session runtime,
satisfying the same IntakeSession seam so the existing IntakeDriver loop,
message source, relay, and StreamChunk panel contract are reused unchanged:

- OpencodeServeSession: a held-open `opencode serve` session (context persists
  across turns) where each human turn is one synchronous POST /session/:id/
  message; normalize_opencode_message maps the reply parts to text/thinking/
  tool_use/draft/turn_end chunks (draft via a propose_draft tool part or the
  fenced roboco-draft fallback). Doc-verified against opencode's server API.
- grok_intake_main / grok_secretary_main: container entrypoints mirroring the
  Claude mains but yielding an OpencodeServeSession; they render opencode.json
  (xAI provider + MCP + system prompt) first, then run the receiver + driver.
- roboco-agent-grok-prompter / -secretary images (FROM roboco-agent-grok) +
  their builder services in all three compose files.

UNVERIFIED-LIVE: the opencode serve flow + exact Part schema + draft path need
a live run against grok-build-0.1 (the part mapping is defensive). The
orchestrator wiring that routes a GROK intake/secretary route to these images
is the next step (a design decision is open — see the handoff notes).

* feat(grok): route interactive intake/secretary to opencode-serve images

Wire the GROK interactive path the in-place way (matching how the interactive
roles already choose ANTHROPIC_* per route), so a GROK route launches the
Grok-native opencode-serve image instead of the Claude SDK-driver image:

- _spawn_intake_container / _spawn_secretary_container pick the
  grok-prompter / grok-secretary image (ensuring the base→grok→interactive
  build chain) when the route is GROK, and stamp provider_type on the spec +
  AgentConfig so finalize routes usage to the opencode store.
- _build_intake_run_cmd / _build_secretary_run_cmd inject OPENAI_* + the
  opencode store mount + system-prompt env for GROK via a shared
  _append_interactive_provider_env, keeping ANTHROPIC_* for every other
  provider. The intake's minimal mounts (no gateway MCP) are preserved, so
  Grok intake matches the Claude intake's tool surface (the spec).
- Add a per-agent opencode store mount to the interactive host paths so
  interactive Grok usage/cost is captured like the one-shot path.

Removes the interim Phase-0 routing guard (the real path supersedes it) and
retires the unused AgentProvider.spawn_interactive/InteractiveSpawnSpec seam —
the interactive roles have a bespoke assembly that the one-shot provider
surface doesn't fit, so the fork lives in their own builders.

UNVERIFIED-LIVE: end-to-end intake/secretary chat on Grok needs the stack up +
opencode serve confirmed against grok-build-0.1.

* feat(grok): surface intake/secretary in the mix-mode picker; doc guardrail parity

- Panel: add intake-1 (prompter) and secretary-1 to the mix-mode per-agent
  routing list so an operator can assign Grok (or Claude) to the interactive
  roles from the UI; assigning a Grok model routes them to the opencode-serve
  image. tsc + eslint clean.
- opencode_config: correct the now-stale parity note — bash-guard is ported
  (secret-scrub.js) and usage/cost is captured (opencode store); the remaining
  gap is the budget/loop/stop/prompt-injection hooks, which need a sidecar
  plugin (open decision), with ROBOCO_GROK_BASH_PERMISSION as the interim gate.

* test(grok): mypy-clean the reaper watchdog + interactive spawn tests

The CI mypy scope (roboco/ tests/) flagged test-only typing issues my per-file
runs missed: direct method assignment (orch._remove_container = AsyncMock())
trips [method-assign], and a module-level dict[str,str] is invariant against
the dict[str, str|None] the run-spec expects.

- Use monkeypatch.setattr for _remove_container in the watchdog tests.
- Annotate the shared _HOSTS as dict[str, str | None].

Production code unchanged; mypy roboco/ tests/ is green.

* feat(grok): cost-ceiling kill-switch (budget-guardrail parity)

Claude Code's per-agent token-budget hook fires against the SDK :9000 server;
opencode exposes NO usage/budget hook to a plugin (confirmed against its plugin
docs), so the budget kill-switch can't be a plugin/sidecar — the orchestrator
enforces it instead.

_enforce_grok_cost_budget runs each dispatch tick: for every ACTIVE GROK
container it reads cumulative cost from the opencode store (the Phase-2 reader)
and kills + evicts it past ROBOCO_GROK_MAX_COST_USD (0 = off), after which the
reaper releases the freed task. This also catches a runaway loop that keeps
firing verbs (so it evades the idle watchdog) but still burns cost.

Covers the budget/runaway-burn slice of guardrail parity. The remaining Claude
hooks (prompt-injection PRE-gate, stop-guard terminal-verb) have no blocking
opencode equivalent — opencode's message/stop hooks are observe-only — and the
interactive reasoning-variant has no opencode.json/serve knob (CLI-flag only);
both are pinned for a live probe rather than shipped as a guess.

* docs(grok): document Grok's reduced guardrail posture (honest, not blocking)

Grok agents run on opencode, not Claude Code, so they do NOT have full
guardrail parity — claiming otherwise would be false. Document it truthfully
and keep them usable rather than blocking them.

- Panel routing card: an amber caveat shown in Grok/Mix mode — command/
  secret-exfil guard + cost cap apply to Grok, but the prompt-injection guard
  does NOT (opencode cannot block a turn); Anthropic/Ollama/Self-Hosted run
  through Claude Code with the full guard set; prefer those for agents that
  ingest untrusted or cross-agent content; Grok is safe for trusted work.
- docs/self/architecture/llm-provider-security.md: the reference — the two
  runtimes, which provider uses which, the per-guardrail parity matrix, why
  the injection/stop gaps exist (opencode hooks are observe-only), and the
  routing recommendation (delivery roles handling untrusted content → a
  Claude-Code-runtime provider).

Panel tsc + eslint clean.

* fix(grok): make the live interactive path work — store perms, error surfacing, variant

Found by actually running opencode serve locally (the path was doc-verified but
never executed). Three fixes:

1. EACCES on the opencode store mount (the live intake crash): on Linux docker
   auto-creates a missing bind source as root:root, so the non-root agent user
   could not mkdir/write in /home/agent/.local/share/opencode and opencode died
   at boot. _ensure_opencode_data_dir pre-creates the per-agent dir 0777 before
   the mount (one-shot via the _GrokHost seam, interactive in both spawns).

2. Silent blank reply on a model error: opencode reports a turn failure in
   info.error with parts=[], NOT as a part — verified live (a bad xAI key
   returns info.error APIError). send() / normalize_opencode_message now surface
   it as an "error" StreamChunk so a failed turn is never blank (the original
   intake bug class). Confirmed live: the error now renders.

3. Reasoning variant on the serve path: the live OpenAPI shows the message body
   accepts a "variant" field (it is NOT CLI-only, as the docs implied), so the
   pin is unblocked. send() passes ROBOCO_GROK_VARIANT as the per-turn variant;
   the orchestrator sets it per-role (_reasoning_effort_for) for interactive
   Grok, the same lever as the one-shot --variant.

opencode serve startup, POST /session, session-id extraction, the part-type
mapping (text/reasoning/tool), and the error path are all validated against a
live opencode 1.17.8. A real successful grok reply still needs a funded key.

* fix(grok): pre-create agent-owned ~/.local in the grok image (opencode state EACCES)

Running the built grok-prompter container surfaced a second EACCES the
mechanism analysis missed: bind-mounting the opencode store at
~/.local/share/opencode makes docker create the intermediate ~/.local AS ROOT,
so the non-root agent user then cannot create its sibling ~/.local/state and
opencode dies at boot. Pre-create the ~/.local tree agent-owned in the image so
the mount leaves the parents writable. Complements the orchestrator 0777
host-source pre-create (which covers the bind source on Linux).

Verified live: with this fix the container starts clean, opencode serve opens
the session, a POST /turn produces a real grok reply, and all chunks
(thinking/text/turn_end) reach the relay endpoint.

* feat(grok): prompt-injection guard for Grok (parity with the Claude hook)

The injection guard is RoboCo's own hook (user-prompt-hook.sh), not a runtime
built-in, so it can be recreated at our input boundary regardless of runtime —
opencode's lack of a blocking pre-prompt hook is irrelevant.

- prompt_guard.detect_injection: the deny patterns ported to reusable Python.
- IntakeDriver._run_turn scans every interactive turn before sending it to the
  model and denies a match as an error chunk. Covers BOTH Grok (opencode) and
  the Claude SDK intake (which runs with setting_sources=[] and so never loaded
  the bash hook — it was unguarded too).
- The one-shot grok entrypoint scans ROBOCO_INITIAL_PROMPT and refuses a
  poisoned task prompt (parity with the Claude UserPromptSubmit deny).
- Broadened the pattern (Python + the bash hook, kept in sync) to catch the
  multi-qualifier canonical phrasing "ignore all previous instructions", which
  the single-qualifier original missed — without false-positiving on
  "ignore the linting rules" (an intermediate non-qualifier word breaks it).

So Grok now has the command/secret-exfil guard (secret-scrub), the cost cap,
AND the injection guard. Verified: 94 agent_sdk tests pass; bash + Python agree
on detect/miss cases.

* docs(grok): drop the security disclaimers — injection guard closes the gap

With the prompt-injection guard now recreated for Grok (prior commit), the
"Grok lacks the injection guard / prefer Claude for delivery roles" warning is
no longer true, so remove it:

- Panel routing card: replace the amber "prefer Claude / not safe" caveat with
  a neutral one-liner — Grok agents run on opencode; the command/secret-exfil
  guard, the prompt-injection guard, and the cost cap all apply.
- docs/self/architecture/llm-provider-security.md: prompt-injection row flips to
  "yes" for Grok; intro + routing recommendation updated to "effective security
  parity, any agent (incl. delivery roles) can run on Grok"; the only remaining
  unported hook is the non-security stop-guard.
- opencode_config docstring: the remaining gap is now just the stop-guard
  (budget + injection are covered).

Panel tsc + eslint clean.

* fix(grok): allow external-directory reads so the pr-reviewer can work

Live NAS run showed the Grok pr-reviewer claim the review and fetch the diff,
then write it to /tmp and FAIL to read it back: opencode auto-denied
"external_directory (/tmp/*)" — its file tools refuse paths outside the project
cwd, and in headless serve/run mode an "ask" permission auto-rejects (no human).

Add permission.external_directory (default "allow", env
ROBOCO_GROK_EXTERNAL_DIR_PERMISSION) to the generated opencode.json. The
container is the sandbox and secret-scrub still blocks credential-file reads, so
allowing in-container external-dir reads is safe and unblocks legitimate scratch
use (e.g. the pr-reviewer grepping a large diff in /tmp).

Verified live against grok-build-0.1: with external_directory:"allow" the Read
tool reads a file outside cwd and returns its contents (no auto-reject); the
plain-string form is accepted by opencode 1.17.8.

Needs a rebuild of roboco-agent-grok + a pr-reviewer re-run on the NAS to confirm.

* refactor(grok): split eligibility out of _maybe_kill_wedged_grok (xenon C -> B)

CI complexity gate (make quality -> xenon --max-absolute B) flagged
_maybe_kill_wedged_grok at rank C — too many guard branches in one method.

Extract the kill-candidate decision into _wedged_grok_slug(task, last_heartbeat)
-> slug | None (recent-heartbeat / no-owner / not-ACTIVE / not-GROK all yield
None); _maybe_kill_wedged_grok now just kills + evicts the returned slug.
Behaviour is identical (same guards, same order) — the reaper watchdog tests
pass unchanged. xenon now passes on the full package; ruff + mypy clean.

* feat(grok): start the in-container SDK server + budget feed (Claude parity)

The keystone of the Grok parity work (CEO's "take Claude as baseline, create
what's missing" call): the one-shot Grok container now starts the same SDK
server the Claude path runs, so the per-verb circuit breaker (the flow/do MCP
servers already POST /verb/attempted to it), the per-session budget/loop
counters, the terminal-verb tracking, and the SessionEnd post-mortem all work
on Grok instead of being silently absent.

- entrypoint: launch roboco.agent_sdk.server (bare venv python, not `uv run`
  which would re-sync the drifted clone lock and stall), wait for /health,
  reset counters; run opencode WITHOUT exec so the script regains control to
  run the post-mortem and the silent-exit substitute after the run returns.
- budget-feed.js: opencode plugin that gates on /budget/status in
  tool.execute.before (halt/loop deny — the only place to stop a runaway
  one-shot run; opencode has no PostToolUse-deny) and records the executed
  tool + args-hash in tool.execute.after. Fail-open; bare-verb normalization
  for MCP-namespaced terminal verbs.
- silent-exit substitute: on a graceful exit with no terminal verb the
  entrypoint posts /terminal/force_substitute so the task isn't left stuck
  claimed/in_progress (Stop-hook parity at the boundary).
- opencode_config: wire budget-feed into the plugin array; add
  ROBOCO_OPENCODE_EXTRA_PLUGINS so per-image role tool plugins load scoped to
  one role; read the per-role ROBOCO_GROK_EDIT_PERMISSION.

Targeted gate green (ruff/mypy/xenon + opencode_config tests; node --check on
the plugins; bash -n on the entrypoint).

* feat(grok): give the Grok Secretary its CEO-authority tools (blocker)

The Grok Secretary could chat but had zero directive tools — it could not read
company state or act on a CEO command, so it was non-functional. This is the
integration blocker.

- secretary-tools.js: opencode plugin registering read_company_state /
  read_task / submit_directive via the Hooks.tool API, each calling
  /api/secretary/* with the container's HMAC agent token — a direct port of the
  Claude Secretary's SDK tools (secretary_driver.build_secretary_options). The
  high-impact directive kinds stay gated server-side (queued for CEO confirm).
- agent-grok-secretary.Dockerfile: bake the plugin and scope it to this image
  via ROBOCO_OPENCODE_EXTRA_PLUGINS, so only the Secretary carries CEO authority.
- grok_secretary_main: correct the docstring that falsely claimed the tools
  reached the API "through the mounted MCP gateway" (there is no gateway mount;
  they're an opencode plugin).
- secretary.md: name the three tools and restate the confirm-before-act gate.

Verified locally that opencode loads a file-path plugin importing
@opencode-ai/plugin and resolves the package; the live model-tool-call +
backend round-trip is flagged UNVERIFIED-LIVE for the NAS.

* feat(grok): give the Grok Intake its propose_draft tool (draft card)

The prompter prompt tells the model to call propose_draft when the spec is
ready, but on Grok that tool didn't exist — so no draft chunk, no panel draft
card, and the human couldn't launch a task from a Grok intake chat.

- intake-tools.js: opencode plugin registering propose_draft via Hooks.tool;
  the execute() only ACKs — the driver (OpencodeServeSession.normalize ->
  _is_propose_draft -> _draft_from_tool_input) intercepts the tool CALL and
  emits the `draft` chunk the panel renders.
- agent-grok-prompter.Dockerfile: bake the plugin, scoped to this image via
  ROBOCO_OPENCODE_EXTRA_PLUGINS (delivery roles never draft).
- test: a propose_draft tool part normalizes to a draft chunk (not a tool_use).

The live tool-call -> draft-card path is flagged UNVERIFIED-LIVE for the NAS.

* feat(grok): scope opencode edit/bash/external-dir permissions per role

Grok wrote ONE global permission block, so a Grok pr_reviewer (or qa / PM /
auditor) ran with edit=allow + bash=allow on untrusted PR content. Now the
permissions are derived per role, mirroring orchestrator._get_role_permissions
on the Claude path:

- edit  — allow only roles that write code (role_config.allows_write:
  developer / documenter); everyone else edit=deny.
- bash  — allow only roles that legitimately run a shell (developer /
  documenter / cell_pm / main_pm); the read-only reviewers (qa / pr_reviewer /
  auditor) and the board get bash=deny. secret-scrub still guards the rest.
- external_directory — only the pr_reviewer reads scratch outside its cwd (the
  /tmp diff); delivery roles get deny.

One-shot roles resolve these in GrokProvider._append_grok_env; the interactive
intake/secretary set edit=deny + bash=deny in the orchestrator (intake keeps
external-dir reads for sibling product repos, the secretary does not). The
Claude path is untouched — the permission env is a GROK-only contract.

Targeted gate green (ruff/mypy/xenon + provider + interactive-spawn tests).

* feat(grok): park the provider on an xAI 429 (break the respawn loop)

A one-shot grok run that hit an xAI 429 exited without a terminal verb; the
dispatcher then re-spawned the same task every tick (429 -> exit -> respawn), a
container/token/cost loop with no living agent to call i_am_blocked.

- entrypoint: detect a rate-limit signature in the run output and exit 75
  (EX_TEMPFAIL); a rate-limited task is NOT substituted — it must be retried.
- _handle_stopped_container: on a grok exit 75, park the provider via the
  rate-limit tracker (retry_after window) instead of crash-retrying, and don't
  count it as a crash. The existing probe-resume loop clears the park after the
  window (unknown-provider time-expiry fallback) and the task is retried.
- spawn_agent: a grok-only, fail-open guard skips the launch while the provider
  is parked, so the dispatcher no-ops instead of looping. The Claude path is
  untouched.

Targeted gate green (ruff/mypy/xenon + new rate-limit tests; bash -n on the
entrypoint).

* feat(grok): close the secret-scrub bash-guard parity gaps

secret-scrub.js (the opencode bash guard) was missing three rules the Claude
bash-guard hook has, leaving a Grok dev able to read secrets the Claude path
blocks:

- source / dot-source of a credential-bearing file (source .env, . ./.env,
  .bashrc / .git-credentials / .netrc / /proc/*/environ).
- interpreter one-liner reading a credential file
  (python -c "open('.env')", node -e "readFileSync('.git-credentials')").
- git-ops check now runs on a SKELETONIZED command (heredoc bodies + echo/printf
  args stripped) so a README/heredoc that merely documents `git push` is no
  longer mistaken for invoking it — a false-positive parity fix from the Claude
  guard.

Functionally smoke-tested with node against the real plugin (git push denied;
echo/heredoc "git push" allowed; source/interpreter cred reads denied; normal
commands allowed). Live opencode firing stays flagged in the file header.

* fix(grok): record a usage session for interactive intake/secretary (M1+M7)

_spawn_intake_container / _spawn_secretary_container built the AgentInstance by
hand and never recorded an agent_spawn_sessions row, so the reap finalizer had
no usage_session_id to look up — every interactive session (Claude or Grok)
finalized at 0 tokens / $0 in the rollups. Record the session (task_id=None) and
pin its id on the instance, mirroring _launch_spawn; the GROK path reads
opencode.db by this id, the Claude path reads the transcript.

Also correct the grok_intake_main docstring (M7): it claimed the serve process
was "gateway-wired" with an "MCP gateway", but interactive intake mounts no
gateway — its only tool is propose_draft, registered by the intake-tools.js
plugin.

* fix(grok): surface a dead opencode-serve clearly instead of a zombie chat (M2)

If `opencode serve` died after the session opened, every subsequent turn failed
with an opaque httpx connection error while the container lingered. send() now
detects the exited subprocess (returncode set) and yields a clear error chunk +
turn_end so the panel shows a real "session ended — start a new chat" message;
the idle watchdog / a human reap then tears the container down.

* fix(grok): close the panel relay when the cost-cap kills an interactive chat (M4)

_enforce_grok_cost_budget killed + evicted a container directly. For the
interactive roles (intake/secretary) that left the panel SSE relay open with no
close sentinel, so the chat froze with no explanation. Add
PrompterLiveRegistry.close_by_agent (push a final error event, then close every
session bound to that agent) and call it from the cost-cap watchdog when the
killed agent is the intake or secretary, so the panel reports the chat ended on
the cost cap instead of hanging.

* fix(grok): make the opencode runtime actually load — proven live on grok-build-0.1

Live verification (opencode 1.17.8 + grok-build-0.1, funded key) showed the Grok
runtime was loading INERT, three ways:

1. The provider override `provider.xai.npm=@ai-sdk/openai` failed model
   resolution (ProviderModelNotFoundError) — opencode can't resolve that package
   from its module path. Worse, ANY custom `provider.xai` block (even just
   options) breaks plugin-tool registration. opencode's BUILT-IN xai provider
   drives grok-build-0.1 with working tool-calls, so emit NO provider block; the
   key + base reach it via XAI_API_KEY / XAI_BASE_URL env (provider.options.apiKey
   alone does NOT authenticate).
2. Plugins referenced by absolute path in the config `plugin:` array never
   registered their hooks/tools. opencode 1.17.8 only registers from the plugin
   AUTO-DISCOVERY dir (~/.config/opencode/plugin/). Bake all plugins there.
3. Plugins must use a NAMED export, not `export default`.

Changes:
- opencode_config: no `provider` block, no `plugin` array; drop the dead
  XaiTarget + timeout machinery; build_opencode_config now takes a model string.
- GrokProvider / orchestrator interactive env: inject XAI_API_KEY + XAI_BASE_URL
  (drop the now-unused OPENAI_*).
- secret-scrub / budget-feed / secretary-tools / intake-tools: named exports;
  baked into /home/agent/.config/opencode/plugin/ (drop the EXTRA_PLUGINS env).
- agent-grok* Dockerfiles: plugin dir + agent ownership; drop the unneeded
  @ai-sdk/openai global install.

Verified live end-to-end: grok-build-0.1 calls read_company_state AND
submit_directive through secretary-tools.js and the backend receives both with
the agent token; a tool.execute.before guard fires; built-in tool-calls work.
Targeted gate green (ruff/mypy/xenon + opencode_config/providers/interactive
tests; node --check the plugins).

* fix(grok): deliver intake draft via the relay + correct opencode-mechanism docs

Live end-to-end verification (opencode 1.17.8 + grok-build-0.1) of the WHOLE
integration, then fixes for what it surfaced:

1) Intake draft card (FUNCTIONAL): opencode's synchronous serve reply
   (POST /session/:id/message) returns only [step-start, text, step-finish] — it
   does NOT include tool-call parts, so the driver could never extract the
   propose_draft draft. intake-tools.js now POSTs the draft straight to the
   prompter-live relay (/api/prompter/live/{session}/events, the same endpoint
   the driver's relay sink uses), so the panel renders the card regardless.
   Verified live: grok calls propose_draft -> the relay receives the draft.

2) Correct misattributed opencode "bugs" (DOCS): earlier comments asserted as
   general opencode behavior that a provider.xai block / npm override / config
   plugin:-array "break" registration. Re-testing showed those were artifacts of
   a PROJECT-level .opencode/opencode.json; from the GLOBAL config (which
   opencode_config writes) the built-in provider, model resolution, the plugin
   array AND the auto-discovery dir all work, and MCP gateway verbs register
   (delivery agents verified). Reframed the comments as design choices (built-in
   provider + XAI_API_KEY env + plugins baked in the auto-discovery dir with
   named exports) and dropped the false claims.

3) Reasoning --variant: passing it does not error, but whether opencode applies a
   named reasoning variant to grok-build-0.1 (no provider-defined variants) is
   UNVERIFIED — comment softened from a "~54% cut" claim to best-effort,
   measure-on-NAS.

Verified live this session: one-shot delivery (model + MCP verbs + plugins +
hooks), secretary tools (read_company_state + submit_directive -> backend with
token), intake draft (relay), grok built-in-provider tool-calling. Remaining
NAS-only: full container assembly (SDK :9000 startup, entrypoint hooks, 429
parking) + the --variant cost measurement. Gate green (ruff/mypy + 51 tests;
node --check the plugins).

* feat(grok): reap abandoned interactive chats (M3)

An interactive intake/secretary chat the human abandoned (closed the tab without
confirming or stopping) leaked its container until the orchestrator restarted —
the wedged-grok reaper is task-driven and these run task_id=None, and an SSE
disconnect intentionally does NOT reap (so a page reload can reconnect).

Reap by IDLE TIME, not connection state: PrompterLiveRegistry tracks
last_activity (bumped on every push/deliver = a turn), and the 60s sweeper
retires sessions idle past ROBOCO_INTERACTIVE_IDLE_REAP_SECONDS (default 1800;
0 disables) via reap_intake_session / reap_secretary_session. An active or
page-reloaded chat that keeps exchanging turns stays fresh and is never reaped;
board-review-parked sessions (task_id set) are exempt. Provider-agnostic — fixes
the leak for both Claude and Grok interactive.

Tests: idle-only reap (active/parked/closed excluded), activity bump keeps a
session alive, threshold 0 disables. Gate green (ruff/mypy/xenon + prompter_live).

* fix(panel): resolve agent names from the live roster so they never drift

A review task assigned to the pr-reviewer rendered as a truncated raw
UUID instead of its name. Root cause: the panel resolved assignees from a
hardcoded static roster in agent-utils.ts that had drifted — it never
gained the board-adjacent agents added backend-side (intake-1,
secretary-1, pr-reviewer-1). Their UUIDs hit no map entry, so
getAgentDisplayName fell through to the unknown-UUID branch and returned
agentId.slice(0, 8). Every assignee surface (task table, task detail,
subtasks, journals, communications, commit cards) shares that resolver, so
all of them showed the fragment.

Make the live /api/agents roster the source of truth instead of a static
duplicate that silently rots:

- agent-utils: add a runtime registry (registerAgentRoster) keyed by both
  UUID and slug; resolveToSlug / getAgentDisplayName / isKnownAgent consult
  it first. The static maps remain only as an offline / first-paint
  fallback (now complete with the three agents).
- api/agents: surface the backend UUID on AgentDefinition (getAll/getOne
  previously dropped it), so the registry can key by UUID.
- use-agents: add useAgentRosterSync (registers the live roster) and derive
  useAgents from live definitions, falling back to the static roster.
- providers: mount the sync once inside QueryClientProvider.

Now any agent the backend knows about resolves, including ones added after
this change — the panel can no longer drift out of sync.

Tests: agent-utils unit tests cover the three agents end-to-end, a
live-roster-only agent (drift-proofing), live-overrides-static, and a
regression guard for the existing roster.

* fix(pr-review): post a COMMENT review when GitHub forbids self-review

A pr-reviewer review of an org-authored PR never reached GitHub. The agent
side ran correctly (claim → read-only diff → review → post_pr_review →
completed + CEO notify), but the GitHub publish 422'd with "Can not request
changes on your own pull request": the PR was authored by the same account
that owns the project PAT. post_pr_review posts best-effort after the DB
transition, so the failure was logged and swallowed — the task completed and
the CEO was notified "reviewed" while the PR showed no review.

GitHub forbids APPROVE / REQUEST_CHANGES on your own PR but DOES allow a
plain COMMENT review. The org's internal PRs (and any PR the PAT owner
opened) hit this. Retry once as a COMMENT review on the self-review 422 so
the review actually lands; the verdict is already stated in the body. The
external/fork-PR path (different author) is unchanged — REQUEST_CHANGES
succeeds there and the fallback never fires.

Tests: self-review 422 downgrades to COMMENT and returns the COMMENT result;
a failing COMMENT retry still surfaces GitError with no infinite loop; the
existing non-self 422 still raises.

* fix(grok): harden cost-guard, pin runtime, refresh stale plugin comments

Address review findings on the Grok provider work:

- budget-feed plugin failed open unconditionally, so a one-shot task agent
  whose in-container SDK budget server went unreachable would run with the
  cost cap unenforced. The entrypoint now exports ROBOCO_BUDGET_ENFORCE=1
  (one-shot agents always start that server) and the plugin's pre-exec gate
  fails CLOSED when the flag is set and the budget endpoint is unreachable,
  halting an uncapped burn. Interactive serve agents (intake/secretary) set
  no flag and keep failing open (they run no budget server by design).

- Pin opencode-ai to the live-verified 1.17.8 (was an unpinned global npm
  install). Untrusted model output runs under it; bump the pin deliberately.

- Document the ROBOCO_GROK_* operator vars in .env.example (image, the three
  opencode permissions, reasoning effort, idle-kill, cost ceiling).

- Refresh stale plugin comments: the MCP tool-name shape and the secretary
  tool-registration path are confirmed live, and secret-scrub's load route is
  the auto-discovery dir (not a config plugin: array). Keep the honest
  not-yet-exercised caveat on secret-scrub's deny path and the reasoning
  variant — those remain genuinely unverified.

* fix(grok): unbreak workspace-cwd agents, free trapped agents, stop self-PR review

Three bugs surfaced by the first live Grok lifecycle run:

- Dev/QA/doc agents crash-looped at startup with ModuleNotFoundError on
  roboco.llm.providers. The entrypoint ran the opencode-config render from the
  agent's workspace-clone cwd, whose own roboco/ dir shadows /app on the
  sys.path front; a branch without the grok code lacks the providers package.
  Render from /app so the installed package always resolves (the render has no
  cwd dependency — writes global, reads ROBOCO_MCP_CONFIG).

- A budget/loop halt blocked EVERY tool, including i_am_idle, unclaim, and
  i_am_blocked, so a halted agent could neither continue nor stop and flailed —
  one billed model turn per blocked retry. The before-gate now always lets the
  release verbs through so a halted agent can exit cleanly.

- The inbound reviewer ingested the org's OWN PRs (authored by the repo-owner
  account), which can't take a REQUEST_CHANGES review (GitHub 422) and get
  re-reviewed every poll. The normalizer flags author_is_owner and ingestion
  skips them — the reviewer reviews only PRs the org did not author.
  External/contributor PRs are unaffected.

Tests: owner-authored PR flagged + skipped; normalize shape covers the new
field. Gate green on the changed modules (ruff/mypy/xenon + 48 tests).

* feat(grok-cli): render config.toml + map per-role grok CLI flags

First piece of the Grok CLI provider that replaces the opencode runtime: a
pure, unit-tested module the agent entrypoint runs to translate the mounted
mcp-config.json into ~/.grok/config.toml ([mcp_servers]) and compute the
per-role 'grok -p' flags — subagent/shell/edit tool removal, raw-git-mutation
and rm-rf denies, reasoning effort — mirroring ClaudeCodeProvider's per-role
permissions with native grok flags instead of an opencode permission block +
JS guard plugins. Uses tomli_w. The rendered config + env injection are
validated live against grok-build (the model called the server through it).

* feat(grok-cli): grok CLI agent image + headless entrypoint

The roboco-agent-grok image now installs xAI's official grok CLI (Grok Build,
pinned 0.2.56) instead of opencode, authenticated by the SuperGrok subscription
via a mounted ~/.grok/auth.json (parity with the Claude ~/.claude mount, no
metered API key). The entrypoint renders ~/.grok/config.toml + per-role flags
from /app (the ModuleNotFound-shadowing lesson), runs grok -p headless with
--output-format json, keeps the prompt-injection guard, and exits 75 on a
rate-limit so the orchestrator parks the provider. No in-container SDK server or
budget-feed — native --max-turns + server-side terminal-substitute replace them.

* feat(grok-cli): GrokCliProvider — subscription auth mount, mirrors ClaudeCodeProvider

Replace the opencode GrokProvider with GrokCliProvider: reuses the orchestrator's
shared mount/auth/git assembly (gateway + identity) exactly like the Claude path,
mounts the host ~/.grok/auth.json read-only (SuperGrok subscription) instead of
injecting an xAI key, and sets the slim env the grok-cli entrypoint + renderer
read (ROBOCO_AGENT_ID for per-role flags, model, mcp-config, prompt). Provider
routing fields are blanked before the shared step so the grok endpoint is never
mislabelled ANTHROPIC_*. Per-role permission logic now lives in grok_cli_config,
so the provider is slim. Registry/orchestrator/exports updated; provider tests
rewritten for the CLI behavior (no XAI key, auth mount present/absent).

* feat(grok-cli): capture per-session token usage + notional cost

Grok runs on the SuperGrok subscription, but — exactly like Claude on Max — we
still record per-agent tokens and a notional cost for the dashboard. The grok
CLI writes a cumulative totalTokens per turn into
~/.grok/sessions/<cwd>/<session-id>/updates.jsonl (the grok analogue of the
Claude transcript / old opencode.db); the max is the session total. This reader
locates that file (url-encoded cwd), extracts the total, and prices it at the
output rate (no input/output split from the CLI; conservative + matches the
reasoning-at-output convention). Validated against a real grok-build session
(18253 tokens -> $0.0365). Entrypoint + finalize wiring follows.

* feat(grok-cli): wire usage capture into the run (session id + post-run extract)

The provider pins a fixed session id (ROBOCO_AGENT_SESSION_ID, reused from the
agent session id as on the Claude path); the entrypoint passes it to
'grok -p -s <id>' so the run's session store is locatable, then runs the usage
reader post-run (best-effort) to write the captured tokens + cost. The
orchestrator-side finalize that reads that file follows.

* feat(grok-cli): read captured usage at finalize; keep interactive serve working

The provider mounts the per-agent data dir and points the entrypoint's usage
file at it; the orchestrator's grok finalize reads that usage.json first (the
grok-CLI total, priced at the output rate) and falls back to opencode.db for the
still-opencode interactive intake/secretary path. Re-add _reasoning_effort_for to
grok.py as a clearly-temporary shim for that interactive path (it needs opencode's
"minimal" variant, distinct from the CLI's --effort) until it is converted too.

* feat(grok): convert interactive intake/secretary to the grok CLI; delete opencode

Move the last Grok runtime off opencode onto xAI's official `grok` CLI, for full
parity with the Claude path. The intake/secretary chat now runs per-turn headless
`grok -p` invocations that resume one session id (proven live: context carries
across runs), with streaming-json deltas mapped to the existing panel StreamChunk
kinds — the IntakeDriver loop, message source, relay, and idle reaper are reused
unchanged; only the SessionFactory differs (GrokCliSession replaces the
opencode-serve session).

- GrokCliSession + a pure, unit-tested streaming-json -> StreamChunk assembler
  (thought coalesced to one block, text streamed live, end captures the session
  id for -r, fenced-draft fallback, clear errors incl. rate-limit).
- intake propose_draft and secretary read_company_state/read_task/submit_directive
  are now FastMCP servers (roboco-intake / roboco-secretary) wired into
  ~/.grok/config.toml, launched via `uv run --directory /app` to resolve the
  installed package. The secretary tools reuse the shared backend helpers.
- Orchestrator: interactive spawn mounts the subscription auth + per-agent usage
  dir (no metered xAI key, no permission env — grok flags carry per-role perms);
  usage/cost now read a captured usage.json (drop the opencode.db reader, the
  _opencode_db_path/_grok_usage_from_opencode methods, and the cost-cap's
  opencode read). hosts["opencode"] -> hosts["grok_usage"]; OPENCODE_DATA_DIR ->
  GROK_USAGE_DATA_DIR.
- Fix one-shot usage capture: `-s` does not pin the session id (grok generates
  its own), so the entrypoint now reads the real id back from the JSON run log
  and the reader uses it; usage is captured per-turn on the interactive path.
- Delete the opencode layer: opencode_config/opencode_usage/opencode_session, the
  docker/grok/*.js plugins, the old one-shot entrypoint, and their tests.
- Compose (all three files), .env.example, and stale comments updated to the
  grok-CLI runtime; add the SuperGrok auth mount + grok-usage dir.

Gate green: ruff, mypy (296 files), xenon, tests. NAS build/verify pending.

* fix(grok): deliver the role blueprint as grok's system prompt via ~/.grok/AGENTS.md

The blueprint was mounted at /app/system-prompt.md but never reached grok — a real
parity gap vs the Claude path (which passes --system-prompt-file). grok agents ran
only on the per-task prompt, missing their RoboCo role/org context.

Verified live on grok 0.2.56 that the obvious flags do NOT work headless:
`--system-prompt-override` and `--rules` are silently ignored under `grok -p`
(identical output with and without). What IS honoured is grok's instruction-file
discovery — and `$HOME/.grok/AGENTS.md` is loaded GLOBALLY regardless of --cwd
(a project AGENTS.md only loads from the cwd/project root, which would pollute the
agent's git workspace). Proven end to end: a blueprint written there makes grok
adopt the role ("I am the RoboCo intake interviewer ... -- intake-1").

write_agents_md() copies /app/system-prompt.md -> ~/.grok/AGENTS.md; the one-shot
render (grok_cli_config.main) and both interactive mains call it. No git pollution
(it lives in ~/.grok, not the workspace), and it covers repo-cwd and /app-cwd
roles alike. Reverted the non-working --system-prompt-override wiring.

* feat(grok): close the Claude-parity divergences (reasoning, subagents, web, bash-guard)

Bring the grok CLI to parity with the Claude path on the four deliberate
differences:

- Reasoning: drop the per-role `--effort low` default — Claude sets no per-role
  thinking budget, so grok now uses the model default for every role. The
  fleet-wide ROBOCO_GROK_REASONING_EFFORT override stays as a cost lever. (This
  also un-caps intake-draft quality, the one that actually mattered.)
- Subagents: the intake interviewer may now fan out to subagents (parity with the
  Claude intake's `Task` allowance); every other role still has `Agent` removed.
- Web: `--disable-web-search` for every role — no agent gets direct web (Claude's
  tool set has none either); the roles that get web reach it through the gated
  roboco-search MCP, unaffected.
- Bash command filtering: full parity, split by deny semantics. Verified live that
  a grok PreToolUse hook deny CANCELS the run, while native `--deny` denies
  GRACEFULLY (the agent gets a permission error and recovers). So:
    * git network/branch/history ops -> native `--deny` (operational reflex; the
      agent must recover, not drop the task). Expanded to the full bash-guard set.
    * credential-exfil / identity-forgery / internal-API / env-dump patterns ->
      the SAME bash-guard the Claude path runs, wired as a grok PreToolUse hook
      (ROBOCO_GUARD_SKIP_GIT=1 so it leaves git to `--deny`). A hard cancel is the
      right response there — no legitimate agent reads ~/.netrc or forges an
      X-Agent-ID. One tolerance line (accept grok's camelCase `toolInput`) makes
      the one tested script guard both runtimes; +5 grok cases (50/50 green).

Also cleaned stale internal task-number / smoke labels out of bash-guard-hook.sh.

* fix(grok): install grok CLI to ~/.grok/bin (its real default), not ~/.local/bin

The image build failed at `chown ... /home/agent/.local: No such file or
directory`. The grok installer's default is $HOME/.grok/bin — the binary lands at
~/.grok/bin/grok; ~/.local/bin/grok is only a convenience SYMLINK the installer
creates on macOS but not in the Linux container. So the Dockerfile referenced a
directory that never existed:
  - PATH pointed at ~/.local/bin -> `grok` would not be found at runtime even if
    the build had passed;
  - chown targeted ~/.local -> the build aborted.

Point PATH + chown at ~/.grok/bin / ~/.grok. Also harden the install: download the
script to a file (a `curl | bash` pipe swallows a curl failure as a silent no-op)
and verify the binary installed and runs (`test -x` + `grok --version`), so a
broken install fails the build loudly instead of producing a grok-less image.

* fix(grok): address adversarial-review findings across the grok-CLI conversion

A 7-dimension adversarial review (find -> independently refute) surfaced 14 real
issues; fixed each:

Runtime bugs
- GrokCliSession.send drained stdout fully BEFORE stderr — a >64KB stderr burst
  would deadlock the turn forever (spinner never clears). Drain stderr
  concurrently, and add a per-turn watchdog (ROBOCO_GROK_TURN_TIMEOUT_SECONDS,
  default 600s) that kills a wedged process and emits error+turn_end.
- Crash-restarted grok agents launched `grok -p ""` (empty prompt) — Claude gets
  a scan-for-work fallback. Default the prompt in _spawn_container so every
  dedicated provider gets it too.
- _grok_usage_json read /data/grok-usage unconditionally while its writers branch
  compose-vs-local, so a local-mode agent finalized at $0 and the cost-cap was
  inert. Single-source the path in a new _grok_usage_dir helper (read == write).
- GrokCliSession secretary role fell through to "unknown" (get_agent_role returns
  a truthy sentinel, never None), defeating the ROBOCO_AGENT_ROLE fallback.

Parity / hardening
- --deny set was missing `git tag -d` / `git reflog delete` that the Claude
  bash-guard blocks — added them (the "same set" claim is now true).
- Interactive mains now install the bash-guard hook too (defense-in-depth).
- Compose: collapse the GROK_AUTH_DIR / ROBOCO_HOST_GROK_DIR auth-mount pair into
  one canonical var so a partial override can't silently break agent auth.

Docs / comments
- Panel routing card + architecture security doc no longer say Grok runs on the
  deleted opencode runtime; orchestrator comments point at the renamed entrypoint.

Tests
- Cover the interactive _render_grok_config MCP wiring (ModuleNotFound guard +
  secretary HMAC env), the cost-cap kill-failure + interactive relay-close paths,
  the local-mode usage read, the role fallback, the turn timeout, and the new
  git denies. (#13 — a separate grok "Write" tool — investigated: grok's only
  built-in file-mutation tool is search_replace, already removed; no gap.)

Gate green: ruff, mypy, xenon, tests.

* fix(grok): declare tomli-w as a runtime dependency (agent image needs it)

The grok agent image failed at spawn with `ModuleNotFoundError: No module named
'tomli_w'` when rendering ~/.grok/config.toml. tomli_w was only a transitive dep
of a dev-extra package, so it was present in dev/orchestrator envs but excluded
from the agent image, which builds its venv with `uv sync --frozen --no-dev`.
grok_cli_config imports it at module load to serialize the MCP gateway config, so
without it a Grok agent gets no gateway verbs.

Promote tomli-w to a direct [project.dependencies] entry. Locked with
`--upgrade-package tomli-w` so only tomli-w is added — no incidental churn of the
8 unrelated packages a full re-resolve would have bumped.

* Updated uv.lock

* fix(grok): auto-approve tool execution (--always-approve) so headless agents can call tools

Live smoke caught every grok agent (Main PM, pr-reviewer, dev, …) ending its run
with stopReason=Cancelled and empty output the instant it reached for a tool. Root
cause: headless `grok -p` cannot approve a tool call without `--always-approve`
(grok's docs: required for unattended automation), and the per-role args didn't
pass it — so no agent could call a gateway verb, an edit, or an MCP tool, and the
run was cancelled.

Add `--always-approve` to grok_cli_args_for_role (one place → every role, one-shot
and interactive). Safety is unaffected: `--disallowed-tools` still removes tools
and `--deny` still hard-blocks command patterns regardless of approval (a denied
command returns a permission error and the agent recovers — verified live).

Proven in the rebuilt image side-by-side: without the flag a tool call yields
Cancelled/not-called; with the real rendered args it returns EndTurn and the MCP
tool actually runs. (My earlier in-image tool-calling check passed `--always-approve`
manually, which masked that the production args omitted it — fixed.)

* fix(pr-review): seed claim heartbeat so the grok reviewer isn't wedge-killed

pr_review_claim transitioned a review task pending -> in_progress but never
seeded last_heartbeat_at, unlike every sibling claim path (_finalize_claim,
qa_claim, doc claim). The reaper treats a NULL heartbeat as a stale claim, and
the GROK idle-kill watchdog bypasses the live-container skip on a NULL
heartbeat -- so the reviewer container was killed (Cancelled) before it could
post_pr_review, churning the task back to pending on a respawn loop. A Claude
reviewer was shielded by the live-instance skip; only GROK manifested it.

Seed the heartbeat at claim time, matching the established invariant. Verified
against a real Postgres (10/10 test_pr_review_db tests, incl. the new
last_heartbeat_at assertion).

* fix(grok): stream one-shot output live + capture real token usage

Two gaps the buffered run hid, both verified in the real image with mounted
SuperGrok auth:

- Observability: the entrypoint buffered grok's output to a temp file and only
  cat it after the run, so `docker logs` was blank while the agent worked.
  Switch the one-shot to --output-format streaming-json piped through tee:
  grok flushes each thought/text event incrementally (confirmed token-by-token
  live in-container), so the agent's reasoning shows in docker logs in real
  time, parity with the Claude stream-json path. Read the session id back from
  the NDJSON run log (the terminal `end` event) since -s does not pin it.

- Usage: total_tokens read 0 for every grok run. grok nests the cumulative
  totalTokens on params.update._meta, but the reader looked at params._meta
  (which only holds event ids); the unit fixture had the same wrong shape, so
  the tests masked it. Read the real path (with params._meta / top-level
  fallbacks) and fix the fixture to the real grok shape. Verified live:
  usage.json now reports total_tokens=3262, cost_usd=0.006524 (was 0).

* fix(grok): validate agent_id before using it as a usage-dir path segment

CodeQL flagged a high-severity py/path-injection: agent_id flowed from
request-facing call sites into _grok_usage_dir() and on to read_text(), so a
value containing '..' or a separator could traverse the filesystem. Validate
agent_id against the slug/uuid allowlist ([A-Za-z0-9_-]+) at the single
chokepoint (_grok_usage_dir feeds both the mount and the finalize read);
anything else raises. Rejects traversal; accepts every real agent slug.

* fix(grok): use explicit-guard path sanitizer CodeQL recognizes as a barrier

The re.fullmatch allowlist from a35b640d was secure but CodeQL's py/path-injection
dataflow did not model the regex call as a barrier, so the high-severity alert
persisted on the analyzed merge. Switch _safe_agent_path_segment to explicit
guards (empty / '.' / '..' / '/' / '\\' / NUL) -- the barrier form the query
recognizes -- which still rejects every traversal vector. Drop the now-unused
re import.

* fix(api): validate agent_id at the orchestrator route boundary (path-injection)

CodeQL traces the py/path-injection from the request agent_id path param on the
orchestrator routes (stop/spawn/resolve-wait/mark-waiting/status) down to the
grok usage-dir read. Validate agent_id at the HTTP boundary with explicit
traversal guards (empty / '.' / '..' / '/' / '\\' / NUL) returning 422, so the
sanitized value is what flows downstream and the query sees a barrier at the
source. Runtime _grok_usage_dir keeps its guard as defense in depth for
non-HTTP callers.

* fix(grok): sanitize usage-dir agent_id with Path(...).name (CodeQL barrier)

Proven against the analyzed merge: CodeQL does not propagate a control-flow guard
through a helper's return value, so neither the route validator nor the
_safe_agent_path_segment guard cleared the py/path-injection alert. Reduce the
validated id to its final path component with Path(...).name -- a data-flow
sanitizer CodeQL models and propagates through the return -- at the single
source of truth (_grok_usage_dir), covering both the finalize read and the
mount/mkdir. The guard stays for fail-loud reject semantics; .name is the
recognized barrier (identity for a valid slug).

* fix(grok): containment-check the usage read against a fixed root (path-injection)

Three sanitizers failed to clear the CodeQL alert because the query does not
model them here: a regex guard, an explicit guard, and Path(...).name (verified
each against the analyzed merge). Replace with the barrier CodeQL does
recognize -- and that is also a genuine control -- at the read sink: resolve the
usage.json path and refuse it unless it is_relative_to the resolved usage root
(a fixed, untainted base from config, extracted as _grok_usage_root).

Note the github-advanced-security autofix proposed 'if usage_json.parent !=
usage_dir: return None', which is a no-op -- appending the constant 'usage.json'
never changes the parent, and it compares against the tainted dir, not a safe
base. This compares against the fixed root instead. The _safe_agent_path_segment
guard stays (fail-loud reject upstream, covers the mount/write side).

* fix(grok): sanitize the usage read with os.path.basename (CodeQL-modeled barrier)

Four prior barriers did not clear the py/path-injection alert (verified each
against the analyzed merge): a regex guard, an explicit guard, Path(...).name,
and an is_relative_to containment check. The one sanitizer CodeQL's query
documents -- os.path.basename -- was never actually tried: it was swapped for
Path(...).name to dodge ruff PTH119, and that pathlib form is not modeled.

Apply os.path.basename to the agent id in _grok_usage_json's own scope (the read
sink), so there is no recognition or interprocedural-propagation ambiguity, and
allow PTH119 for this file with a documented reason. The _safe_agent_path_segment
guard and the route 422 stay as the actual reject controls.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-19 09:15:01 +02:00
Renn F f48106cbb6 docs: reflow hard-wrapped prose to one line per paragraph
Markdown and editors soft-wrap on their own, so the manual ~75-char line
breaks across the docs added nothing but noise. Join wrapped prose, list
items, and paragraphs into single lines across 67 docs — README, CLAUDE.md,
deployment, usage, the RAG knowledge base, and the agent role prompts.
Whitespace-only: code fences, tables, and blockquote alerts are byte-identical
and the change is token-verified (no content altered). Applied with a
deterministic reflow tool (committed separately).

Also lands two doc edits that were awaiting commit: the measured under-load
resource numbers in usage.md and the pr_reviewer additions to the
org-structure RAG doc.
2026-06-16 23:18:55 +02:00
Renn F 60de349934 fix(prompts): Main PM must honor explicitly-named cells, not silently drop them
A panel-wide UI task that asked for the UX/UI and Frontend cells was
decomposed into Backend + Frontend only — the Main PM judged the UI work
collapsible into Frontend and dropped UX/UI entirely. Its prompt gave full
cell discretion ('you decide which cells', 'most roots only touch one
cell') with no rule to honor cells the brief names.

Add a rule: when the brief, acceptance criteria, or PO/HoM handoff
explicitly call for a cell, the Main PM must create a subtask for each
named cell and never collapse one into a neighbour — its discretion covers
only un-named scope; a genuinely-unnecessary named cell must be confirmed
via escalate_up/dm, not silently dropped. Matching anti-pattern added.

The companion finding (cross-dev sequencing) needed no change: the Cell PM
prompt already keeps dependent units in one dev's lane and splits only
independent units, and cross-cell order is enforced by the dependency_ids
gate, not sequence.
2026-06-16 22:30:33 +02:00
Renn F 5902c0fe38 feat(roles): add the read-only pr_reviewer role end-to-end
A global, read-only PR reviewer agent (pr-reviewer-1) that reviews inbound
external/fork PRs and posts one change-request. Wired end-to-end:

- identity: Role.PR_REVIEWER + agent + ROLE_LEVEL (QA-peer) + REVIEWER_ROLES
- lifecycle: CLAIM_RULES + ROLE_TEAM_RULES + a dedicated claim_pr_review /
  post_pr_review verb pair (distinct from QA's) + the pr_review_done action and
  its in_progress->completed transition; give_me_work / i_am_idle gain the role
- role_config: a read-only RoleConfig (allows_write=False)
- journaling: ALL_CELLS read tier so it can read internal intent like QA
- tracing: post_pr_review requires a learning entry; claim_pr_review is waived
- seeds presentation + factory prompt layer + builtin tools + the agentrole
  enum migration (037) + regenerated verb/lifecycle artifacts

Read-only at /app like QA/auditor; default-off — nothing dispatches review work
until external_pr_enabled. Foundation + role-config + enum suites green; ruff +
mypy clean; orchestrator boots.
2026-06-16 10:37:06 +02:00
Renn F e209e285b8 feat(dispatch): per-dev sequenced queues for code subtasks (guardrails spec 3)
True two-dev parallelism: a cell PM delegates the FULL set of code units up
front — each dev gets its own queue, both build at the same time, each works
its queue one task at a time in order. Replaces the old ceiling (≤2 code
subtasks per parent, one per dev) which structurally forced under-decomposition.

- Cap: `code` removed from `_SPINE_TYPE_CAPS` — no per-parent code cap (total
  fan-out still bounded by `_SUBTASK_HARD_CAP=12`); `planning`/`documentation`
  stay sequential at 1. `_same_assignee_rejection` exempts `code` so a dev may
  own a queue, but still rejects an exact same-title duplicate (the accidental
  re-delegation bug). `_spine_type_dup_envelope` simplified to the sequential
  spine it now only serves.
- Dispatch barrier: `_blocked_by_earlier_lane_sibling` holds a dev's
  higher-sequence pending code leaf while it still has an earlier non-terminal
  code sibling under the same parent (keyed on assignee, gates only code) — the
  dev works its queue in order. Wired into `_spawn_pending_dev`. Loop-free
  (skip the tick, no reject/respawn) and best-effort (lookup failure → dispatch),
  mirroring the existing merge barrier. The merge barrier is unchanged: leaf
  PRs still merge serially in sequence order into the shared cell branch, so the
  independent build lanes never wedge it.
- Prompt: cell_pm role guidance rewritten from the two-subtask-cap model to the
  per-dev-queue model (delegate all units now; dependent units go in one dev's
  queue, upstream first).

Independent per-dev queues (each lane advances at its own pace) rather than
strict cross-dev wave-sync, by design — more parallel and leaves the
wedge-prone merge barrier untouched. Pairs with the spec-2 idle coverage gate:
removing the code cap lets a PM claim every criterion up front, so that gate is
always satisfiable.
2026-06-16 04:10:05 +02:00
Renn F 1fb723174a feat(gateway): decomposition coverage gate + AC visibility (guardrails spec 2)
The decomposition floor that pairs with the roll-up gate (spec 4): a PM
cannot finish decomposing a parent while one of its acceptance criteria has
no subtask responsible for it — the "two leaves, half the ACs silently
dropped" pattern. Three parts:

- Gate: i_am_idle is rejected for a cell_pm/main_pm whose owned parent still
  has criteria in unclaimed_parent_acceptance_criteria (claimed = referenced
  by any live, non-cancelled child). Distinct from the roll-up gate, which
  fires at submit_up/complete and demands a *completed* child; this fires
  earlier and asks only that every criterion be *claimed*. Safe-by-
  construction: inert until a PM declares coverage, so legacy / not-yet-
  adopted decompositions are never blocked.

- Visibility: PM-facing briefings (give_me_work, i_will_plan, submit_up) and
  every delegate response now carry parent_ac_coverage ({id,text,claimed,
  verified} per criterion) + unclaimed_parent_acs, so a PM can map subtasks
  to criterion ids via covers_parent_criteria and see what is still
  uncovered after each delegate. Off for leaf roles, so a developer's own
  criteria never surface as bogus "unclaimed" noise.

- Prompts: cell_pm / main_pm role prompts document covers_parent_criteria and
  the new idle enforcement in the existing Coverage discipline.

TaskService.{parent_ac_coverage,unclaimed_parent_acceptance_criteria} added
beside uncovered_parent_acceptance_criteria; all three refactored onto a
shared _parent_ac_ref_sets helper (keeps each under the xenon B ceiling,
preserves the committed roll-up behavior). Verb tables regenerated for the
new delegate param — the regen also syncs pre-existing table drift that was
never regenerated after earlier merges (read_messages, pass_review
ac_verdicts, board pitch). Two brand-new generated tables (prompter,
secretary) are left untracked pending a separate decision.
2026-06-16 03:49:00 +02:00
46d89b58fe feat: company-in-a-box — goal-aware company layer (0.4.0) (#171)
* feat(goals): company charter singleton — data layer (Business Goals slice 1)

First slice of the company-in-a-box "Business Goals" phase: a single CEO-owned
charter row (north star + objectives + constraints + operating policy) that
will be injected into every agent's context_briefing so all work is goal-aware.

- CompanyGoalsTable: singleton table (all-zeros id), JSON objectives /
  constraints / operating_policy, updated_at / updated_by.
- migration 032: create + seed the singleton row (offline-renderable; column
  server-defaults fill an INSERT of just the id).
- CompanyGoalsService: get() (empty defaults when unset) + upsert() (singleton,
  partial update, caller commits).
- tests: empty defaults, roundtrip, singleton + partial-update preservation.

Next slices (mapped, not yet built): briefing injection (BriefingInputs +
build_context_briefing + EvidenceRepo), API route (GET any / PUT CEO-only),
panel /goals page, and base/Board/PM prompt mentions.

* feat(goals): inject the company charter into every agent briefing (slice 2)

The charter is now goal-aware context for every agent:
- BriefingInputs gains company_goals; build_context_briefing surfaces it.
- EvidenceRepo.company_goals(): single-row lookup returning a COMPACT charter
  (north star + objectives + constraints + operating policy; audit columns
  dropped, lists capped) or None when unset, so an empty charter never bloats
  the per-verb briefing.
- _briefing_for wires it into every context_briefing.

Tests: briefing surfaces company_goals (defaults None); repo returns None for an
absent/empty charter and the compact dict when set.

* feat(goals): company charter API — GET any agent, PUT CEO-only (slice 3)

- routes/company_goals.py: GET returns the charter (any authenticated agent —
  it drives every briefing); PUT is CEO-only (403 otherwise), partial update via
  model_dump(exclude_unset=True), explicit commit.
- schemas/company_goals.py: response + partial-update models.
- registered at /api/company-goals.
- tests: GET open to any role, CEO update persists + is readable, non-CEO 403.

* feat(goals): make the company charter actionable in agent prompts (slice 5)

Agents already receive company_goals in the briefing (slice 2); now tell them to
act on it:
- base.md: universal "Align with the company charter" section — favour work and
  trade-offs that advance the objectives, honour the constraints, flag conflicts;
  never a license to leave your role.
- board / main_pm / cell_pm: role-specific lines tying triage / cell-routing /
  subtask decomposition to the charter.

Prompts are composed at spawn from base.md + roles/*.md directly (compose_prompt),
so no _generated regeneration is needed.

* feat(goals): company charter panel page (slice 4)

CEO-facing editor for the charter at /company-goals:
- lib/api/company-goals.ts: get / update (PUT) client.
- company-goals-card.tsx: edit north star + constraints (one per line) +
  objectives / operating_policy (JSON, parsed + validated with toast errors);
  display derives from server state (no set-state-in-effect).
- (dashboard)/company-goals/page.tsx + a "Company Goals" sidebar nav link.

tsc --noEmit + eslint clean. Completes Phase 1 (Business Goals): data, briefing
injection, API, prompts, panel.

* fix(test): make test_app route assertions robust to FastAPI 0.137 _IncludedRouter

FastAPI 0.137 stopped flattening include_router into app.routes — each include is
now an _IncludedRouter (a BaseRoute with no .path), so `{r.path for r in
app.routes}` raised AttributeError and the two router-registration tests failed
(the bump arrived via the claude-agent-sdk update in uv.lock). Add
_registered_paths(): OpenAPI schema paths (the stable public contract) plus each
included router's prefix, which also covers the websocket /ws mount (never in the
schema). Drops the now-incorrect type: ignore[attr-defined].

* feat(research): pluggable web search/fetch for Board + PM agents

Add a provider-agnostic web-research capability so the Board and PMs can
ground decisions in current external evidence the knowledge base can't
answer.

- ResearchService selects a provider adapter from config: Tavily, Brave,
  and Exa adapters plus a NullProvider that degrades gracefully when no
  key is set. Result count and fetched-content size are clamped to caps.
- /api/research/search and /api/research/fetch: role-gated to Board + PMs
  (and the CEO), with a per-agent/day Redis quota that fails open.
- roboco-search MCP server (web_search / web_fetch) calls those routes;
  the provider key stays server-side and agent containers never egress.
  Mounted per role by the orchestrator, behind a master switch.
- Charter-aware prompt guidance for Board, Main PM, and Cell PM.

Additive: with no key configured it is a no-op and the existing delivery
lifecycle is unchanged.

* feat(pitch): Board pitch -> CEO approve -> auto-provision repos

Add an additive origination path so a product can be proposed, approved,
and stood up without manual repo/Project setup.

- Pitch entity + migration (pitches table); PitchService create/list/
  reject/approve.
- GitHubProvisioningService: the one place that creates repos (POST
  /orgs/{org}/repos). Server-side token/org; when unconfigured the whole
  approve path is inert and nothing is created.
- On approval: provision one repo per target cell, register a Project per
  repo, create a Product when multi-cell, and seed one Main-PM delivery
  task — all reusing the existing Product / coordination-task machinery.
- /api/pitches: Board authors (PO/HoM), CEO approves/rejects, Board+PM+CEO
  view. Errors mapped via a single translator.

Additive: the delivery lifecycle is untouched; with no provisioning token
the capability is a no-op. Agent-facing pitch tool + panel are follow-ups.

* feat(strategy): dormant autonomous strategy engine (engine 2)

Add a second, optional engine that watches the company against its
standing goals and surfaces what needs the CEO — without touching the
delivery lifecycle (engine 1).

- StrategyEngine.assess() reports observations: the company is idle while
  goals stand, and tasks stranded in 'blocked' past a threshold.
- run_cycle() notifies the CEO (notify-only; it never spends, builds, or
  auto-approves — originating work stays a CEO decision).
- Orchestrator runs it on its own interval, started/stopped with the other
  background loops; the loop returns immediately unless enabled.

DORMANT by default (strategy_engine_enabled=False): the loop never runs and
a standard deployment is unchanged. Auto-origination is a further opt-in.

* docs(changelog): record Business Goals, Web Research, Pitch->Provision, and the dormant strategy engine under Unreleased

* feat(secretary): wire the Secretary role end-to-end (foundation)

Add SECRETARY as a distinct role — the CEO's conversational chief-of-staff,
governed separately from the Prompter (which stays read-only/human-only).
This is the role foundation only; authority, the live agent, and the panel
land in following commits.

- foundation/identity: Role.SECRETARY (board level), seeded secretary-1 agent,
  role-level mapping.
- journaling read tier (ALL — it advises the CEO), role_config entry,
  per-role model (opus), prompt-layer mapping + roles/secretary.md.
- i_am_idle gains SECRETARY so the role has a verb surface.
- migration 034: add 'secretary' to the agentrole enum (mirrors 025).
- Role-registry tests updated for the new role.

Inert by itself (nothing spawns it yet); additive — existing roles unchanged.

* feat(secretary): directives + gate-list authority (backend)

The Secretary acts only under CEO command. Low-risk directives (relay a
dictated message) execute immediately; high-impact ones — charter edits,
task start/cancel/override, pitch approval, announcements — are recorded
pending and run only after the CEO confirms (the gate list).

- secretary_directives table (migration 035) as the command audit + queue.
- SecretaryService: read company state; submit (direct->run, gated->queue +
  notify CEO); confirm/reject; execution runs with the CEO as actor through
  the existing services (the Secretary never holds CEO authority itself).
- /api/secretary: submit + state/task reads (Secretary or CEO); list/confirm/
  reject (CEO only). Writes commit explicitly.

* feat(secretary): live conversational agent (container + bridge)

Stand up the Secretary as a persistent Claude-SDK container the CEO chats
with, mirroring the Intake agent and reusing its driver/session machinery.

- secretary_driver: build_secretary_options exposes read_company_state /
  read_task / submit_directive as SDK tools that call /api/secretary/* with
  the agent's HMAC token; backend-call logic is module-level + tested.
- secretary_main: container entrypoint (receiver + relay) reusing IntakeDriver.
- orchestrator: start/spawn/reap secretary session + run-cmd builder; no
  workspace clone (reads state via API), mints a role=secretary token.
- secretary_live routes: panel <-> container bridge over the live registry.
- agent-secretary image (Dockerfile + compose build service).

Inert until a session is started; additive — intake and all agents unchanged.

* feat(secretary): panel chat + directive confirmation queue

The CEO's Secretary surface: a live chat (SSE) to talk to the Secretary, and
a 'Needs your confirmation' queue listing gated directives the Secretary
proposed — each with Confirm / Reject. Adds the sidebar nav entry.

- lib/api/secretary.ts: live (start/stream/status/send/stop) + directive
  (list/confirm/reject) + state clients (all as the CEO).
- hooks/use-secretary.ts: drives one chat, accumulating SSE token deltas.
- secretary page: chat pane + pending-directive cards.

Completes the Secretary end-to-end (role + authority + live agent + panel).

* feat(pitch): agent-facing pitch tool + pitches panel

Complete the pitch path: the Board can now author pitches through the gateway,
and the CEO reviews/approves them in the panel.

- content_actions.pitch (Board-only) -> PitchService.create, returning an
  Envelope; wired as a do-tool (do_server + /api/v1/do/pitch + schema) and
  added to the Board's do-tools.
- Panel /pitches page: lists pitches with CEO Approve & provision / Reject;
  sidebar nav entry.

Pitch (Phase 4) is now end-to-end: author -> CEO approve -> auto-provision.

* feat(cockpit): read-only 'is the business winning?' summary

A pure aggregation for the CEO over existing data — no new state, no writes.

- CockpitService.summary(): charter north-star/objectives, delivery counts
  (in-flight/blocked/awaiting-CEO), 30-day spend vs the charter's budget cap,
  pending pitches, and the strategy engine's signals (what needs you). Stamped
  basis='proxy' — performance is a proxy until real launches.
- GET /api/cockpit/summary (CEO / Board / Main PM / Secretary).
- Panel /cockpit page + sidebar nav.

Reuses goals + usage + StrategyEngine.assess(); reads only.

* docs(changelog): add the Secretary and Cockpit to Unreleased

* fix(test): isolate the company-goals empty-defaults test from committed state

The shared test DB persists committed writes across tests; a route test
commits a charter, so the unit test's 'unset' assertion must establish its
own clean precondition rather than assume global emptiness.

* fix(gateway): lower evidence_repo complexity to rank A (xenon gate)

company_goals()'s 4-way `or` emptiness check tipped the module average to
rank B; `any(...)` is equivalent and keeps the module under the gate's A bar.

* chore(compose): mirror agent-secretary-image build into docker-compose.yaml

Both compose files are byte-identical and tracked; .yaml carries the same
agent-secretary-image build service already present in docker-compose.yml.

* chore(lifecycle): regenerate artifacts for secretary i_am_idle

The secretary role gained i_am_idle in the lifecycle spec; regenerate the
generated prompt/doc/json artifacts so foundation-check stays green.

* docs(changelog): cut the company-in-a-box phases to 0.4.0

Label the six additive phases (business goals, web research, pitch-provision,
strategy engine, secretary, cockpit) as 0.4.0; tag v0.4.0 is held until the
branch merges to master so it points at the release commit.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-15 20:47:41 +02:00
Renn F 481c5fe17b feat(gateway): run a fast quality gate at i_am_done, before QA
The developer's i_am_done submit now runs the project's fast quality gate
(lint + typecheck) in the developer's workspace and blocks the transition to
awaiting_qa if it's red, returning the failing output as the remediate hint —
so a red gate is caught at the dev's desk instead of in QA review or CI. The
slow test suite intentionally stays on CI. The gate is fail-open on
infrastructure errors (missing workspace/toolchain never blocks a submit) and a
no-op for projects that configure no lint/typecheck commands. Developer prompt
updated.
2026-06-14 04:54:05 +02:00
Renn F 2db11c7833 feat(qa): require a per-acceptance-criterion verdict before pass_review
QA may no longer pass a task with a single gestalt approval — pass_review now
takes ac_verdicts (one verification entry per acceptance criterion) and the
gateway rejects a pass that does not cover every criterion. If a criterion does
not hold, QA fails the review instead. The verdicts are folded into the
persisted qa_notes for the audit trail. Threaded through the flow MCP tool,
the HTTP request schema, and the route; QA prompt updated.
2026-06-14 04:44:21 +02:00
Renn F 8affb283f5 feat(gateway): enable 2-devs-per-cell parallelism + split-before-claim sizing
- Intake / Main-PM / Cell-PM prompts: enumerate independently-shippable work
  units, inherit the breakdown down the chain, and dispatch independents in
  parallel (dependency order, never one-at-a-time).
- Raise the code-spine concurrency cap from 1 to 2 per parent (one per cell
  developer) so both devs build in parallel; keep the same-assignee guard and
  the planning/documentation cap at 1, plus the cross-team planning exemption.
- Split-before-claim: hard-block an egregiously-bundled code leaf at delegate
  time so the PM splits it before any dev claims it; nudge the moderate band
  in the delegate success envelope.
2026-06-14 04:34:02 +02:00
a6b67a6a58 Feat: board redraft loop (#139)
* feat(board): expose board review brief + guard approve-and-start

Slice 1 of the board-informed intake re-draft loop (backend foundation):

- JournalService.board_review_brief(task_id): the PO + Head of Marketing
  DECISION_LOG entries for a task, oldest-first, each tagged with author —
  the board's review as structured data.
- GET /api/tasks/{task_id}/board-review (PM-or-above) backing the CEO's
  approval/redraft surface, so the real board analysis is readable instead
  of a placeholder; BoardReviewEntry response schema.
- Guard: approve_and_start now refuses a board task whose review is not
  complete (service invariant + precise BOARD_REVIEW_INCOMPLETE at the route).
  Previously only the UI hid the button; the backend let an early/rogue call
  hand the task to Main PM mid-review.

Tests: brief filtering/ordering + endpoint (200/404) + the two guard paths.

* feat(panel): show real board review at the approve gate + live refresh

Slice 1 frontend of the board-informed intake re-draft loop:

- tasksApi.getBoardReview + useBoardReview hook consume GET
  /tasks/{id}/board-review.
- The Approve & Start dialog now renders the actual Product Owner + Head of
  Marketing notes (markdown) instead of a static placeholder, so the CEO reads
  the board's analysis before approving.
- C2: useTask polls (4s) while a task is still on the board with an
  incomplete review, so the Approve & Start button appears as soon as the
  board finishes — there is no per-task websocket. Polling stops once
  board_review_complete flips.

* feat(intake): board-informed re-draft loop (backend, cold path)

Slice 2 of the re-draft loop:

- update_live_draft: apply a board-informed re-draft to the EXISTING task in
  place (title/description/acceptance_criteria) — never a duplicate — then route
  it: 'main_pm' hands it to the Main PM via approve_and_start; 'board' clears
  board_review_complete for another review round.
- confirm route branches on task_id → update_live_draft vs confirm_live_draft;
  LiveConfirmRequest.task_id added (scope taken from the task, not required).
- POST /live/re-interview/{task_id} (PM-or-above): spawns a fresh intake session
  seeded with the current draft + the board brief (compose_redraft_message),
  scoped to the task's product/project. The cold path + Slice-3 fallback.
- format_board_briefing / compose_redraft_message helpers.

Tests: pure helpers + update_live_draft (main_pm hand-off, re-board reset,
missing-task).

* feat(panel): board-informed re-draft entry + prompter re-draft guidance

Slice 2 panel of the re-draft loop:

- 'Re-draft with board feedback' button on a board-reviewed task detail →
  /prompter?redraft=<taskId>.
- usePrompter.startRedraft(taskId): calls POST /prompter/live/re-interview/{id},
  scopes the chat to the task, and streams the re-draft; redraftTaskId is
  threaded (persisted across reload) so confirm carries task_id and updates the
  existing task in place rather than creating a duplicate.
- prompterLiveApi.reInterview; ConfirmPayload.task_id.
- Prompter role prompt: a 'Re-drafting after board review' section so the agent
  revises the included draft from the board brief instead of starting over.

Panel verified by CI (no local node_modules).

* feat(intake): keep-alive re-draft — park the intake agent during board review

Slice 3 of the re-draft loop (in-context fidelity; cold path is the fallback):

- Registry: LiveIntakeSession.task_id + park(session_id, task_id) (keep alive
  instead of reaping) + find_by_task() for board-completion injection.
- Confirm: the board route (first pass) PARKS the intake agent instead of
  reaping, so it keeps the whole interview in context.
- Orchestrator: on board-review completion, inject the synthesized board brief
  into the parked session (_inject_board_brief_into_parked_intake) so the
  resident prompter re-drafts in-context. No-op when nothing is parked (the
  container died / a new intake replaced it) — the cold /re-interview path
  covers that. No reaper change needed (an idle parked session spends no tokens
  and the budget sweep is the only agent-stopping sweep).
- Panel: confirm(board) keeps the chat alive (parked, redraftTaskId set) with a
  notice; the injected revised draft arrives over the existing stream to approve.

Tests: registry park/find_by_task/closed-ignored. Container delivery + the full
panel parked flow need live (container-runtime) verification.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-14 00:40:18 +02:00
Renn F e0cd305844 docs(cell-pm): require mapping every cell criterion to a subtask before idling
Decomposition is where scope silently disappears: a cell PM delegates a
subtask covering most of its acceptance criteria, idles, and the uncovered
criteria have no subtask, no dev, and no branch — the gap surfaces only at
submit_up or, worse, at QA/CEO review, forcing a full cell revision loop.

Add a Coverage section to the cell-PM prompt that pulls the every-criterion-
has-a-home discipline forward from the submit_up checklist to decomposition
time. Before idling after a delegate, the PM must account for every cell
criterion as one of exactly three outcomes — covered now, covered later in a
sequenced follow-on (recorded in the decision note), or out of cell scope
(also recorded) — so a dropped criterion costs one extra delegate instead of
a whole revision loop. Reinforces that on respawn the anti-re-decompose rules
will block recovering dropped scope, so coverage must be mapped up front.
2026-06-11 05:59:43 +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
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 4c59bfa840 fix(prompt): Main PM delegate description is a goal+constraints brief, not a solution-dump
delegate already requires substantive acceptance_criteria (TASK_AT_CREATE
completeness), so the defect was not missing criteria — it was the Main PM
writing the cell-PM subtask description as prescriptive prose that dictates
the cell's solution (e.g. a full UX layout for a design task), doing the
cell's job and wasting the expertise it delegated to. The role doc now tells
the Main PM the description is a brief: state the goal + the constraints to
fit, leave the HOW to the cell, and for design tasks give the problem, not a
mockup.
2026-06-03 08:32:51 +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 3d34fc2677 feat(progress): plan-driven progress — % derived from the plan checklist (#173)
Progress was only the synthetic milestone entry (auto-emitted at
open_pr/i_am_done); agents never deliberately reported and the % was
an ungated free-form guess.

Now the plan's sub_tasks ARE the progress skeleton:
- progress() gains optional `plan_step` (a sub_task id or its 1-based
  order). With it, that step is marked completed and the percentage is
  DERIVED as completed/total (equal weight) via new
  TaskService.record_plan_progress — the agent cannot set/game it.
- A narrative entry WITHOUT plan_step is allowed for important
  mid-step documentation and carries the current derived % (the bar
  never regresses). No hard anti-spam gate (would loop minimax) —
  prompt guidance steers "meaningful moments, not every tool call".
- `percentage` is now an optional fallback, used only for tasks with
  no sub_task checklist (back-compat). v2 ProgressRequest, the do.py
  route, and the do_server MCP tool updated accordingly.
- An unmatched plan_step returns invalid_state listing the valid step
  refs (resolve by id / order / 1-based index).
- developer + documenter prompts updated to the plan_step workflow.
- Helpers extracted (_plan_subtasks/_derive_plan_pct/_valid_step_refs/
  _mark_subtask_complete) to keep record_plan_progress within the
  cyclomatic gate.

Commit 3 of 3 for the plan/progress quality work (#171/#172/#173).
2026-05-16 11:09:41 +02:00
Renn F 4c397e1768 feat(gateway): developer i_will_work_on takes a substantive step checklist (#172)
The dev plan was a free string with only a presence gate, so the
executing dev had no checklist for plan-driven progress (#173).

- IWillWorkOnRequest gains `steps` (same SubTask shape as a PM's
  sub_tasks); flow_dev route threads it through.
- i_will_work_on layers steps onto the narrative plan via the same
  panel-shaped path PMs use, so task.plan.sub_tasks is populated
  (panel render + #173 progress).
- New _dev_steps_gate (mirrors _pm_sub_tasks_gate, runs after the spec
  gate): a developer FRESH claim must supply a non-empty steps list
  with every description >= _PM_SUBTASK_DESC_MIN_LEN. Re-entry/recovery
  short-circuit before the gate (extracted _dev_reentry +
  _fresh_dev_claim keep i_will_work_on within the
  return-count + cyclomatic gates).
- developer role prompt: steps template + "thin steps rejected" + the
  progress(plan_step=...) handoff.
- Updated every dev-fresh-claim test fixture across the suite to pass
  substantive steps; added dedicated _dev_steps_gate coverage.

Commit 2 of 3 for the plan/progress quality work (#171/#172/#173).
2026-05-16 10:48:23 +02:00