655 Commits
Author SHA1 Message Date
Renn F f83f930323 chore: set CLA governing law to Italy 2026-05-31 00:56:52 +02:00
Renn F 183151baf1 chore: license under AGPL-3.0 and add Contributor License Agreement
- Add full AGPL-3.0 LICENSE (canonical GNU text)
- Switch README and pyproject.toml from MIT to AGPL-3.0
- Add CLA.md (individual + entity) granting relicensing rights
- Add CONTRIBUTING.md explaining workflow and why the CLA exists
- Add CLA Assistant GitHub workflow to enforce signing on PRs
- Document licensing stance in CLAUDE.md
2026-05-31 00:53:45 +02:00
Renn F 08b56d4680 fix(gateway): i_documented auto-records journal:reflect from submission
The reflect gate's first, by-design tracing_gap ('journal:reflect missing')
counts toward the per-verb circuit breaker (i_documented limit 3 / 60s). A
documenter that fumbled note(scope='reflect') even twice got locked out and
went idle, stranding the task in awaiting_documentation with the PR orphaned.

i_documented now synthesizes the required reflect entry from the notes + files
it already carries, but only when the submission clears the notes/files gate
thresholds and the agent did not journal one themselves (theirs is richer and
left untouched). One call, no loop.
2026-05-28 00:24:42 +02:00
Renn F c1d0eefd20 fix(orchestrator): dispatch board agents for assigned board-team tasks
No dispatcher ever spawned board roles (product-owner / head-marketing) —
_handle_pm_assigned_task gates on _PM_AGENTS and there was no board path —
so a task assigned to the Product Owner sat pending forever (surfaced by the
first board-led run). Board roles advise: triage / note / say / escalate_to_ceo
/ i_am_idle, with NO verb to claim, plan, delegate, or complete. So a respawn
cannot advance the task and would just loop.

Add _handle_board_assigned_task: spawn the assigned board agent exactly ONCE
(tracked in _board_dispatched) with a review prompt that steers it to its real
verbs (record requirements via note, discuss via say, then i_am_idle). The
board review is recorded; the CEO then reassigns the task to Main PM for
delegation (the handoff stays CEO-mediated, by design — board roles cannot
delegate). _dispatch_pm_work routes board-assigned tasks here.
2026-05-25 02:48:13 +02:00
Renn F bc5e016d6d fix(panel): align task actions to server contract + collect required audit notes
The panel's human action buttons had drifted from the server request
schemas: wrong field names (qa_notes/reason vs notes), missing bodies
(cancel/complete/submit-pm-review), and a bare-string docs-complete body —
so cancel/pass-qa/fail-qa/escalate-to-ceo 4xx'd and decisions recorded no
audit note. (Agents were unaffected — they go through the gateway.)

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

Verified: pnpm tsc --noEmit and eslint both clean.
2026-05-24 07:10:34 +02:00
Renn F 5120b5ce81 fix(api): require substantive audit notes on human task-decision endpoints
Audit/tracing rule: every human decision must record its rationale. These
panel-facing routes accepted empty/absent notes, leaving no trail:
- docs-complete: now requires notes (>=20) — what was documented
- submit-pm-review: now requires notes (>=20) — what is ready for review
- complete: now requires justification (>=20) — why the task is done

Mirrors the existing pass-qa / ceo-approve notes gates (checked after the
404/403 so not-found and forbidden still take precedence). submit-qa is
left as-is: it already gates on commits + PR + progress_updates +
self_verified, and the panel collects no extra note there to drop.
Tests updated to send notes; added complete-without-justification reject.
2026-05-24 07:05:33 +02:00
Renn F c093996efc fix(ceo-approve): require substantive notes; panel collects them
ceo-approve was bound to QANotes (notes required), so the panel's
one-click approve (posts {}) 422'd. The wrong fix is to waive notes —
that empties the audit record for a production merge. Instead require
substantive notes (>=20 chars, mirroring pass-qa) and make the panel
COLLECT them: a new CeoApproveDialog (mirrors the reject dialog) gates
the 'Approve & Merge' action, and the dashboard approval-queue enforces
the same before POSTing. The CEO sign-off note is now always captured.
2026-05-24 06:59:37 +02:00
Renn F d49d1cdb37 feat(gateway): developers author the full rich plan, at parity with PMs
A dev's i_will_work_on stored a flat {text} plan and routed steps to
progress, so the dev leaf's Plan tab rendered empty (no approach,
sub_tasks, technical_considerations, risks) — zero audit/tracing on the
task that does the actual work.

i_will_work_on now captures the same rich plan a PM authors via
i_will_plan: plan(>=150) doubles as approach, steps become sub_tasks,
plus technical_considerations + risks (open_questions optional). A new
_dev_plan_gate enforces them on FRESH claims only (re-entry/recovery
short-circuit before it). set_plan gains a no-downgrade guard so a
flaked-then-recovered dev can't clobber its rich plan back to flat (the
actual mechanism behind the empty leaf).
2026-05-24 06:59:25 +02:00
Renn F a8056892b6 fix(gateway): main_pm completes an in_progress root; walk it to CEO
A root resumed from paused (its subtasks all terminal) sits in
in_progress, but escalate_to_ceo requires source=awaiting_pm_review and
nothing moves the root there — submit_up is cell-PM-only. main_pm_complete
rejected the in_progress root ("expected awaiting_pm_review"), so the
chain stalled one step short of CEO.

main_pm_complete now:
- accepts in_progress (own root, subtasks terminal) in addition to
  awaiting_pm_review;
- after opening the root->master PR (which sets pr_created), walks the
  root in_progress->awaiting_pm_review via the TaskService transition
  (role-validated, no gateway team-match) so escalate_to_ceo's source
  gate passes;
- then escalates -> awaiting_ceo_approval.

