The orchestrator spawned agents only by bare image names and built any
missing image from source on the host, so a deployment had to carry the
build context and a toolchain — there was no way to just pull and run the
images the release workflow publishes.
Add two settings (default empty = unchanged local-build behavior):
ROBOCO_AGENT_IMAGE_REGISTRY and ROBOCO_AGENT_IMAGE_TAG. When a registry is
set, the orchestrator spawns and ensures {registry}/roboco-agent-*[:tag] and
pulls (never builds) any image it lacks. Also adds the previously-missing
agent-secretary image to the lazy-build map.
Ship docker-compose.registry.yml: a standalone compose that pulls every
published image (GHCR or Docker Hub, pinnable version) and wires the
orchestrator to spawn the matching pre-built agent images. The existing
build compose files are unchanged.
The secretary agent image landed with the company layer (0.4.0) and is
built by docker-compose + referenced by the orchestrator, but the release
workflow's publish list was never updated, so it was absent from GHCR and
Docker Hub. Add it so a pull-based deploy has the full image set.
Add the 2.5-hour 'Working with RoboCo' build session (a conversation to a
shipped feature) as a second hero thumbnail beside the 26-min intro.
Update the agent count from 20 to 22 across the README, CLAUDE.md, usage,
the base agent prompt, the how-to guide, and the org-structure RAG doc:
the standing org gains the PR Reviewer (board-level, read-only), and the
on-demand Intake and Secretary are now counted. The org-structure doc
gains the PR Reviewer in the hierarchy, count table, board team, and
communication matrix. The historical 0.1.0 changelog entry is left as-is.
Rewrite the resource-usage section: drop the unmeasured per-agent RAM
ceiling (RAM is low and agents run few-at-a-time) and lead with storage —
the image set's shared base layer — which is what docker prune reclaims.
Scope close_pull_request repo resolution by project_id and thread the
umbrella's project into close-on-land, so a contributor PR is never
resolved (or closed) against a same-numbered PR in another project's
repo. Skip the comment + close PATCH when the PR is already closed, so a
retried sweep never re-posts the 'superseded' comment.
Require a non-cancelled descendant that actually landed a PR before
retiring the contributor PR, so an umbrella force-completed over a
cancelled code subtask leaves the contributor's still-valid PR open.
Run close-on-land from the always-on sweeper rather than the default-off
poll loop, so a supersede that lands after the feature is toggled off is
still reconciled. Serialize concurrent supersede triggers under a lock so
a double-click can't cut two branches / spawn two umbrellas. Anchor the
supersede marker checks to the marker line so appended CEO notes can't be
mistaken for the closed/dedup tokens. Make the fork-head branch cut
idempotent (forced refspec) so a commit-fail retry converges.
Also drop an importlib.reload(roboco.config) in a unit test that rebound
the settings singleton and leaked into the PM decision-window test.
When a supersede umbrella reaches COMPLETED (our own PR merged), close-on-land
retires the contributor's PR with a linking thank-you comment:
- TaskService.supersede_umbrellas_pending_close() finds landed umbrellas not yet
marked closed=1; mark_supersede_pr_closed() records the close (idempotent).
- orchestrator._close_superseded_prs runs in the external-PR poll tick: parses
the contributor PR# from the umbrella's quick_context and calls
GitService.close_pull_request(delete_branch=False) — we never touch the
contributor's fork branch. _parse_supersede_pr is unit-tested.
Completes the supersede flow: CEO authorizes -> fork branch -> Main PM -> cell
-> our PR -> CEO merge -> contributor PR closed + linked. ruff + mypy clean
(279); foundation + gateway suites green (5208).
A second adversarial review of the supersede flow found a feature-breaking HIGH
plus correctness gaps:
- HIGH: parenting the umbrella to the review task burned a MAX_TASK_DEPTH level
(review->umbrella->cell-PM->dev = depth 4 > 3), so the dev code task could
never be created and the work never reached a cell. Fix: the umbrella is now a
ROOT task; the contributor PR# + review link ride quick_context (also
simplifies close-on-land — no parent walk).
- MED: no dedup — a repeat CEO trigger created duplicate umbrellas / two racing
PRs. Fix: find_supersede_umbrella() makes the trigger idempotent (returns the
existing umbrella).
- LOW: supersede worked on un-reviewed/cancelled review tasks. Fix: require
review.status == COMPLETED (review-first).
- LOW: a partial failure could orphan a pushed fork branch. Fix: create the
umbrella before the push, and log the branch so any orphan is discoverable.
ruff + mypy clean (279).
The org takes over a reviewed external PR and finishes it itself:
- POST /api/tasks/{id}/supersede-external-pr (CEO-only) -> orchestrator
.supersede_external_pr: confirms the review task (this CEO action authorizes
running the contributor's code), cuts a roboco-owned branch off the fork head
(create_branch_from_pr_head — the only point untrusted code enters a roboco
branch), and creates the supersede umbrella.
- TaskService.create_supersede_umbrella: a planning task on the same repo,
parented to the review task (contributor PR# stays reachable for close-on-land),
carrying the pre-cut fork branch, handed to Main PM to delegate to a cell.
confirmed_by_human=True.
From there the work rides the normal lifecycle (Main PM -> cell -> our PR ->
QA/doc/PM -> CEO). ruff + mypy clean (279); app builds with the new route.
Follow-up: close-on-land of the contributor PR + an adversarial review pass.
An adversarial review of the feature found two blocking defects (both would
surface the moment external_pr_enabled is turned on) plus hardening gaps:
- HIGH: the enforcement legacy role-gate overlay OVERWROTE spec-derived roles,
so pr_reviewer was erased from the (in_progress->completed) edge it shares
with the PM self-complete gate — the review task could never complete. Fix:
UNION legacy + spec roles instead of overwriting (also preserves the legacy
'add roles' intent on every shared edge).
- HIGH: claim_pr_review routed claim+start through the verb runner, which hit
start()'s plan gate (planless review task -> None -> crash/respawn loop) and
auto-created+pushed a stray branch (violating the read-only/branchless
invariant). Fix: mirror QA's claim_review — a verb-body TaskService.pr_review_claim
does pending->in_progress with no plan and no branch.
- MED: add the pr_reviewer Write(*)/Edit(*) deny at the permission layer (it
ingests untrusted PR diffs — make read-only explicit, not implicit).
- MED: regenerate the verb-table artifacts (the schemas existed but the
generator had not been re-run; the agent prompt showed 'unknown' signatures).
ruff + mypy clean (279 files); foundation + gateway suites green (5205 passed).
The safe core of supersede: fetch a contributor PR's head via the GitHub
special ref refs/pull/{n}/head into a roboco-owned local branch and push it to
origin, so a dev cell can finish the work on a branch WE own and merge. We never
push to the contributor's fork. First point untrusted code enters a roboco
branch — callers invoke it only for a human-confirmed supersede.
At ingest, a non-empty external_pr_author_allowlist restricts which external
PRs are reviewed to those GitHub logins (case-insensitive). An empty allowlist
(default) reviews every external PR — safe because the review is read-only; the
confirmed_by_human gate still guards any later supersede that runs fork code.
Unit-tested (_pr_author_allowed).
Make the reviewer verbs reachable and dispatched:
- flow_server: claim_pr_review / post_pr_review MCP tools + registry entries.
- flow API: a flow_pr_reviewer router (/api/v1/flow/pr_reviewer/{verb}) with
give_me_work / claim_pr_review / post_pr_review / i_am_idle, gated by a new
require_pr_reviewer dependency; request schemas ClaimPrReviewRequest /
PostPrReviewRequest; router registered in the app.
- orchestrator: _dispatch_pr_review_work routes PENDING source='external_pr'
tasks to the single global pr-reviewer-1 (no pre-claim — the reviewer claims
via claim_pr_review, which needs the task PENDING; guarded on is_agent_active)
+ _build_pr_review_prompt (read-only, trust-boundary framing) + the builder
map entry. _dispatch_pm_work and _dispatch_dev_work now skip
source='external_pr' so only the reviewer ever handles review tasks.
ruff + mypy clean (279 files); app builds with all four pr_reviewer flow routes;
foundation + flow-mapping suites green.
Implement the choreographer + service layer for inbound external-PR review:
- PRReviewerMixin (claim_pr_review, post_pr_review) composed into the
Choreographer MRO. claim_pr_review runs claim+start (pending->in_progress) and
returns the PR's unified diff INLINE; post_pr_review runs pr_review_done
(in_progress->completed) then posts ONE change-request to GitHub from the verb
body (a2a.send pattern), gated on a journal:learning entry.
- VerbRunner: _do_pr_review_done atomic handler -> TaskService.complete_review.
- TaskService.complete_review: validated in_progress->completed for the review
task, attributed to the reviewer.
- GitService.get_pr_diff: read-only unified diff via the GitHub API (the fork
code is never checked out or run).
- Enforcement: review tasks (source='external_pr') are branch-gate exempt for
claimed->in_progress (they do no git of their own, like coordination tasks).
ruff + mypy clean (278 files); composed choreographer imports with both verbs.
GitService.post_pr_review posts a single review via POST /pulls/{n}/reviews
(REQUEST_CHANGES by default; APPROVE/COMMENT supported) — the first /reviews
call in the codebase. Resolves owner/repo/token from the project slug,
authenticates as the PAT owner (Bearer), and raises GitError on any token or
GitHub failure so the calling side-effect can surface it. This is the capability
the pr_reviewer's post_pr_review verb invokes after its DB commit. httpx fully
mocked in tests (request shape, auth, error paths).
Add the dormant inbound path for external-PR review (gated by external_pr_enabled,
off by default):
- GitService.list_open_prs lists a project's open PRs, normalized with fork /
author-association classification (the inbound counterpart to the org's
outbound, head-filtered PR calls).
- TaskService.ingest_external_pr + external_review_task_exists create one
de-duped review task per newly-seen external PR (source='external_pr',
confirmed_by_human=False) — a gate so no agent fetches or runs contributor
code until a human confirms the PR.
- A poll loop in the orchestrator, mirroring the strategy-engine loop: only when
enabled it lists each active project's open PRs, ingests the external ones, and
wakes the dispatcher.
The trust-critical author/fork classifier is unit-tested; the GitHub-list and
DB-ingest paths are exercised by the integration gate.
Introduce the configuration contract for inbound external-PR review, mirroring
the strategy-engine block: external_pr_enabled (master switch, off),
external_pr_poll_interval_seconds (>=60), external_pr_author_allowlist, and
external_pr_require_human_confirm (default true). All inert by default — no
inbound GitHub call and no untrusted-code execution until the CEO opts in and a
human confirms an ingested PR.
Add an architecture doc for the company-in-a-box layer (charter, pitches,
strategy engine, cockpit signals, feature toggles) — none of it was in the KB
the agents query — and note the on-demand human-facing roles (prompter,
secretary) in org-structure so they are no longer invisible.
The agents' runtime KB had drifted three releases behind the gateway. Add the
two missing role docs (prompter, secretary — both live-session SDK chat roles,
human-only) and fold the AC/decomposition guardrails and per-dev code queues
into the high-traffic PM docs:
- task-model: acceptance_criteria_ids + parent_ac_refs fields and how the
child->parent AC link works.
- task-planning: covers_parent_criteria on delegate, the parent_ac_coverage /
unclaimed_parent_acs briefing fields, the decomposition-floor and roll-up
gates (safe-by-construction), and per-dev sequenced code queues.
- task-tools: same coverage note + correct stale verbs — QA is
pass_review/fail_review (not pass/fail), cell_pm gains reassign, and main_pm
no longer claims submit_up/reassign it does not have.
Promote the Unreleased section to [0.5.0] - 2026-06-16 — AC/decomposition
guardrails, per-dev sequenced code queues, the unified Business page, the 26
panel UI fixes, and the spawn/PR/ownership firefight fixes — and add the 0.5.0
compare link.
Correct the Removed note: the /cockpit, /company-goals, /secretary, and
/pitches panel routes are deleted (404), not redirected; the relocated strategy
signals are served by the new GET /api/cockpit/signals endpoint.
Bump the version 0.2.0 -> 0.5.0 across pyproject, __init__, config app_version,
panel package.json, and the uv.lock self-entry — these had drifted unbumped
since 0.3.0.
* [0c66b856] Frontend: Build tabbed Business page consolidating Goals/Secretary/Pitches (#183)
* [c9f00d0d] feat(business): add /business tabbed page consolidating Goals, Secretary, Pitches (#182)
- Create src/app/(dashboard)/business/page.tsx with URL-driven Tabs (goals|secretary|pitches), reading ?tab= via useSearchParams; defaults to 'goals'
- Create src/components/business/goals-tab.tsx: key-introspected form fields for objectives items and operating_policy (no raw JSON textareas), updated_at/updated_by metadata, skeleton loading, OfflineState on error
- Create src/components/business/secretary-tab.tsx: ReactMarkdown (GFM) chat bubbles, structured directive cards with labeled key-value rows, RequiredNotesDialog for reject, skeleton loading, OfflineState on error
- Create src/components/business/pitches-tab.tsx: sub-header Refresh button, PitchCard skeleton loading, OfflineState on error (not empty-state text), RequiredNotesDialog for both Approve and Reject
- Create src/components/ui/required-notes-dialog.tsx: Submit disabled on empty/whitespace, Cancel closes without action, state resets on each open via key pattern
- Update sidebar.tsx: remove Cockpit/Company Goals/Secretary/Pitches entries, add single Business entry (Building2 icon, /business)
- Replace company-goals/page.tsx, secretary/page.tsx, pitches/page.tsx with server-side redirect() to /business?tab=X
- Replace cockpit/page.tsx with notFound() (404)
- All tabs: shadcn Card + Skeleton, sonner toast for success/error
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
* [e3e5ff9b] feat(dashboard): add StrategySignalsPanel next to CeoApprovalQueue in a 2-column grid layout (#181)
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
* refactor(panel): delete the consolidated old routes instead of stubbing them
cockpit/company-goals/secretary/pitches are fully consolidated into /business,
so the old route pages are dead code. Remove the four page.tsx files outright
rather than keep redirect/404 stubs — the clean move is to delete, not add.
The sidebar already points only at /business; no internal links reference the
old routes (the remaining /company-goals|/secretary|/pitches|/cockpit strings
are backend API paths the API clients call, unaffected). Old bookmarks now
resolve to Next's default 404, which is correct for a removed route.
* perf(cockpit): light /cockpit/signals endpoint for the Dashboard panel
The relocated Strategy Signals panel was calling /cockpit/summary, which runs
the whole fan-out (company goals + usage/spend + task-counts + pitches +
strategy assess) just to read the signals. Add CockpitService.signals() +
GET /api/cockpit/signals (CockpitSignals schema, same _COCKPIT_ROLES gate) that
runs only StrategyEngine.assess(), and repoint the panel (+ cockpitApi.signals()
client method, CockpitSignal type). Now the Dashboard fetches only what it
shows. Backend gated: ruff + full mypy + 6 cockpit tests green (live DB).
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
* [41219301] Fix 26 verified UI bugs in the panel dashboard (#174)
* [115788ef] API/state bugs batch 1 — PATCH fix, WebSocket reconnect, agent roster, sessions export, timestamp (#173)
* fix(orchestrator): launch agent MCP servers with uv run --no-sync
Agent MCP servers (flow/do/git-readonly/optimal/docs/search) are launched as
`uv run python -m roboco.mcp.<server>` with cwd = the agent's workspace clone.
When that clone's uv.lock drifts from the baked image, `uv run` re-syncs the
dependency set mid-spawn and the servers never reach "connected" — they sit at
status="pending", so the agent gets zero gateway verbs. It then can't claim,
commit, or even i_am_idle (all MCP verbs), so its Stop is rejected and it
respawns in a loop, re-doing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does not stop the cwd-relative
resolve/sync; --no-sync does, so the servers reuse the baked /app/.venv as-is
and start instantly. PMs were unaffected only because they run from /app where
the env already matches the lock.
* fix(agent): launch agent uv-run subprocesses with --no-sync
Agents with a write workspace (developer/product_owner/head_marketing/documenter)
run with cwd = their git workspace clone. Claude Code launches each MCP server
(flow/do/git-readonly/optimal/docs/search) and the SDK server as
`uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the
baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's
lock — a multi-minute stall on a cold wheel cache — so the servers never reach
"connected": they sit at status="pending" and the agent gets ZERO gateway
verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected,
and it respawns in a loop redoing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative
resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the
servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles
(qa/cell_pm/main_pm/auditor) were unaffected because their env already matches.
- orchestrator.py: --no-sync on all 6 generated MCP servers
- docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch
- test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync
* [115788ef] fix(api): use PATCH not PUT in tasksApi.update(), remove WS double-increment, fix staleTime/roster id, remove sessions groupsApi dup, add < 1h ago label
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
* [db2c341f] UI/visual bugs batch 1 — priority labels, QA columns, DnD prompt, CEO dialog, dark mode (#172)
* fix(orchestrator): launch agent MCP servers with uv run --no-sync
Agent MCP servers (flow/do/git-readonly/optimal/docs/search) are launched as
`uv run python -m roboco.mcp.<server>` with cwd = the agent's workspace clone.
When that clone's uv.lock drifts from the baked image, `uv run` re-syncs the
dependency set mid-spawn and the servers never reach "connected" — they sit at
status="pending", so the agent gets zero gateway verbs. It then can't claim,
commit, or even i_am_idle (all MCP verbs), so its Stop is rejected and it
respawns in a loop, re-doing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does not stop the cwd-relative
resolve/sync; --no-sync does, so the servers reuse the baked /app/.venv as-is
and start instantly. PMs were unaffected only because they run from /app where
the env already matches the lock.
* fix(agent): launch agent uv-run subprocesses with --no-sync
Agents with a write workspace (developer/product_owner/head_marketing/documenter)
run with cwd = their git workspace clone. Claude Code launches each MCP server
(flow/do/git-readonly/optimal/docs/search) and the SDK server as
`uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the
baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's
lock — a multi-minute stall on a cold wheel cache — so the servers never reach
"connected": they sit at status="pending" and the agent gets ZERO gateway
verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected,
and it respawns in a loop redoing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative
resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the
servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles
(qa/cell_pm/main_pm/auditor) were unaffected because their env already matches.
- orchestrator.py: --no-sync on all 6 generated MCP servers
- docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch
- test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync
* [db2c341f] fix(ui): priority badges, QA columns, DnD dialog, CEO label, dark mode
- priority-indicator.tsx: update labels P0→P0-Highest etc, add text-xs to all color strings, fix className operator precedence bug
- task-table.tsx: match priority label format and add text-xs to badge className
- kanban-column.tsx: show QA Pass/Fail buttons in VERIFYING column alongside AWAITING_QA
- kanban-board.tsx: intercept DnD drops onto NEEDS_REVISION/AWAITING_DOCUMENTATION to show notes dialog when showQaActions is true
- task-action-dialogs.tsx: change CeoApproveDialog Label from 'Approval notes' to 'Notes required'; fix all Cancel buttons to call handleOpenChange(false) so state is cleared on dismiss
- create-task-dialog.tsx: reset form when dialog is closed without submitting
- active-blockers-panel.tsx: add dark:border-red-900 dark:bg-red-950 dark:hover:bg-red-900 to blocker items
- notifications/page.tsx: add dark: Tailwind variants for NORMAL, HIGH, URGENT priority badge colors
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
* [123e2ec2] Fix remaining 15 UI bugs — revision pass after CEO rejection (#178)
* [cdb9b22a] fix(ui): task-header BACKLOG/NEEDS_REVISION actions, P0 priority label, chat-composer safe clear, and inline-edit double-mutation guards (#176)
- task-header.tsx: add BACKLOG ('Activate Task') and NEEDS_REVISION ('Start Revision') cases to getAvailableActions() switch so the Actions dropdown is never empty for those statuses
- draft-proposal-card.tsx: PRIORITY_LABELS[0] changed from 'Urgent' to 'Highest' to match backend contract
- chat-composer.tsx: move setValue('') inside try-block after onSend resolves; a failed send now preserves the textarea text
- acceptance-criteria.tsx: onMouseDown={(e)=>e.preventDefault()} on inline-edit save button to prevent onBlur+onClick double API mutation
- tab-dependencies.tsx: same onMouseDown guard on parent-task inline-edit save button
- tab-plan.tsx: onMouseDown guards on all inline-edit/add save buttons (ApproachSection, SubTasks, TechConsiderations, Risks, OpenQuestions)
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
* [01852d69] fix(dashboard): wire real agent status, refetch all 4 queries, error indicator, Coming Soon tooltip on search, sentinel div auto-scroll in message-list and mentor-chat, and New Report / Generate Report button mutations in reports-panel and auditor-dashboard — all 9 files fixed (#177)
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
* [3f35f502] feat(tasks): add case activate and case start-revision to handleAction switch in task detail page (#179) (#180)
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
* fix(tasks): route Start Revision through the operator status override
The new "Start Revision" action on a NEEDS_REVISION task called
lifecycle.start (POST /tasks/{id}/start), which is assignee-only — so an
operator/CEO clicking it from the task detail page got a 403 ("Only the
assigned agent can start this task") instead of a transition.
Route it through useUpdateTask (PATCH /tasks/{id} with status=in_progress)
instead. The backend treats status as an audited admin override applied via
admin_set_status and gated on elevated (ASSIGN) permissions — the same
god-mode path the kanban board uses for operator status changes — so the
operator can nudge a needs_revision task back into progress for its assignee
to rework. Mirrors the existing kanban updateTask.mutateAsync shape.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Per-dev sequenced queues (the prior commit) have a PM delegate a dev's whole
code queue up front, so a dev owns several pending, assigned-but-unclaimed code
leaves at once (seq0 + seq2). The orchestrator's lane barrier holds the seq2
SPAWN while seq0 is non-terminal — but _pending_assignment_guard rejected
i_am_idle for ANY pending assigned task, with no lane awareness. So a dev whose
current leaf just moved to QA (awaiting_qa) could neither idle (guard rejects)
nor proceed cleanly: it was steered to claim seq2 early (the claim path has no
lane/sequence check, since delegate sets `sequence` not `dependency_ids`),
jumping its own queue order, or it looped on the rejection. An adversarial
review of the queue work surfaced this; it is latent until PMs actually
delegate multi-item per-dev queues, so the green suite hid it.
Fix: TaskService.has_earlier_incomplete_code_sibling mirrors the orchestrator's
lane barrier in the service layer; _pending_assignment_guard now drops a dev's
lane-held pending code leaves (via _pending_blocking_idle / _pending_not_lane_held)
so the dev idles cleanly and the orchestrator spawns the next queue item when
the lane clears — preserving one-leaf-at-a-time, in order. `is not True` keeps
it inert under partial test mocks. Tests cover the service primitive (live /
terminal / higher-seq / non-code / missing-field) and the guard (dev idles when
lane-held; still blocks a non-lane-held pending leaf). Full mypy + xenon green.
The spec-2 change added `include_ac_coverage` to Choreographer._briefing_for
(_impl.py) but not to the typed stub in _protocol.py that the role mixins
inherit for static analysis. The composed Choreographer (board/doc/qa mixins +
_LegacyChoreographer) then had two incompatible _briefing_for signatures in its
MRO — caught by full `mypy roboco/ tests/` (not by a per-file check). Add the
same keyword to the stub. mypy clean at CI scope (648 files).
regenerate_verb_tables.py looped every role in ROLE_CONFIGS, emitting a
_generated/<role>.md for prompter and secretary too. Both intentionally keep
only note+evidence in role_config — their real tools live in their agent_sdk
drivers (intake: propose_draft; secretary: read_state/read_task/
submit_directive, the last gated through the backend /directives), and neither
uses the _generated/<role>.md prompt-composition path. So the generated tables
understated those roles and showed up as perpetually-untracked noise.
Skip the driver-based roles (_DRIVER_BASED_ROLES) in both the aggregate verbs.md
and the per-role file output, with a comment pointing at the real surfaces.
Regenerated verbs.md drops the two misleading sections.
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.
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.
The AC coverage + roll-up gates (specs 1, 4) are inert until PMs declare which
parent criteria each subtask covers. Thread covers_parent_criteria end-to-end so
they can: MCP delegate tool -> DelegateRequest schema -> cell_pm + main_pm routes
-> DelegateInputs -> child.parent_ac_refs. Additive/optional — no behavior change
until a PM populates it; the tool docstring instructs splitting the parent's
criteria across subtasks so their union covers all of them.
ruff + mypy clean; 107 delegate/flow_server tests green.
A parent could complete / submit_up / escalate_to_ceo once its subtasks were
merely terminal — never checking whether the parent's acceptance criteria were
actually satisfied. That's how PR #175's half-built umbrella sailed to CEO
approval (escalate_to_ceo had no subtask/AC check at all).
- TaskService.uncovered_parent_acceptance_criteria(parent): parent ACs not
covered by a COMPLETED child (via parent_ac_refs). Safe-by-construction —
returns [] unless a child declares coverage, so it is INERT for tasks
decomposed before coverage tracking and activates only once a PM maps
children to parent criteria. Cancelled children do not count.
- _parent_acs_covered_envelope wired into all four roll-up gates: cell_pm_complete,
main_pm_complete, submit_up, and escalate_to_ceo (the weakest — previously
only journal:decision). isinstance guard keeps it inert under partial mocks.
- 4 new tests; 57 task + 89 gateway tests green.
Pairs with spec 2 (coverage at decompose-time forces the linkage this enforces).
Foundation for the decomposition-coverage and roll-up AC-verification gates.
Acceptance criteria were a flat list[str] with no per-criterion identity, so
nothing could relate a child task's criteria to the parent's — letting a PM drop
half a parent's ACs unnoticed (PR #175).
- migration 036: additive acceptance_criteria_ids + parent_ac_refs array columns;
backfills stable md5(task_id:index) ids for existing rows.
- Task model + TaskCreateRequest + db table: the two fields.
- TaskService.create generates one stable id per criterion (1:1) when absent.
- DelegateInputs.covers_parent_criteria -> child.parent_ac_refs (the linkage),
propagated through create_subtask.
- regression-safe (53 task tests green) + 1 new test.
Coverage gate (spec 2), roll-up AC gate (spec 4), per-dev sequenced queues
(spec 3) build on this. Design: docs/SPEC_AC_GUARDRAILS_2026-06-16.md.
A developer that hits a wall calls i_am_blocked, which escalates the code task
to its cell PM (assigned_to=PM, BLOCKED) and snapshots the dev as
pre_block_assignee — the intended dev->cell-PM triage handoff. The in-band
recovery (unblock(restore=True)) hands ownership back to the dev. But the
OUT-OF-BAND paths — the operator PATCH /tasks/{id} status override and the
orchestrator's own _auto_recover_blocked_parent / _auto_resume_paused_parent —
go through admin_set_status, which set only status and never restored the owner.
The task re-entered pending/in_progress still owned by the PM, and the dispatcher
then execute-spawned the PM on a code task it cannot do ('break this down and
delegate' against the task itself) -> respawn loop.
admin_set_status now, when taking a task out of 'blocked' into pending/in_progress
with a pre-block snapshot present, routes through the existing
_apply_pre_block_restore primitive (the same one unblock(restore=True) uses) to
hand ownership back to the executor. Every other override is unchanged, and the
escalate/apply_escalation/block-down path is untouched, so the dev->cell-PM
handoff still works. + 2 regression tests.
open_pr -> GitService.create_pr posted "base": parent straight to GitHub, so
when the parent (an ancestor task's integration branch) was never pushed — a PM
paused before its first push, or the workspace was wiped — GitHub 422'd "base
field invalid" and stranded every child PR. The base-existence fallback added
in 3d9dd298 lived only in create_pull_request, which open_pr never calls.
Add _ensure_base_on_remote and call it in create_pr: if the base branch is
absent on origin, create it off the default branch's tip (preserving the
integration hierarchy) instead of failing; fall back to the default branch only
if that create push itself fails. Covered by 3 new tests.
Agents with a write workspace (developer/product_owner/head_marketing/documenter)
run with cwd = their git workspace clone. Claude Code launches each MCP server
(flow/do/git-readonly/optimal/docs/search) and the SDK server as
`uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the
baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's
lock — a multi-minute stall on a cold wheel cache — so the servers never reach
"connected": they sit at status="pending" and the agent gets ZERO gateway
verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected,
and it respawns in a loop redoing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative
resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the
servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles
(qa/cell_pm/main_pm/auditor) were unaffected because their env already matches.
- orchestrator.py: --no-sync on all 6 generated MCP servers
- docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch
- test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync
Agent MCP servers (flow/do/git-readonly/optimal/docs/search) are launched as
`uv run python -m roboco.mcp.<server>` with cwd = the agent's workspace clone.
When that clone's uv.lock drifts from the baked image, `uv run` re-syncs the
dependency set mid-spawn and the servers never reach "connected" — they sit at
status="pending", so the agent gets zero gateway verbs. It then can't claim,
commit, or even i_am_idle (all MCP verbs), so its Stop is rejected and it
respawns in a loop, re-doing work it can't submit.
UV_PROJECT_ENVIRONMENT pins the venv location but does not stop the cwd-relative
resolve/sync; --no-sync does, so the servers reuse the baked /app/.venv as-is
and start instantly. PMs were unaffected only because they run from /app where
the env already matches the lock.
* 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>
Token enforcement currently covers the REST API only; the /ws/* endpoints —
including the read-only operator stream /ws/system, which carries rate-limit and
token-usage telemetry — do not validate X-Agent-Token even in secure mode (nginx
injects it for the panel, but a direct WS connection is not rejected). Recorded
under the existing trusted-network disclaimer; the streams are read-only with no
control surface.
- permissions: the Auditor is a silent, read-only observer with no say/dm in
its verb surface, so can_write_channel now returns False for it — matching
the role's real capabilities instead of granting an unreachable channel
write (test updated to assert read-only).
- Makefile: make lint and make gate now type-check mypy roboco/ tests/, matching
make quality / make quality-fast, so the developer-desk gate also catches test
type errors before submit (tests/ is already mypy-clean).
- docs: CLAUDE.md no longer lists USAGE_UPDATE — only USAGE_SNAPSHOT is published
to /ws/system.
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].
Two completion-stranding fixes, reimplemented on current master from
CoreyRDean's #120 and #121:
- merge_pull_request: on a 405 (repo disallows the requested merge method — e.g.
squash merges turned off in repo settings), look up a permitted method
(_first_allowed_merge_method, preferring squash > merge > rebase) and retry
once. A repo's merge-button config can no longer permanently wedge the PM on
an open, mergeable PR. No behavior change when the requested method is allowed.
- create_pull_request: if the resolved PR base branch is missing on origin (an
ancestor task claimed but never pushed -> GitHub 422 "base field invalid"),
ls-remote the base and retarget to the project default branch
(_pr_base_on_remote), mirroring the existing create_branch fallback. No
behavior change when the base exists.
Both funnel through the central git paths so every caller benefits. Adds unit
tests for both fallbacks.
Recall no longer depends on a per-query HyDE LLM call — it comes from the index.
Each chunks_<type> table gets a generated `tsv` column + GIN index (migration
031; the engine CREATE TABLE matches so fresh tables get it too).
VectorStore.hybrid_search fuses pgvector cosine with Postgres full-text in one
query: score = min(1, cosine + 0.3 * normalized_ts_rank). A vector-only match
keeps its cosine score (so decisions/reviewer thresholds are unchanged), a
keyword match adds a bounded boost (the recall win), and a keyword-only match
stays low. Empty/garbage query text degrades to pure vector.
HyDE is removed from the search hot path: _compute_query_embedding now embeds
the query directly, and _generate_hyde_passage / rag_use_hyde /
IndexConfig.use_hyde are deleted. So a search is one local embed + one indexed
SQL — no LLM round-trip. The raw query text is threaded through the
embed-once + concurrent fan-out (search_with_embedding(embedding, query_text)).
Verified live via a real pgvector round-trip: vector ranking + keyword boost +
[0,1] scores + empty-query fallback all correct. Adds wiring + fan-out unit
tests; the fusion SQL itself is verified live (needs pgvector, not gated in CI).
OptimalService.search / query (via _aggregate_citations) ran each index's
plugin.search() sequentially, and every plugin.search re-ran HyDE + embed — so an
N-index query made N LLM+embed round-trips in series (~28s across all indexes,
even though the SQL is fast). Embed the query ONCE
(BaseIndexPlugin.compute_query_embedding) and run every index's vector search
concurrently against that single embedding (search_with_embedding +
asyncio.gather). The search/query signatures and return contract are unchanged;
behavior is identical, just ~Nx fewer embed calls and parallel fetch.
Adds a regression test asserting one embed + per-index fan-out.
VectorStore.search / list_docs called dict(row["metadata"]), but asyncpg
returns jsonb as a JSON *string* (no codec on the pool), so dict() iterated
characters and raised 'dictionary update sequence element #0 has length 1; 2 is
required' — making every KB search fail at the row-mapping step once migration
030 let the query reach rows (it was masked before by the missing content
column). Add _as_dict(): json.loads a string, pass dicts through, null/non-object
-> {}. Caught by live end-to-end verification on the NAS.
An adversarial audit of the piragi -> in-house swap surfaced nine confirmed
issues; this fixes all of them.
- Re-ingest now REPLACES a source's chunks instead of appending. Add
VectorStore.delete_by_source and BaseIndexPlugin.replace_on_reingest (default
True), called before add_chunks in both ingest paths. Without it every
startup / periodic / manual reindex appended a fresh copy of each doc's
chunks, growing the tables unbounded and crowding out distinct results.
Conversations opt OUT (replace_on_reingest=False): their many messages share
one source URI, so delete-by-source would wipe history.
- index_* now honor the plugin IngestResult. The explicit record endpoints
(error / standard / decision / review / learning) raise on failure instead of
writing a green tracking row for content that never persisted;
conversation / journal indexing stays best-effort but skips the tracking row
when the embed fails. index_message / index_entry return IngestResult.
- A deprecated index type (code) now returns 404 instead of a 500 leaked from
_get_plugin's missing-plugin error: add OptimalService.is_index_registered
and guard the stats / clear / refresh routes. The panel drops the dead 'Code'
category, filter, badge, label, and mock data.
- Panel: getContext reads 'results' (matches SearchResponse) instead of a
non-existent 'context' field; the reindex toast no longer reports phantom
'0 code files'; the stats 'Updated' label uses the max timestamp across
indexes rather than indexes[0]; ProactiveContextItem matches the wire shape.
- Drop the always-zero per-document chunk_count from the documents API.
- Remove dead RAG settings (hybrid_search, cross_encoder) the engine never
consumed, and correct stale piragi / BM25 references in code, README, and
CLAUDE.md. Delete the unused duplicate roboco/kb embedder package the swap
shipped.
Adds tests for replace-on-reingest (incl. the conversations carve-out) and the
deprecated-index 404.
The initial 030 used sa.inspect(op.get_bind()) for its conditional ALTERs, which
raises NoInspectionAvailable under `alembic upgrade --sql` (offline mode) —
breaking test_enum_migration_parity and any offline SQL generation. Reimplement
the same idempotent rename (text->content) + add (created_at) as a self-contained
plpgsql DO block that guards on information_schema at execution time, so it
renders offline and runs online identically. Round-trip verified.
The in-house RAG engine (which replaced piragi) reads/writes a `content`
column and a `created_at` column on every chunks_<index_type> table and
provisions them at runtime via CREATE TABLE IF NOT EXISTS. On databases that
already carried the piragi-era tables (column `text`, no `created_at`) that
DDL is a silent no-op, so the engine never reshapes them and every
ingest/search/list fails with `column "content" ... does not exist`.
Migration 030 ALTERs each existing chunk table in place — renames
text -> content and adds created_at — preserving the non-rebuildable agent
knowledge (journals, decisions, errors, learnings, reviews, conversations)
that a docs reindex cannot regenerate. It guards on actual column presence,
so it is idempotent and safe on piragi-shaped, already-correct, or absent
tables (e.g. chunks_code).
Adds a guard test pinning the migration's table list to the IndexType enum so
a new index type cannot silently escape the schema alignment.