The root->master PR is non-empty because the cell->root PR was already
merged into the root branch (the prior cell-completion fix). Updated
test_main_pm_complete_wrong_status (in_progress is now valid; uses paused)
and added a regression test for the in_progress->CEO path.
2026-05-23 06:15:48 +02:00
Renn F e3def6b3a2 fix(gateway): cell PM completes its own cell task; drop main-PM handoff
submit_up bubbled the cell task to Main PM (_handoff_to_main_pm), but
main_pm_complete rejects any task with a parent_task_id ("only operates
on root tasks"), so the cell->root PR had no one to merge it and the
cell task wedged at awaiting_pm_review. _maybe_advance_parent_to_pm_review
already intends the CELL PM to complete it.

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

Main PM still only completes the ROOT (root->master + escalate-to-CEO).
First run to reach cell-PM bubble-up exposed this.
2026-05-23 05:16:56 +02:00
Renn F 32b6b31dd6 fix(gateway): PR base/target is the parent task's branch, not derived
submit_up opened the cell->root PR with a base computed by
merge_chain.parent_branch_for, which drops the last --segment but
REUSES the child's team prefix. Across a team boundary (cell
feature/backend/ROOT--CELL -> root feature/main_pm/ROOT) that yields a
ref that does not exist on the remote, so GitHub rejects the PR with
422 base: invalid and the cell parent wedges.

Resolve the base/target from the parent task's authoritative
branch_name (what branch creation already cuts each child from), via a
shared VerbRunner._parent_branch_for helper used by _do_create_pr and
_do_pr_merge. Falls back to parent_branch_for only when there is no
parent (root->master -> master) or it has no branch yet. The leaf->cell
path is unchanged (same team). Latent since the merge chain landed;
first run to reach cell-PM bubble-up exposed it after #180.
2026-05-23 04:14:12 +02:00
Renn F c78395f9fc fix(gateway): submit_up opens cell PR before the pm-review transition
submit_up composed submit_pm_review (atomic) then create_pr (side
effect), but VerbRunner.run_intent runs all composes before any side
effect. submit_pm_review rejects without a PR (returns None), then
create_pr deref'd the None task -> 'NoneType has no attribute
branch_name', wedging cell-PM bubble-up in a respawn loop.

Add IntentSpec.pre_side_effects, run before composes. submit_up now
opens the cell->root PR first (persisting pr_number), so submit_pm_review
re-fetches and passes its pr_created gate. Mirrors the dev open_pr ->
i_am_done split. Latent since the lifecycle spec (207aaec); first run to
reach cell-PM bubble-up exposed it.
2026-05-23 02:32:41 +02:00
Renn F 3742483e1c fix(agent): pin uv to baked /app/.venv so MCP/SDK servers start instantly (#179)
Every agent MCP server is launched as `uv run python -m roboco.mcp.<server>`
(via the orchestrator-generated mcp-config.json) and the SDK server via
`uv run python -m roboco.agent_sdk.server` (sdk-startup-hook.sh) — both with
cwd = the agent's WORKSPACE, not /app. `uv run` then resolves a cwd-relative
`.venv` (≠ the image's baked /app/.venv), ignores VIRTUAL_ENV with a warning,
and RE-SYNCS the full dependency set (torch/lancedb/pyarrow/scipy, ~350MB)
into a fresh venv on every spawn.

A warm host uv wheel cache masks this (fast re-resolve from cached wheels —
earlier runs this session opened PR #26/#28/#29 fine). On a COLD cache (first
spawn after an image rebuild — exactly when deploying new fixes) the download
takes minutes, the MCP servers never register, and the agent burns its whole
budget with "No such tool available: mcp__roboco-*" before reaping. Observed
this session: be-dev-1 never claimed; /tmp/sdk-server.log showed the live
torch/lancedb download + the `VIRTUAL_ENV ... will be ignored` warning.

Fix: set UV_PROJECT_ENVIRONMENT=/app/.venv in (1) every MCP server's env in
the generated mcp-config.json (one place — shared mcp_env dict) and (2) the
SDK startup hook. uv then reuses the pre-baked image venv instantly,
regardless of cwd or cache state. Not a regression from this session's code
(none of #172b/#175/#176/#177/#178 touched the launch/venv path — verified);
a pre-existing launch-cwd fragility that rebuilding to deploy exposed.

Test: _generate_mcp_config asserts every server env pins
UV_PROJECT_ENVIRONMENT=/app/.venv. make quality green.
2026-05-23 01:23:18 +02:00
Renn F 879b8cb991 fix(deps): bump starlette 1.0.0 → 1.0.1 (PYSEC-2026-161)
pip-audit (make quality) flagged PYSEC-2026-161 in starlette 1.0.0,
fixed in 1.0.1. Transitive via fastapi; lock-only bump
(`uv lock --upgrade-package starlette`) — sole version change in the
lockfile, no other packages touched.
2026-05-23 01:23:17 +02:00
Renn F 9e9dd55b5d fix(task): drop cell_pm→main_pm auto-escalation in complete (#178)
`service.complete()` ran a two-tier approval chain
(`_apply_complete_approval_chain`): when a Cell PM completed an
`awaiting_pm_review` task, `_handle_cell_pm_escalation` silently
reassigned `task.assigned_to = main_pm.id` (+ `claimed_by`) and kept
the task in `awaiting_pm_review` for a second-tier review by Main PM.

That model is incompatible with the gateway's `main_pm_complete`,
which explicitly rejects any non-root task
(`if t.parent_task_id is not None: return invalid_state("main_pm
complete only operates on root tasks")` —
choreographer/_impl.py:3860). Result: the leaf got handed to main-pm
with no verb that could advance it → permanent wedge.

Observed end-to-end this session (smoke run 02:25–02:35):
- 02:25:56 leaf → awaiting_pm_review (correctly assigned to be-pm via
  notify_pm_of_docs_complete).
- 02:26:42 be-pm cell_pm_complete REJECTED tracing_gap journal:reflect
  (proves leaf IS assigned to be-pm).
- ~02:27 (silent — success-path is INFO, filtered): be-pm wrote the
  reflect note + retried → choreographer cell_pm_complete → git.pr_merge
  → service.cell_pm_complete → service.complete(agent=be-pm) →
  _apply_complete_approval_chain → _handle_cell_pm_escalation →
  task.assigned_to = main_pm.id (no `task.reassigned` audit because the
  event goes to `_emit_task_event(EventType.TASK_ESCALATED_TO_MAIN_PM)`,
  not the gateway audit log).
- 02:27:30 _dispatch_pm_review_work (orchestrator) saw leaf with
  assigned_to=main-pm → spawned main-pm against the leaf (target_id
  in audit_log confirms it: `target_id=f3bdd585 agent_slug=main-pm`).
- 02:28:21+ be-pm cell_pm_complete → not_authorized "not assigned to
  you". main-pm main_pm_complete → invalid_state "only operates on
  root tasks". Closure dispatcher cycled both PMs to budget-reap.

Fix: remove the cell_pm branch from `_apply_complete_approval_chain`.
Cell PM completing a non-root awaiting_pm_review task now transitions
it to COMPLETED (the gateway model). Cell→main escalation, when
intended, uses the dedicated `submit_up` verb on the cell-level parent,
not `complete`. The main_pm → CEO branch for root parents stays.
Removed the now-dead `_handle_cell_pm_escalation` helper and the now-
unused `agent_id` parameter on `_apply_complete_approval_chain`.

Tests inverted: `test_complete_cell_pm_escalates_to_main_pm` →
`test_complete_cell_pm_does_not_escalate_to_main_pm` (asserts
status=COMPLETED, assigned_to != main_pm.id). The no-Main-PM-fallback
test trivially still passes (the path is now the only path).
Lifecycle test comment updated. make quality green.
2026-05-20 05:17:54 +02:00
Renn F 0bafbedb30 fix(orchestrator): auto-recover blocked parent at PM closure respawn (#177)
#170 made the closure dispatcher auto-resume a `paused` parent before
respawning its PM, but only `paused`. A parent that is `blocked` at
closure (every descendant already terminal) is an errant/stale block —
a child's i_am_blocked propagated, or a PM blocked it and never
unblocked — the real dependency is already done. #170 left it as-is, so
the respawned PM landed on a blocked parent it cannot submit_up /
complete and had to manually `unblock` it first (needs journal:decision)
— which models do not reliably do, wedging the whole closure chain
forever (observed end-to-end this run: leaf stuck awaiting_pm_review,
cell parent blocked, root paused, PMs cycling indefinitely).

Add `_auto_recover_blocked_parent` (mirrors `_auto_resume_paused_parent`)
and recover `blocked` symmetrically to `paused` in
`_maybe_spawn_pm_closure`. `blocked -> in_progress` is lifecycle-valid —
it is exactly what `unblock(restore=True)` performs. Scoped to the
closure-spawn point (descendants terminal) so a live dependency block is
never auto-cleared. Best-effort, like the paused path. 4 new tests
mirror the #170 suite (recovered-before-spawn, mutual exclusivity with
paused, patch shape, error-swallowing). make quality green.
2026-05-18 05:11:16 +02:00
Renn F caa4fc1969 fix(gateway): unclaim releases a pending-assigned task — escape trap (#176)
An agent assigned a `pending` task it never claimed was structurally
trapped: from pending-assigned, unclaim returned None ("cannot unclaim
from status pending"), i_am_idle rejected ("assigned but never claimed"),
i_am_blocked rejected ("block requires in_progress"). Any persistent
claim-time rejection (a gate the agent cannot satisfy, a transient
validation error) therefore looped the agent until budget-reap AND left
the task orphaned (pending, assigned, no progress). Observed in smoke-16
and smoke-17.

unclaim_for_agent now releases a pending task assigned to the caller:
no status change (already pending → no lifecycle transition, no
WorkSession to abandon since it was never claimed), just clear
assigned_to/active_claimant_id so the dispatcher can reassign. The
choreographer spec gate already permits unclaim from pending (composes=()
— role-only), so the service branch is the whole fix. Updated the now-
stale unclaim remediate string; rewrote the test that encoded the buggy
trap and added a paused-status negative case.
2026-05-18 01:30:06 +02:00
Renn F 1d02b09fe0 fix(bash-guard): deny interpreter/library HTTP to internal hosts (#175)
The internal-API rule only fired when the FIRST shell token was an HTTP
CLI (curl/wget/http/https/httpie). smoke-17 showed an agent reach the
orchestrator with hand-forged X-Agent-ID/X-Agent-Role headers via:

  python3 << 'EOF'
  import httpx
  httpx.post("http://roboco-orchestrator:8000/api/v2/flow/developer/i_will_work_on",
             headers={"X-Agent-ID": "<self>", "X-Agent-Role": "developer"})
  EOF

The binary is python3 (slips the CLI check) and it imports httpx, not
roboco.* (slips the #164 import check). Only minimax's wrong endpoint
path prevented a real gateway bypass under a forged identity.

Add a language-agnostic rule: deny when the command pairs an HTTP-client
token (httpx/requests/urllib/aiohttp/http.client/net::http/fetch(/
node-fetch/axios/...) with a forbidden internal host, consistent with
the curl/wget sibling (inspects full $low incl. heredoc body). External
HTTP (pypi/docs/github) has no internal host so it still passes. The
stale "interpreter one-liners — out of scope" KNOWN GAP comment is
corrected; the variable-expansion gap remains documented.

11 new tests incl. the exact smoke-17 heredoc, requests/urllib/aiohttp/
node-fetch/Net::HTTP variants, and allow-cases (external host, client
import w/o host, pytest runner). make quality green.
2026-05-18 00:08:04 +02:00
Renn F 251d1c36a2 fix(mcp): flow_server i_will_work_on forwards steps — completes #172
#172 (4c397e1) added IWillWorkOnRequest.steps, the flow_dev route
threading, the _dev_steps_gate, and the developer prompt — but never
updated the roboco-flow MCP tool. flow_server.i_will_work_on exposed
only (task_id, plan) and posted only those, so the agent's tool could
not transmit steps. Every fresh dev claim hit the gate's
incomplete_input missing=['steps'] with no way to satisfy it →
permanent wedge for every code task (observed in smoke-16: be-dev-1
looped ~20 times, then deadlocked — could not claim, block, or idle).

Add the steps parameter and forward it, mirroring the existing
i_will_plan/sub_tasks pattern (which is why PM i_will_plan was never
affected). Update the two body-shape tests and add a steps-passthrough
regression test.
2026-05-17 22:42:53 +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
Renn F ed828a719b feat(gateway): substantive-plan gate — approach >=150 + real sub_task descriptions (#171)
Plans were vague because the gate accepted the bare minimum: PM
approach >=20 chars and title-only sub_tasks. minimax wrote exactly
the minimum.

- IWillPlanRequest.approach min_length 20 -> 150 (kept in sync with
  _PM_APPROACH_MIN_LEN; the gate enforces it at the choreographer
  layer too so direct/MCP callers can't bypass the HTTP boundary).
- New _thin_subtask_hint: every PM sub_task must have a title and a
  description >= _PM_SUBTASK_DESC_MIN_LEN (60) saying what the step
  does — each sub_task is both a delegate target AND a
  progress-checklist item, so a title alone is not a plan.
- cell_pm/main_pm role prompts: explicit "the gate REJECTS thin plans"
  framing + concrete sub_task example + the new minimums.
- Updated all affected test fixtures across the suite to use
  substantive approaches/descriptions; added thin-sub_task rejection
  coverage.

Commit 1 of 3 for the plan/progress quality work (#171/#172/#173).
2026-05-16 10:14:45 +02:00
Renn F e94159dce8 fix(orchestrator): auto-resume paused parent before PM closure respawn (#170)
A PM auto-pauses its owned parent on i_am_idle (by design — so the
closure dispatcher knows to respawn it when subtasks finish).
Pre-gateway the parent was resumed at respawn so the PM landed
actionable; the gateway refactor dropped that, so the respawned PM had
to issue resume() itself. minimax reliably failed to (called resume on
the leaf / unblock on the paused root), wedging smoke-15 — the leaf
stayed awaiting_pm_review and the chain never completed.

Restore the pre-gateway behaviour: _maybe_spawn_pm_closure now calls
new _auto_resume_paused_parent (paused -> in_progress via the same
PATCH path _auto_block_task uses) immediately before spawning the PM,
but only when the parent is actually `paused` (awaiting_pm_review /
in_progress parents untouched). Best-effort: a resume failure is
logged and swallowed so it never blocks the spawn (the PM can still
resume manually). The parent stays assigned to the PM, so it lands on
its own in_progress task able to submit_up / complete / escalate
directly — no reliance on the weak model issuing resume().

Combined with 4090397 (exact-complete remediate), this closes the
smoke-15 PM-completion wedge end to end.
2026-05-16 07:06:17 +02:00
Renn F 4090397cea fix(gateway): rejected PM is told the exact complete() call (#170, partial)
Smoke-15 wedge: leaf 1533ce56 sat at awaiting_pm_review owned by
be-pm, but the PMs looped firing complete/unblock at the wrong
(parent) task_ids — every rejection was generic ("not assigned to
you" / "not ready for completion"), so minimax never discovered it
just needed `complete(1533ce56)`.

New _own_review_hint: on a cell_pm/main_pm complete-guard rejection
(not-owner, wrong-state, or main-pm-on-non-root), if the PM owns a
DIFFERENT task that is awaiting_pm_review, append a remediate suffix
naming it and the exact `complete(task_id='<id>', notes='...')` call.
Best-effort (never raises into the rejection path), pure guidance —
no control-flow or state-machine change.

Scope: this is the bounded, low-risk slice of #170 (fix b). The
parent-state corruption + missing recovery transition (root->paused /
cell->blocked from earlier mis-targeted verbs, fix a/c) is a lifecycle
state-machine change deferred for explicit design alignment — tracked
in #170.
2026-05-16 06:37:49 +02:00
Renn F 0737cc0143 fix(git): authenticate diff-path fetches so QA's diff base is current (#168)
Smoke-15: QA's claim_review diff was `origin/master...origin/<branch>`
but origin/master in QA's clone was the STALE clone-time tip
(47c674d) — the three-dot diff spanned the whole session delta
(41 files / +2740) instead of the 1-line README change.

Two compounding causes:
- _default_branch_ref early-returns the ref NAME when origin/HEAD is
  set (it was) WITHOUT fetching it, so the base stayed stale.
- Every fetch in the diff path ran unauthenticated; the repo is
  private, so `git fetch` failed ("could not read Username for
  github.com") and could never refresh the ref. (The documenter path
  was correct only because #162 uses the PAT-injected
  workspace.fetch_branch_for_inspection.)

Fix: new best-effort _token_for_branch resolves the project PAT
(None on any failure → degrades to unauth, never raises in the
evidence path). diff()/list_changed_files() thread it into
_resolve_head_ref + _resolve_diff_base, whose fetches now pass
token= (uses the existing _run_git http.extraheader Basic-auth
injection). _resolve_diff_base additionally re-fetches the resolved
default branch so the base is current even when origin/HEAD shortcut
skipped the fetch.
2026-05-16 06:29:18 +02:00
Renn F 12672b94ed fix(docs): i_documented persists DocRef dicts, not bare strings (#169)
Smoke-15: be-doc i_documented(files=["README.md"]) → choreographer
doc.py stamped `existing.documents = files` (a list[str]) onto
Task.documents. Task.documents is list[DocRef] persisted as dicts —
list_docs does DocRef(**d), _get_existing_doc_ref does d.get("path"),
the RAG indexer does d.get("path"). A bare string 500'd GET /docs
("TypeError: DocRef() argument after ** must be a mapping, not str")
during be-pm's PR review and would AttributeError the indexer.

Fix at source: new _doc_refs_for builds proper DocRef dicts
(path, title=filename, doc_type, created_by/at, updated_by/at) at the
i_documented stamp. Defensive read: new _coerce_doc_ref tolerates
dict / DocRef / bare-string / rejects unknown — applied at
list_docs and _get_existing_doc_ref so legacy/corrupted rows can't
500. _add_doc_to_task (docs-route write path, not the gateway flow
that broke) left as-is per scope.
2026-05-16 06:20:35 +02:00
Renn F 8158eb37ef unpinning claude clode version 2026-05-16 05:28:20 +02:00
Renn F 38dba74837 fix(agents): stop instructing agents to ToolSearch built-in tools (#167)
The system-prompt directive layer and the briefing block both opened
with "FIRST ACTION REQUIRED: run ToolSearch to activate deferred
Edit/Write". That premise is false: per Claude Code 2.1.114, ToolSearch
gates only deferred MCP tools, never built-ins — and it is not even a
callable tool in the agent runtime. Built-ins are loaded at spawn via
the `--tools` flag and gated solely by the per-role permission rules
(the actual Edit/Write breakage was the global Write(*)/Edit(*) deny +
single-slash path, fixed in c0ba335). So weak models dutifully chased a
nonexistent ToolSearch, concluded Edit/Write were unavailable, and
rewrote whole files via destructive shell redirection.

Both touch points now affirm the role's built-in tools are loaded and
ready, tell the agent NOT to call ToolSearch, and (for authoring roles)
explicitly steer away from whole-file shell redirection — directly
countering the clobber behaviour. Role prompt files (developer,
cell_pm, main_pm, board) updated to match. Dead
_read_tool_load_from_role_prompt (no callers) removed. Directive tests
rewritten to lock the corrected behaviour.
2026-05-16 03:53:52 +02:00
Renn F c0ba335470 fix(runtime): agents can finally Edit/Write — drop global deny + fix abs path syntax (#167)
Smoke-10..14: every agent (developers included) got "Edit exists but is
not enabled in this context" and fell back to destructive bash
redirection (a 207-line README rewritten to a 3-line stub, which QA
correctly failed). Two coordinated defects in _generate_agent_settings /
_get_role_permissions:

1. base_deny carried a GLOBAL Write(*)/Edit(*). Claude Code evaluates
   permission rules deny -> ask -> allow, first match wins — a deny
   ALWAYS beats a more-specific allow and the glob syntax has no
   negation. So the global deny unconditionally shadowed every per-role
   workspace-scoped Write/Edit allow. Removed it; the security denies
   that legitimately rely on deny-always-wins (Bash(git:*), credential
   Read denies, curl github, env) stay. Roles that must not author
   (qa, cell_pm, main_pm, auditor) keep their OWN Write(*)/Edit(*) deny.

2. The workspace allow used a single leading slash (Write(/data/...)).
   Claude Code resolves a single / against the settings.json project
   root, not the container filesystem root, so the allow silently never
   matched even without defect #1. Emit the // absolute-filesystem form.

defaultMode stays bypassPermissions (switching to dontAsk would require
re-deriving the full allow-list and risks wedging agents elsewhere —
out of scope). Verified against Claude Code 2.1.114 permission docs.
2026-05-16 03:45:02 +02:00
Renn F 954acff911 fix(git): resolve diff HEAD ref per-workspace so QA/doc/PM see real diffs (#161 facet)
Smoke-14: QA's claim_review evidence had pr_diff_summary="" and
files_changed=[] on a PR with a real README change. Root cause: diff()
and list_changed_files() diffed against the bare local <branch_name>.
That ref only exists in the clone where the dev ran `git checkout -b`
at claim. QA / documenter / PM inspect from their OWN clones, where a
bare <branch_name> resolves refs/heads then refs/remotes/<name> but
NEVER refs/remotes/origin/<name> — so `git diff base...<branch>` had an
unresolvable HEAD and silently returned an empty diff (run with
check=False).

#161 previously fixed the BASE side (cell-PM parent never pushed → fall
back to default branch). This is the symmetric HEAD-side facet.
open_pr pushes the leaf branch, so origin/<branch> is the
workspace-independent source of truth. New _resolve_head_ref fetches the
branch and prefers the local branch (dev's own clone, unchanged
behaviour), falling back to origin/<branch> (QA/doc/PM clones), then the
bare name so the command stays well-formed. diff() and
list_changed_files() route through it; explicit base (incremental dev
path, base=HEAD~1) is preserved.
2026-05-16 03:27:23 +02:00
Renn F 1605d187f1 fix(security): bash-guard git-ops check inspects commands, not file content (#165)
The git network/auth deny rule matched its regex against the whole
command string, so heredoc bodies and echo/printf arguments that merely
documented git verbs (a README, a notes file) were treated as git
invocations and denied. This wedged smoke-13's dev: after wiping the
README via an Edit/Write fallback it could not restore it because every
`cat > README.md << EOF ... git commit ... EOF` was blocked.

The git-ops check now runs against a skeleton of the command with
heredoc bodies and echo/printf literal args stripped (both are data the
shell writes, never executed). Quoted args to a shell interpreter
(`bash -c "... && git fetch"`) ARE executed, are not echo/printf/heredoc
bodies, and so survive untouched — the hook's core purpose is preserved.
A sentinel prefix distinguishes a legitimately-empty skeleton from a
python failure (fail closed on failure). All other rules, including the
#164 import-bypass rule, still inspect the full command.
2026-05-16 02:20:08 +02:00
Renn F 81f5655d48 fix(security): block gateway-internals import + agent-id forgery (#164)
Smoke-12: be-dev-1 (minimax-m2.7) bypassed the entire MCP boundary by
running `uv run python3 -c "import os;
os.environ['ROBOCO_AGENT_ID']='...'; from roboco.mcp.flow_server
import open_pr; open_pr(...)"` from the Bash tool. This voided the
per-role tool manifest (role-scoping is meaningless if the agent can
import any server module in-process), forged agent identity via an
env-var rewrite, and ran choreographer code outside the gateway's
tracing + auth.

bash-guard-hook.sh now adds two deny rules:
  1. Any python/uv/poetry/pipenv/pdm/hatch invocation that imports or
     `-m`-runs roboco.* internals (mcp/services/runtime/foundation/
     api/enforcement). The whole command string — heredoc body
     included — is matched, so quoting/heredoc forms are covered.
  2. Any assignment or export of ROBOCO_AGENT_ID (identity forgery).

Reading roboco source for context (cat/grep) is still allowed — the
block is on *executing* internals, not viewing them. Normal python
one-liners without roboco imports still pass.

19 bash-guard tests pass (10 prior + 9 new). Note: takes effect on
agent-image rebuild (hook ships in the agent container).
2026-05-16 00:59:26 +02:00
Renn F c18ad34530 fix(gateway): reject PM-created documentation subtasks (#163)
Smoke-12: be-pm delegated TWO subtasks under one cell parent — a
code subtask (be-dev-1, spawned) and a documentation subtask
(be-dev-2). The orchestrator dev-dispatch refuses to spawn a developer
for task_type=documentation, so the doc subtask became a permanent
orphan that loops dev-dispatch forever and would deadlock submit_up
(all subtasks must be terminal). The spine-cap is per-type so
code + documentation both passed sibling-dedup — the PM never saw the
anti-pattern warning.

_delegate_static_guards now rejects task_type='documentation' with a
remediate explaining the lifecycle auto-creates the documentation
phase (awaiting_documentation → documenter spawned) after the code
subtask passes QA, and that the PM should delegate ONLY the code
subtask.
2026-05-16 00:47:59 +02:00
Renn F aa2e6bc5ed fix: panel logo (#160), diff base fallback (#161), doc branch checkout (#162)
#160 — panel /roboco-logo.png "received null":
    next/image optimizer fails for static public assets in Next.js
    standalone mode. Added `unoptimized` to the sidebar logo Image so
    it serves the static file directly (validated on panel rebuild).

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

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

Smoke-11 reached dev→QA→doc (deepest ever) and validated the prior
6 fixes (panel flood gone, #158/#159/#157 confirmed). These three
clear the doc-phase blockers found in that run.
2026-05-16 00:13:14 +02:00
Renn F 5da909d9d7 fix(gateway): cross-team planning fanout + complete tracing-gap hints
Task #157 — spine-cap allows planning fanout across cells:
    main-pm's pattern is to delegate planning to be-pm / fe-pm / ux-pm
    in parallel — each on a different team. The previous spine-cap
    rejected all planning siblings under one parent as
    over-decomposition. New helper _is_cross_team_planning skips the
    cap for planning when both teams are non-empty and distinct. Code
    / documentation stay capped regardless (single repo on one branch
    shouldn't have two simultaneous code subtasks).

Task #159 — tracing-gap remediate hints every requirement:
    journal:during_work>=1, journal:struggle, commits>=1, pr_open,
    and self_verified had no entries in _hint_for_missing_key, so when
    they were missing the agent saw the token in `missing[]` but the
    `remediate` text had no instruction for how to satisfy them.
    Smoke-10's be-dev-1 burned multiple turns retrying i_am_done not
    knowing scope='reflect' doesn't count toward during_work. Now
    every token has a hint, the during_work hint warns that reflect
    doesn't satisfy it, and multi-hint remediate uses a numbered list
    so the model treats each requirement as a distinct step instead
    of a semicolon-blob.

Also coerce convert_plan._coerce_risk formatting (ruff-format follow-up
to 9cd73d0).
2026-05-15 08:21:08 +02:00
Renn F 9cd73d0902 fix(panel): coerce risk.severity to default str on read + write
Bug:
    Smoke-10's main-pm submitted a rich plan with risks omitting
    severity. _normalize_risk persisted severity=None into the DB.
    Every panel poll of /tasks/{id} then 500'd because
    TaskPlanResponse.risks declares list[dict[str, str]] and Pydantic
    rejects None for a str field. Result: panel single-task page broken
    end-to-end every ~1-3s as panel reloads.

Fix:
    Write side (_normalize_risk): default missing/None severity to
    "medium" so new writes never persist None.
    Read side (convert_plan._coerce_risk): defensively coerce any
    existing DB row with severity=None to "medium" so old bad data
    doesn't continue bricking the read path.
2026-05-15 07:25:52 +02:00
Renn F 2c838c2a9e feat(gateway): propagate sessions to subtasks + auto-emit milestone progress
Task #156 (sessions): pre-gateway flow created a session for the whole
task tree at once, so subtasks were visible in the PM's group chat the
moment they existed. The gateway creates subtasks one-by-one via
delegate(), losing that wiring. Added
MessagingService.propagate_sessions_to_subtask and threaded it through
the choreographer's _create_subtask_from_inputs. ChoreographerDeps grew
an optional `messaging` field so existing test wirings keep working.

Task #155 (progress): smoke-9 had zero progress entries because the dev
never called progress() explicitly. Added _record_milestone_progress and
fire it server-side from two natural milestones — open_pr ("opened PR
#N", 70%) and i_am_done ("submitted for QA review", 90%). Best-effort
write (contextlib.suppress) so a progress failure cannot break the verb
path. Extracted _open_pr_success_envelope to keep cyclomatic rank ≤ B.
2026-05-15 06:54:35 +02:00
Renn F 4fdde2b082 fix(gateway): evidence/QA/doc paths populate files_changed from git
Bug:
    ContentActions.evidence() hard-coded files_changed=[] and diffed
    against HEAD~1 instead of the branch's parent. QA's _build_qa_claim_
    evidence (and doc/_impl mirrors) sourced files_changed from
    work_session.files_modified, which the gateway commit() never
    populates (no add_files_modified plumbing). Result: QA / docs / PM
    reviewers saw an empty change list on real PRs and only the latest
    commit's delta — flagged in smoke-9 when PR #20 showed the README
    change on GitHub but evidence() reported empty.

Fix:
    Added GitService.list_changed_files (git diff --name-only against
    parent branch). evidence(), _build_qa_claim_evidence,
    _claim_doc_evidence, and _build_i_am_done_ok all source files_changed
    from this — git is the authoritative source. evidence() also drops
    the HEAD~1 base so the diff is the full PR.

    Wired EvidenceRepo into ContentActionsDeps so evidence() returns
    journal_highlights too, matching the QA/doc shape.
2026-05-15 06:31:38 +02:00
Renn F d5ff8c7b13 fix(gateway): i_will_plan persists rich plan dict, not raw string
Bug:
    Choreographer.i_will_plan() built spec_ctx with the raw plan string
    while ctx (_ClaimPlanStartContext) got the resolved (panel-shaped)
    dict. The verb runner uses spec_ctx — so the rich shape never reached
    TaskService.set_plan. The panel's Plan tab stayed empty even when
    PMs supplied approach / sub_tasks / risks.

Fix:
    Pass the resolved (possibly-dict) plan into spec_ctx too. Widened
    lifecycle.Context.plan to `str | dict[str, Any] | None` to match.
    Tightened _resolve_effective_plan to require a non-empty narrative
    paragraph — rich structure layers on top of prose, not in place of it.
2026-05-15 06:15:44 +02:00
Renn F e3570b444f fix(orchestrator): briefing renders ToolSearch directive + current verb names
Smoke-8 follow-up. Two issues in _write_agent_briefing:

1. _build_tool_load_block was scraping role prompts for a "## Load on
   spawn" section that doesn't exist in any role file. Returned "" for
   every role → no ToolSearch directive in the briefing. Combined with
   weak models skipping the system-prompt-layer directive (#144), the
   agent's first action was Edit → "not enabled in this context."

   Fix: per-role tool list lives in the orchestrator (mirrors
   factories._base.py). Pre-renders the directive directly. developer
   and documenter get Edit + Write; QA/PMs/board get the common
   read-only set. 7 tests pin the contract.

2. The briefing's "Terminal tools (how to exit cleanly)" section still
   listed pre-gateway verb names: roboco_agent_idle,
   roboco_task_substitute, roboco_task_escalate,
   roboco_task_submit_qa, _qa_pass/fail, _docs_complete, _complete.
   Same rename pattern as #145's _TERMINAL_TOOLS set. Updated to:
   i_am_idle, i_am_blocked, unclaim, i_am_done, pass, fail,
   i_documented, complete, submit_up, escalate_up, escalate_to_ceo.

The agent now reads the same directive in two places (system prompt +
session briefing) — the second touch point catches weak models that
skip the first.
2026-05-15 05:01:18 +02:00
Renn F 47c674d70e fix(hooks): post-tool-budget-hook records terminal tool to SDK
Smoke-8: the stop-hook still nagged after a successful i_am_idle even
after #145's _TERMINAL_TOOLS rename. Root cause was upstream — nothing
was POSTing to /terminal/tool_recorded, so the SDK's recent_tools
deque stayed empty and had_terminal_recently() always returned False.

The PostToolUse hooks already record every tool call to
/budget/tool_called for the budget/loop tracker. Added a parallel call
to /terminal/tool_recorded so the terminal-tracker sees the same
stream. Fire-and-forget; never blocks Claude.

After the SDK suffix-strip (line ~798 in agent_sdk/server.py),
mcp__roboco-flow__i_am_idle becomes i_am_idle which is in
_TERMINAL_TOOLS (per #145). Stop-hook reads /terminal/stop_attempt
and now sees had_terminal_recently=true on the first attempt → exits 0.
2026-05-15 04:54:08 +02:00
Renn F 0c60d0bf7d ++ 2026-05-15 04:49:07 +02:00
Renn F 64d89fbd93 docs(prompts): teach all roles the roboco-git-readonly verbs
Smoke-8: QA fell back to Bash for git inspection (git log, git branch,
git show) — bash-guard correctly blocked most of it. The
roboco-git-readonly MCP server WAS registered for every agent (per
orchestrator.py:1897) with roboco_git_status/log/diff/branches, but
no role prompt mentioned them, so agents never tried them.

Added the four verbs to the verb tables in: developer.md, documenter.md,
qa.md, cell_pm.md, main_pm.md, board.md. Each entry notes "use these,
NOT raw `Bash git ...`" so agents reach for the right tool first.

No code change — these MCP tools have existed all along. This is a
prompt visibility fix.
2026-05-15 04:48:59 +02:00
Renn F ef29d663fa docs(prompts): teach PMs the gateway's auto-naming conventions
Smoke-8: QA correctly failed a PR because the PM wrote acceptance
criteria the gateway can never satisfy:
- "branch named feature/backend/<full-uuid>" — gateway generates
  hierarchical 8-char IDs with `--` separator
- "commit prefix [<root-id>]" — gateway prefixes with the leaf
  (dev's) task ID, not the root

The dev did the right work (timestamp added to README, PR opened) but
the literal criteria were unreachable.

Both cell_pm.md and main_pm.md now have an "How to write
acceptance_criteria" block explaining:
- Gateway-controlled outputs: branch name, commit prefix
- Examples of bad criteria (implementation/identifier-based) and good
  criteria (outcome/file-content/PR-state)
- When you must reference a task ID, use the leaf (dev) ID — not
  the root.

Next smoke run: PMs should write outcome criteria, dev work should
clear QA on first review (assuming the work itself is correct).
2026-05-15 04:42:54 +02:00
Renn F cfefe85f87 fix(orchestrator): don't auto-restart on graceful exit; tighten role-status
Smoke-8 surfaced a tight respawn loop: QA failed a PR cleanly, container
exited 0, then _check_health bumped error_count and respawned QA with
the same task_id. But by then the task was in needs_revision (dev's
state), so QA's claim_review was rejected — and the cycle repeated on
the next health tick. Token-burning loop.

Two layers:

1. _check_health now reads docker's exit code. exit_code == 0 →
   graceful (intentional handoff via i_am_idle / clean shutdown) →
   reset error_count, do NOT auto-restart. Non-zero → keep the
   existing crash-retry behavior. Refactored into
   _inspect_container_state + _handle_stopped_container to keep
   xenon's complexity check happy.

2. _readiness_check_role_for_status now includes the dev-owned
   states (needs_revision, verifying) so a misrouted spawn for QA /
   PM / board on these statuses fails the readiness gate before the
   gateway has to reject it. Defense in depth — the right path is
   #1 (don't respawn on clean exit at all), but if some other code
   path tries to spawn QA on needs_revision the gate now catches it.

Tests: 12 new (5 for _check_health graceful/crash matrix + 7 for the
expanded role-status table). Pre-gateway names (none of which were
needed here) untouched.
2026-05-15 04:36:08 +02:00
Renn F 87b18bc64f fix(gateway): spine-cap remediate forbids task_type workaround
Smoke-7: be-pm got the expected spine-cap rejection on a second
delegate. The remediate said "drive the existing sibling to
completion / cancel it, OR split this parent into two sibling
parents". The model read that, decided neither applied, and
"adapted" by re-delegating with task_type='documentation' as a
"verification subtask". The gateway accepted it (different type =
no cap collision) but the orphan subtask had no claimant — it
blocked submit_up forever with "subtasks not all terminal".

Two-layer fix:

1. _spine_type_dup_envelope remediate now explicitly forbids the
   workaround: "DO NOT work around this by delegating again with a
   different task_type (e.g. 'documentation' or 'research' as a
   'verification' subtask). The lifecycle handles QA, documentation,
   and PM-review automatically after the code subtask finishes —
   you do not create auxiliary subtasks for those roles. Call
   i_am_idle() now and wait for the existing child to come back."

2. cell_pm.md workflow step 6 strengthened to name the anti-pattern
   explicitly: no verification subtask (QA is the verification step);
   never re-delegate with a different task_type as a workaround.

3 new tests pin the remediate text: forbids workaround, names the
verification anti-pattern, retains invalid_state error kind.
2026-05-15 03:26:04 +02:00
Renn F d4126ffb7f fix(sdk): _TERMINAL_TOOLS uses current gateway verb names
Smoke-7 evidence: every agent's first successful i_am_idle was followed
by a stop-hook error "you stopped without calling a terminal tool"
even though i_am_idle had just succeeded.

Root cause: agent_sdk._TERMINAL_TOOLS still held pre-gateway names
(roboco_agent_idle, roboco_task_submit_qa, ...). /terminal/tool_recorded
strips the `mcp__roboco-flow__` prefix and stores 'i_am_idle' — the
membership check against {'roboco_agent_idle', ...} never matched, so
had_terminal_recently() always returned False, and the stop hook nagged
every clean exit. Wasted ~2-3 turns per agent + burned stop_allowance.

Fix: rebuilt _TERMINAL_TOOLS with the current gateway verb names:
i_am_idle, i_am_done, i_am_blocked, i_documented, unclaim, pass, fail,
complete, submit_up, escalate_up, escalate_to_ceo.

7 new tests pin:
- every role's terminal verbs are recognized
- pre-gateway names are NOT in the set
- _SessionState.had_terminal_recently() returns True after i_am_idle
2026-05-15 03:20:43 +02:00
Renn F 197b1576c3 fix(prompts): hoist ToolSearch activation to top of system prompt
Smoke-7: be-dev-1 hit "Edit exists but is not enabled in this context."
Claude Code v2.1.69+ defers built-in tools (Edit, Write, Read, etc.)
behind a ToolSearch call. Weak models (minimax-m2.7) skip soft
directives buried in the briefing.

Also: 4 role prompts (developer, cell_pm, main_pm, board) claimed
"no ToolSearch needed" — a lie that compounds the problem. The
manifest registers MCP tools; built-in tools are still deferred.

Fix: compose_prompt now prepends a tool-load directive layer as the
FIRST block in the system prompt. It names the exact ToolSearch call
the role needs:
- developer/documenter: Read, Bash, Grep, Glob, Task, TodoWrite, Edit, Write
- qa/pm/board: Read, Bash, Grep, Glob, Task, TodoWrite (no Edit/Write)

The directive includes the failure mode it prevents so the model
understands what skipping the call causes.

Updated role-prompt lines that lied about ToolSearch.

7 new tests pin: directive is the first block; developer/documenter
get Edit/Write; qa/pm don't; failure-mode message is present.
2026-05-15 03:14:43 +02:00
Renn F 417b8c5f29 fix(gateway): dm catches A2AAccessDeniedError; circuit breakers handle dict errors
Smoke-7 surfaced: be-qa called dm(recipient='qa-all', ...) — 'qa-all'
is a channel slug, not an agent. A2A enforcement raised
A2AAccessDeniedError. It propagated past dm(), past content_actions,
got caught by FastAPI middleware which renders RobocoError.to_dict()
as {'error': {'code': ..., 'message': ..., 'details': ...}} — a
DICT-shaped 'error' field.

do_server's circuit-breaker check (and flow_server's mirror) did
`payload.get('error') in _CIRCUIT_REJECTION_KINDS` — trying to hash
a dict against a frozenset → `TypeError: unhashable type: 'dict'`.
The agent saw "Error executing tool dm: unhashable type: 'dict'"
and got stuck calling dm in a loop.

Two-layer fix:

1. content_actions.dm now catches A2AAccessDeniedError and returns
   Envelope.not_authorized with the original reason + route_hint as
   remediate. This is the right shape — content tools always emit
   Envelopes; RobocoErrors escaping to the middleware is a bug.

2. Defense-in-depth: do_server._record_and_check_circuit and
   flow_server._record_and_check_circuit now guard against non-string
   error fields. Any future RobocoError-leak that bypasses (1) will
   pass through untouched instead of crashing the tool call.

3 new tests pin the contracts:
- dm A2A denial returns Envelope.not_authorized (not propagated)
- do_server circuit-breaker doesn't crash on dict-shaped errors
2026-05-15 03:03:53 +02:00
Renn F b90ce83946 fix(mcp): expose pass/fail to QA via IntentSpec→public name mapping
Smoke-7 surfaced this: QA spawned, claim_review succeeded, but every
attempt to call `pass()` fell through to dm/say workarounds. The MCP
tool 'pass' never existed.

Root cause: foundation.policy.lifecycle declares the intent verbs as
`pass_review`/`fail_review` (Python-friendly names — `pass`/`fail` are
keywords). intents_for_role(Role.QA) returns those names, the spawn
manifest carries them, and flow_server reads them. But flow_server's
_TOOLS dict has keys 'pass'/'fail' — the manifest's pass_review keys
didn't match and got silently dropped from the registration.

Fix: add _INTENT_TO_PUBLIC = {'pass_review': 'pass', 'fail_review':
'fail'} in flow_server. _register_tools transforms manifest names
through it before _TOOLS lookup. Manifest entries map to the public
MCP tool names the prompts advertise.

Also fixed _VERB_RETRY_LIMITS keys in foundation.agent_loop — they
used the IntentSpec names too, but the SDK receives the public name
from /verb/attempted (derived from the flow URL path), so the limit
entries never matched real rejections. Renamed to 'pass'/'fail'.

3 regression tests pin: pass/fail register under public names;
IntentSpec names don't leak through; the registered tool POSTs to
the correct orchestrator path.
2026-05-15 02:54:39 +02:00