Commit Graph
230 Commits
Author SHA1 Message Date
Renn F dc11ca23f8 docs(prompts): D4 compel triage() first on respawn in cell_pm.md
Cell PM prompt now mandates triage() as the first call on every
respawn, before re-decomposing. Smoke run 3 showed PMs re-decomposing
blindly and hitting spine-cap; triage shows them existing children
and prevents the over-decomposition pattern.
2026-05-12 06:31:56 +02:00
Renn F 517aa4b16b docs(prompts): D4 compel progress() after each commit in developer.md
Developer prompt now mandates progress(task_id, message, percentage)
after each commit. Wave 1's progress verb was added but no agent
called it. The Progress tab stays empty without it.
2026-05-12 06:31:43 +02:00
Renn F 02c241e3a5 docs(prompts): D4 compel open_session in PM prompts
PM prompts now include open_session(task_id, channel, topic) in the
State→Verb table for the "just claimed" state. Without this, the
Sessions tab stays empty — Wave 1's session verb was added but agents
never called it because the prompt didn't directive it.
2026-05-12 06:31:30 +02:00
Renn F 129504a51d docs(prompts): D3 document journal:during_work>=1 in developer.md
Smoke run 3 showed be-dev-1 writing reflect but no mid-work entry,
hitting tracing_gap on i_am_done with missing: ['journal:during_work>=1'].
The reflect note does not satisfy this gate — it's an end-of-work
artifact. The prompt now shows the 5-step cadence explicitly:
i_will_work_on → decision → work → reflect → i_am_done.
2026-05-12 06:30:23 +02:00
Renn F 68d52be4a7 docs(prompts): D2 post-first-delegate reasoning for Main PM + Cell PM
Smoke run 3 showed Main PM seeing the spine-cap reject on its 2nd
delegate attempt (its 1st succeeded) and concluding 'I cannot delegate'
→ escalated to product-owner. The new anti-pattern tells PMs that
spine-cap or role-guard rejections AFTER a successful delegate mean
over-decomposition, not delegation impossibility — verify with triage()
and idle instead.
2026-05-12 06:30:05 +02:00
Renn F 314d829172 docs(prompts): D1 circuit-recovery instruction in all 6 role prompts
Smoke run 3 showed be-dev-1 hitting circuit_open on i_am_done and
escalating via i_am_blocked instead of writing the missing journal
entry and retrying. The prompts now name circuit_open explicitly,
tell agents to read the remediate, fix the one piece, retry once,
and only escalate if the breaker fires again.
2026-05-12 06:29:45 +02:00
Renn F 41ef7f6b4e feat(gateway): C8 PM-decision gate windowed satisfaction
_check_pm_decision_required now requires the latest journal:decision
within pm_decision_window_seconds (default 300). Older decisions no
longer satisfy the gate. Adds JournalService.latest_decision_at.

Future-tighten (out of scope): per-verb-group consumption tracking
would need persistent state — Choreographer is per-request today.
2026-05-12 06:27:33 +02:00
Renn F 89eacf028e feat(gateway): C7 synthetic checkpoint on auto-pause
Smoke run 3 showed agents auto-pausing on i_am_idle (correct behavior
for non-terminal tasks) but capturing no checkpoint — panel's
Checkpoints column stayed empty. Pre-gateway parity: the auto-pause
path now writes a synthetic checkpoint summarizing state at pause-time
so the panel reflects reality.

Manual i_will_pause (G8a, deferred) will eventually let agents pass
their own checkpoint_summary; for now this synthetic write covers the
bare i_am_idle case which is what all current agents do.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section C7.
2026-05-12 05:45:31 +02:00
Renn F 1ab9ccabd8 feat(events): C6 spawn auditor on escalation/block/cancel events
The auditor's role is 'silent observer' — read every channel and emit
a reflect note when something notable happens. Smoke run 3 never
spawned auditor because no event-subscription registered it. Added
handler handle_auditor_spawn() wired to:
  - task.blocked          (EventType.TASK_BLOCKED)
  - task.cancelled        (EventType.TASK_CANCELLED)
  - task.awaiting_ceo_approval (EventType.TASK_AWAITING_CEO_APPROVAL)

Routine events (task.claimed, task.started, task.created) deliberately
do NOT trigger auditor — those are progress, not exceptions. The
auditor's container is one-shot: i_am_idle() exits after logging its
reflect note.

Auditor spawn failures are swallowed into a WARNING log so they cannot
block the underlying event's processing chain. The auditor is a silent
observer — its absence must have no side effects on the lifecycle.
2026-05-12 05:39:41 +02:00
Renn F f38c15b966 feat(gateway): C5 write acceptance_criteria_status on i_am_done
Pre-gateway parity. evidence(task_id).acceptance_criteria_status was
always [] because the gateway's i_am_done gate validated each
criterion against the dev's journal:reflect but didn't persist the
per-criterion verdict. The panel + audit log couldn't show
per-criterion checkmarks.

Now the gate writes a list of {criterion, addressed, artifact_ref,
checked_at} entries to task.acceptance_criteria_status. The existing
matching logic surfaces which artifact (commit sha / reflect-note)
addressed each criterion; entries that aren't addressed get
addressed=False so the panel can flag them.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section C5.
2026-05-12 05:32:25 +02:00
Renn F b53d8fe194 feat(gateway): C4 auto-create WorkSession on claim
Pre-gateway parity. Smoke run 3 showed task.work_session_id null on
every task — the choreographer's claim/plan/start path didn't create
the row that downstream subsystems (panel, PR tracking, merge chain)
need to track agent-per-task git activity.

Add TaskService.ensure_work_session(task_id, agent_id) as a public
wrapper around the existing _create_work_session_if_needed logic.
Role restriction lifted to None so both developers and PMs get a
session (pre-gateway always created sessions for all claimants).
Built-in re-entry guard prevents duplicate rows on re-claim.

Wire the call into both _claim_plan_start_run and _resume_from_claimed
immediately before _touch, so every successful in_progress transition
(including the stuck-claimed recovery path) creates the row.

Spec ref: Wave C task C4 (2026-05-12).
2026-05-12 05:22:44 +02:00
Renn F a47237416e feat(runtime): C3 tunable reaper threshold + heartbeat on every verb dispatch
Smoke run 3 showed agents reaped at the 3-min stale-claim window
while they were actively retrying rejected verbs. Two causes:

1. The reaper threshold was hardcoded at 180s via claim_stale_seconds.
   LLM inference + retry loops routinely take longer than that between
   verb-successes. Added settings.stale_claim_reap_seconds (default
   600s); override via ROBOCO_STALE_CLAIM_REAP_SECONDS env var.
   claim_stale_seconds (spawn-filter cutoff) is unchanged at 180s.

2. last_heartbeat_at only refreshed on verb SUCCESS. A verb stuck
   in a rejection loop (e.g. tracing_gap missing journal:decision)
   showed no heartbeat updates even though the agent was alive.
   Added a best-effort heartbeat refresh inside _emit_rejection so
   EVERY verb dispatch — success or rejection — counts as activity.

Heartbeat approach: option (b) — touch inside _emit_rejection (single
centralized rejection path). Requires no middleware layer, no HTTP body
parsing, and no new files. The _touch guard for task_id=None means
agent-level rejections (no task context) are a safe no-op.

Net effect: agents stop being reaped mid-retry. Genuinely-stuck
containers (no verb dispatch at all) still reap normally at 600s.

Spec ref: Wave C Task C3.
2026-05-12 05:10:32 +02:00
Renn F eb9cd93e09 fix(workspace): C2 cache refresh fetch for 30s per workspace path
Smoke run 3 fired 'ensure_workspace: refresh fetch returned non-zero'
9 times per run because each evidence(task_id) call triggered
ensure_workspace -> fetch. The workspace doesn't change in subseconds.

Added a 30s TTL cache keyed by workspace path. ensure_workspace(force=True)
bypasses the cache for callers that genuinely need a fresh fetch.

Net effect: log noise drops from 9 entries to 1-2 per run; orchestrator
spends less time waiting on redundant git fetches.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section C2.
2026-05-12 05:03:12 +02:00
Renn F cdb4a6edeb fix(mcp): C1 per-verb circuit breaker trips on incomplete_input too
Smoke run 3 showed Main PM hitting 7 incomplete_input rejections on
the decision-note required-fields gate before finally succeeding.
The per-verb breaker tracks repeated rejections of the same verb in
a 60s window and returns circuit_open after the 3rd strike — but its
classification set only included tracing_gap. incomplete_input was
added in Wave 1 (pre-gateway parity for decision/reflect structured
fields) and should have been added to the breaker at the same time.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section C1.
2026-05-12 04:52:38 +02:00
Renn F d73e86044b fix(gateway): B6 give_me_work returns pre-assigned pending tasks first
Smoke run 3 showed Main PM's first give_me_work() returning
{status: idle, next: 'no Main PM work'} even though c7935d2c was
pending and assigned to Main PM. The filter only walked
list_assigned_for_agent (ordered by priority/updated_at — pending
could rank behind in_progress rows) and the PM path fell through
to idle because the pre-assigned pending case was not checked first.

Pre-pended a list_pending_for_agent check in both give_me_work and
pm_give_me_work: tasks where assigned_to=agent_id AND status=pending
take priority over all other lookups. Added TaskService.list_pending_for_agent
for the query (ordered by sequence, priority, created_at).

Updated existing tests in test_choreographer_dev, test_choreographer_pm_extras,
and test_heartbeat_wired to set list_pending_for_agent.return_value=[]
where they were not testing the pre-assigned path.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B6.
2026-05-12 04:44:03 +02:00
Renn F 85e20e6a2a fix(docker): B5 tighten bash-guard denial message to 2 lines
Smoke run 3 showed the bash-guard hook emitting 8+ lines on every
blocked shell-git op — enumerating every alternative MCP verb across
roboco-flow / roboco-do / roboco-git-readonly. That's repeated token
spend on every refused retry; the LLM doesn't need the full alt-list
inline, it has the role prompt + the MCP tool schema for that.

Trimmed to 2 lines: denial reason + a one-line pointer to the role's
State→Verb table. Test asserts <= 3 echo lines in any denial block.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B5.
2026-05-12 04:37:01 +02:00
Renn F 6550d69b75 fix(gateway): B4 decision/reflect remediate includes literal call example
Smoke run 3 showed Main PM taking 7 attempts to satisfy the decision-note
required-fields contract — the remediate listed which fields were
missing but didn't show what a fully-formed call looks like. The LLM
pattern-matches examples better than field-list prose; each retry it
dropped a different field.

Added a literal note(scope='decision', ...) / note(scope='reflect', ...)
call template to the rejection remediate so the agent sees the canonical
shape with named-keyword args and example values. The missing-fields
list stays — both pieces of information are useful, but the example is
what actually drives convergence.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B4.
2026-05-12 04:35:13 +02:00
Renn F ce92829385 fix: clear 55 pre-existing test failures uncovered after Wave A landed
Three classes of failure, all surfaced once Wave A's plan-required gate
and the migration 013 went in. Per project standing rule: pre-existing
errors are not a free pass — fix them.

1. Wave A1 ordering (32 lifecycle parity failures + 1 full-pipe test).
   _pm_sub_tasks_gate fired BEFORE _claim_plan_start_gate, so wrong-state
   PMs got `incomplete_input` (the gate's verdict) when the spec's
   lifecycle gate should have returned `invalid_state` first. Swapped:
   re-entry check → spec lifecycle gate → sub_tasks gate → claim_plan_run.
   Parity test now sees the spec's verdict as expected.

2. E2 enum naming (2 migration_013 failures + ripple).
   _str_enum in roboco/db/tables.py didn't pass name=… to SQLAlchemy
   Enum(...), so Base.metadata.create_all in test setup inferred
   `role` from the Python class `Role` while the alembic migrations
   declare `agentrole`. Tests saw two enums for the same class and
   hit `agentrole = role` operator errors. Fixed: default name to
   lower(class_name) (matches every migration), override `Role` →
   `agentrole`. One dict entry; no class-by-class registration needed.

3. _MockContentActions.note() signature drift.
   Wave 2 G4 added `structured` kwarg to ContentActions.note().
   The integration mock at tests/integration/v2/test_full_pending_to_completed.py
   didn't accept the new kwarg → 1 test failed on the very first call
   from the v2 do/note route. Added `structured: object = None` and
   left it unused (the test asserts lifecycle, not journal rendering).

Plus three ruff E501 line-length fixes in the test files I touched.

Quality: ruff + mypy clean. pytest 6690 passed / 0 failed / 274 skipped.
2026-05-12 04:22:03 +02:00
Renn F f680db34c6 fix(gateway): B3 canonical say() return status — always 'posted'
Smoke run 3 showed inconsistent return strings — main-pm got
status='sent', be-pm got status='posted' for the same verb.
Confirmed say() already returns 'posted' at its sole success exit.
Added test_say_status.py to pin the canonical past-tense pattern
(note->'noted', say->'posted', notify_ack->'acked') and prevent
regression. dm() and notify() retain 'sent' — different verbs,
different semantics.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B3.
2026-05-12 04:05:44 +02:00
Renn F eed4551497 feat(alembic): B2 drop unused pm_approvals Task column
Smoke run analysis initially flagged three Task fields as unused
(pm_approvals, quick_context, proactive_context). A follow-up audit
found quick_context (stores original_developer marker + doc notes +
PR creator + escalation notes) and proactive_context (RAG injection)
are actively used. Only pm_approvals is truly orphaned.

Migration 014 drops pm_approvals; downgrade() recreates it if ever
needed. The two false-positive fields stay untouched.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B2 (re-scoped 2026-05-12).
2026-05-12 04:03:21 +02:00
Renn F 7254ceee50 fix(docker): B1 update shell hooks to gateway verb names
Smoke run 3 showed stop-hook.sh complaining 'Denied: you stopped
without calling a terminal tool' AFTER agents successfully called
i_am_idle() — because the hook listed 9 pre-gateway verb names
(roboco_agent_idle, roboco_task_substitute, etc.) that no longer
exist. Same staleness in bash-guard-hook.sh.

Both hooks now reference current gateway verbs only. stop-hook
branches its suggestion by ROBOCO_AGENT_ROLE so devs see
i_am_done/i_am_blocked, QAs see pass/fail, PMs see complete/escalate_up.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section B1.
2026-05-12 03:48:47 +02:00
Renn F fadc05966e feat(alembic): A5 migration 013 drops stray role postgres enum
Smoke run 2 (2026-05-11) produced 'UndefinedFunctionError: operator
does not exist: agentrole = role' because postgres had two enums
(role, agentrole) for the same Python class. Information_schema check
confirms no column uses role; migration drops it. Upgrade() raises if
that ever stops being true. Downgrade() recreates the enum with the
foundation's Role values.

Investigation of WHY a second enum got created is tracked in spec E2;
this migration handles the symptom.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section A5.
2026-05-12 03:39:17 +02:00
Renn F d5a40086f4 fix(workspace): A4 downgrade expected refresh-fetch auth-fail to DEBUG
Smoke run 3 fired the same workspace.py warning ~9x per run:
  'ensure_workspace: refresh fetch returned non-zero'
  stderr: 'fatal: could not read Username for https://github.com'

This is EXPECTED behavior, not a bug. The docstring on
_fetch_origin_best_effort explains that credentials are deliberately
scrubbed from .git/config after the initial clone (part of the secret-
exfiltration mitigation) and refresh fetches are best-effort. For
private repos the auth-fail is the documented outcome.

The original A4 spec proposed re-injecting the PAT -- that would have
violated _assert_no_pat_leak and the URL-scrub mitigation. Re-scoped
to: silence the known-benign signature at DEBUG, keep WARNING for
genuine failures (network errors, broken remotes, repo-not-found).

No behavior change. No security boundary touched. Just log level.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
A4 (re-scoped 2026-05-12 after investigation showed the original spec
proposed reintroducing a documented security regression).
2026-05-12 03:35:13 +02:00
Renn F 10be97fd5a refactor(orchestrator): A2+A3 follow-ups — extract workspace-path helpers
Fixes 2 important + 1 minor issue from the code-quality review of 5adb4ff:

1. Formula duplication: the workspace path string was inlined at two
   sites in orchestrator.py (the canonical _prepare_agent_spawn and the
   new _build_mount_args -w logic). Extracted to module-level helpers
   _agent_workspace_path(project, team, agent_id) and
   _cell_workspace_path(project, team) so both callers share the same
   formula. Future path changes only land in one place.

   Also extracted _resolve_project_slug_from_git_context() as the
   module-level counterpart to the instance method, called by the static
   _build_mount_args site that cannot access self.

2. Test consistency: test_workdir_matches_edit_allowlist_path now
   extracts the Edit(<prefix>/**) value from _get_role_permissions and
   asserts the spawn cmd's -w value equals that prefix. The test would
   actually catch a drift where _build_mount_args and _get_role_permissions
   use different formulas — previously it just compared two copies of
   the same string.

3. Test coverage: added test cases for product_owner and head_marketing
   spawns (both share the per-agent workspace path), so all roles that
   _get_role_permissions distinguishes are covered.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
A2+A3 (re-scoped 2026-05-12).
2026-05-12 03:13:11 +02:00
Renn F 5adb4ff272 fix(orchestrator): A2+A3 set agent container cwd to workspace path
Smoke run 3 surfaced two bugs that share a root cause:
  - Edit(/app/README.md) → 'Edit exists but is not enabled in this context'
  - commit(files=['/app/README.md']) → 'outside repository at <workspace>'

Both happened because the container's WORKDIR is /app (roboco package
source) while the agent's task workspace is bind-mounted at
/data/workspaces/<project>/<team>/<agent>/. The Dev role's
Edit/Write permission allowlist scopes to the workspace, so any Edit
call from /app fails the path match.

Adds '-w {workspace_path}' to the docker run command so the container
starts with cwd = task workspace. Edit(README.md) and git add README.md
now resolve inside the workspace clone.

Mirrors _get_role_permissions path selection exactly:
  - developer / product_owner / head_marketing: per-agent workspace
  - documenter: cell workspace (matches its Write/Edit allowlist)
  - qa / cell_pm / main_pm / auditor: omit -w, fall back to /app

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
sections A2 + A3 (re-scoped per investigation 2026-05-12).
2026-05-12 03:03:17 +02:00
Renn F cfb7424c80 fix(gateway): A1 review-fixes — re-entry ordering, gate unit-coverage, approach check
Three fixes from the code-quality review of a1009c0:

1. Critical: _pm_sub_tasks_gate ran before _handle_pm_reentry, breaking
   idempotent re-entry for PMs whose containers crashed mid-run. Moved
   the gate to after the re-entry short-circuit so initial-claim is the
   only path that hits the gate.

2. Critical: gate had no direct unit test (the HTTP-layer test mocked
   the choreographer). Added tests/unit/gateway/test_i_will_plan_sub_tasks_gate.py
   with six tests: empty sub_tasks → incomplete_input, missing rich_plan
   → incomplete_input, filled sub_tasks → gate passes, developer with
   empty sub_tasks → gate passes (devs don't decompose), sub_tasks filled
   but approach empty → incomplete_input, in_progress re-entry short-
   circuits before gate even with no sub_tasks.

3. Important: approach was only enforced at the HTTP Pydantic boundary.
   Direct service-layer callers (MCP, test fixtures, orchestrator-
   internal) could persist a plan with no approach. Gate now also checks
   approach >= 20 chars (_PM_APPROACH_MIN_LEN constant) and includes it
   in the rejection's missing list when absent.

Plus: stale docstring on i_will_plan corrected; _handle_pm_reentry
docstring rewritten to lead with the domain reason (re-entry contracts)
not the PLR0911 linter justification; unused pytest import removed from
test_i_will_plan_rich_required.py; pre-existing PLR2004/PLC0415 issues
in that file fixed.
2026-05-12 02:45:48 +02:00
Renn F a1009c05e8 feat(gateway): A1 plan-required-at-claim gate
i_will_plan now requires approach (min_length=20) at the schema and
non-empty sub_tasks at the gateway when the caller is a PM role. Restores
pre-gateway parity for _validate_claimed_start — agents could not
transition claimed -> in_progress without filling the rich plan.

Smoke run 3 (2026-05-11) showed PMs calling i_will_plan with just
plan='paragraph' and the gateway accepting it; Plan tab stayed empty
because no agent filled approach/sub_tasks/risks/open_questions.

Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section A1.
2026-05-12 02:30:00 +02:00
Renn F 62d1084a0c fix(gateway): notify_list/get/ack call NotificationDeliveryService (not Service)
Wave 1 wired notify_list/get/ack into ContentActions but pointed them at
`self.notifications` (which is NotificationService — sender side, with
send_blocker_notification / send_qa_ready_notification / etc.). The
read methods (list_for_agent, get_for_recipient_and_mark_read,
acknowledge) live on `NotificationDeliveryService` instead.

Smoke run 2026-05-11 surfaced this immediately:
  AttributeError: 'NotificationService' object has no attribute 'list_for_agent'

Fixes:
- roboco/api/deps.py — import NotificationDeliveryService and wire it
  in as a new ContentActionsDeps field `notification_delivery`.
- roboco/services/gateway/content_actions.py — add notification_delivery
  to ContentActionsDeps (Optional with `None` default for back-compat
  with any tests that don't supply it). Point notify_list, notify_get,
  notify_ack at self._deps.notification_delivery.
- tests/unit/gateway/test_content_actions.py — _make_deps adds a default
  AsyncMock for notification_delivery so existing tests continue to pass.

Quality: ruff + mypy clean. 505 unit tests pass.
2026-05-11 08:41:46 +02:00
Renn F dc9c49e1e4 feat(gateway): G8 part b — typed blocker_type + what_needed on i_am_blocked
Pre-gateway parity (G8 part b of the 2026-05-11 design). The pre-gateway
TaskBlockInput at 254cc93:roboco/mcp/schemas/__init__.py required
blocker_type (external|internal|question|dependency) and what_needed
so PMs could triage their inbox by class. Current i_am_blocked dropped
both fields — every blocked task looked the same to the PM.

Now i_am_blocked accepts both as optional kwargs:
- Back-compat: callers that omit them still work (blocker_type defaults
  to None → rendered as flat reason in the struggle entry).
- New: when supplied, the struggle journal entry body is structured
  markdown (## Blocker Type / ## What Needed sections) so the panel's
  journal view renders named blocks instead of one flat sentence.

Validator on blocker_type enforces the enum at the Pydantic boundary
with a clear "must be one of: ..." error if the agent invents a value
(same pattern as the Wave 3 G7 validators).

G8 part a — typed `pause(checkpoint_summary, remaining_work)` — defers.
That gap needs a new IntentSpec in foundation/policy/lifecycle.py
(currently pause is an ActionSpec only; agents auto-pause via i_am_idle)
plus checkpoint wiring through TaskService.add_checkpoint. Material
work, deferred until after the user has deployed and verified G7 +
G8b lands cleanly.

Wired:
- roboco/api/schemas/v2/flow.py — IAmBlockedRequest gains optional
  blocker_type + what_needed; @field_validator enforces the enum
- roboco/api/routes/v2/flow_dev.py — passes the new fields through
- roboco/services/gateway/choreographer/_impl.py — i_am_blocked signature
  + structured struggle-entry rendering
- roboco/mcp/flow_server.py — typed wrapper with the kwargs
- agents/prompts/roles/developer.md — updated verb table
- tests/unit/mcp_servers/test_flow_server.py — updated to expect the
  new optional kwargs as None when omitted

Quality: ruff + mypy clean. 505 tests pass.
2026-05-11 06:05:02 +02:00
Renn F bd52e3d0c3 fix(schemas): pre-gateway-style cross-field validators on DelegateRequest
Pre-gateway parity for G7 of the 2026-05-11 design. The pre-gateway
TaskCreateInput at 254cc93:roboco/mcp/schemas/__init__.py:210-235 had
@field_validator hooks that caught the most common LLM-vs-schema
confusions with helpful "did you mean X?" hints. Those validators were
lost in the gateway refactor.

Three validators added to DelegateRequest:

- estimated_complexity: rejects ints (some agents send 1/2/3 thinking
  it's a priority), enforces enum {low|medium|high|critical}. Hint
  steers them to drop priority (which isn't a delegate parameter).

- nature: rejects invented values like the 2026-05-11 'standard'
  regression. Enum is {technical|non_technical}. Hint explicitly cites
  the regression so the LLM knows why this is enforced.

- task_type: rejects invented task_type values. Enum is {code,
  documentation, research, planning, design, administrative}.

Fail-fast at the Pydantic boundary returns a 422 with the structured
hint inline, so the agent loops a single retry instead of leaking a
TaskCompletenessError up the stack.

Existing tests in tests/unit/api/routes/v2/test_flow_*.py used
"nature": "feature" — a value that the gateway's TaskNature enum
never accepted, so it would have been rejected at completeness check
anyway. Updated both to "technical".

Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
2026-05-11 06:00:58 +02:00
Renn F 72e01a7f13 feat(gateway): Wave 2 pre-gateway parity — structured note, sub_tasks, channels
Three Wave 2 gaps from the 2026-05-11 pre-gateway parity design:

G4 — note() decision/reflect now require structured fields at the gateway
(pre-gateway `Field(...)` parity). Returns `incomplete_input` envelope
with field-by-field hints when any required field is missing.
  - decision: context (str), options (list[{name,pros,cons}] min len 2),
    chosen (str), rationale (str). `consequences` and `next_steps` are
    now list[str] (was str). Renderer emits each option as a "### Name
    + Pros / Cons" block instead of a bullet — matches the pre-gateway
    DecisionOption sub-shape exposed in `roboco/mcp/schemas/__init__.py`
    at `254cc93`.
  - reflect: what_done, what_learned, what_struggled (each non-empty
    str). next_steps stays optional.
  - Bumped tests/unit/gateway/test_content_actions.py with explicit
    pass-with-N-options coverage (≥2 floor; 3-option case green).

G5 — i_will_plan now persists sub_tasks alongside approach / risks /
open_questions / technical_considerations. The Plan tab's Sub-Tasks
section was empty because the verb didn't accept the field. Choreographer
server-assigns id + order to each sub_task (pre-gateway build_plan_data
parity) and normalizes every list entry to the EXACT shape
`panel/src/types/index.ts::TaskPlan` consumes:
  - SubTask: {id, title, description, completed:false, order,
    estimated_hours:null, notes:null}
  - Risk: {description, mitigation, severity:null} — accepts the
    {risk, mitigation} pre-gateway shape too
  - OpenQuestion: {question, answer:null, answered_by:null,
    answered_at:null} — accepts a bare string fallback
The normalization lives in three small module-level helpers
(_normalize_sub_task / _normalize_risk / _normalize_open_question)
called from _build_panel_shaped_plan, keeping i_will_plan's branch
count under PLR0912.

G6 — new `channels()` verb returns the agent's readable + writable
channel slugs from foundation.policy.communications. Stops invented
slugs ("backend-dev", "backend") that we kept seeing in smoke runs.
Added to every role's manifest including auditor (read-only access).

Wired through:
- roboco/api/schemas/v2/do.py — list-typed consequences/next_steps,
  dict-typed options, ChannelsRequest
- roboco/api/schemas/v2/flow.py — IWillPlanRequest.sub_tasks
- roboco/api/routes/v2/do.py — /channels endpoint
- roboco/api/routes/v2/flow_*.py — pass sub_tasks through
- roboco/services/gateway/content_actions.py — channels() method;
  _check_scope_required_fields enforces decision/reflect structure;
  _render_option_block emits per-option markdown blocks
- roboco/services/gateway/choreographer/_impl.py — _build_panel_shaped_plan
  helper used by i_will_plan
- roboco/services/gateway/role_config.py — _CHANNEL_DISCOVERY tuple
  on every role
- roboco/mcp/do_server.py — channels() tool + note() signature with
  options as list[dict[str,str]]
- roboco/mcp/flow_server.py — i_will_plan signature with sub_tasks

Frontend: no code change. panel/src/types/index.ts already declares
the exact shape we now write; panel/src/components/tasks/task-detail/
{tab-plan,tab-progress,tab-sessions,tab-notes}.tsx already reads it.
The empty panels we observed were a backend write-side problem, not
a frontend read-side problem — Wave 1 + Wave 2 close it.

Quality: ruff + mypy clean. 505 unit tests pass (added 2 new tests on
decision-scope requirements, updated 3 existing tests to fit the
pre-gateway-parity contract).

Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
2026-05-11 05:57:57 +02:00
Renn F 8408d761ca feat(gateway): Wave 1 pre-gateway parity — sessions, progress, notify inbox
Closes empty-panel complaints (Sessions, Progress) and the i_am_idle
notification-inbox deadlock identified in the 2026-05-11 gap analysis.
All backend service methods already exist; this is pure MCP-surface
widening on top of the existing choreographer + ContentActions.

New MCP tools (roboco-do):
- progress(task_id, message, percentage)         — Progress tab writer
- open_session(task_id, channel, topic, ...)     — Sessions tab writer (PM+)
- link_session(session_id, task_id, ...)         — Idempotent task↔session
- notify_list(unread_only, pending_ack_only, limit)
- notify_get(notification_id)
- notify_ack(notification_id)

Wired through:
- roboco/api/schemas/v2/do.py — six new request schemas with Field constraints
  (Progress.percentage: ge=0, le=100; OpenSession.topic: max_length=200; etc.)
- roboco/api/routes/v2/do.py — six new POST routes, thin dispatchers
- roboco/services/gateway/content_actions.py — six new ContentActions methods
  forwarding to TaskService.add_progress, MessagingService.create_session_for_tasks
  /link_session_to_task, NotificationDeliveryService.list_for_agent / get_for_
  recipient_and_mark_read / acknowledge
- roboco/mcp/do_server.py — six new typed tool wrappers + registered in _TOOLS
- roboco/services/gateway/role_config.py — receivers (list/get/ack) added to
  every role except auditor (who gets list/get, no ack). Session verbs to
  PM-or-up. Progress to dev + doc.
- agents/prompts/roles/*.md — verb tables updated for developer / QA /
  documenter / cell_pm / main_pm. i_am_idle line points to notify_list as
  the deadlock resolution path.

Authorization:
- progress: assignee + active status (in_progress / verifying / awaiting_qa /
  awaiting_documentation)
- open_session: cell_pm / main_pm / product_owner / head_marketing / ceo
- link_session: caller must own the task
- notify_ack: caller must be a recipient (ValueError from service maps to
  not_authorized envelope)

Per-file ignore extended:
- roboco/services/gateway/**/*.py = [PLC0415, PLR0913] — same rationale as
  roboco/mcp/**: typed verb signatures are the agent-facing contract; bundling
  into dataclasses hides field-level schema the LLM needs at the tool layer.

Quality: ruff + mypy clean. 503 unit tests pass on touched surfaces.

Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
2026-05-11 05:43:01 +02:00
Renn F 60bd9b175d fix(gateway): tighten sibling-dedup to cap spine-type concurrency
Smoke run 2026-05-11 (3rd attempt) caught the runaway-decomposition
pattern again, but with TWO different dev assignees so the old
same-assignee-same-type check missed it. Cell PM split one workflow
into "Execute Git Workflow: Branch, Edit, Commit, Push" (be-dev-1)
+ "Create PR with Task ID Linked to Parent Task" (be-dev-2), then
respawned and added a 3rd ("Commit and push smoke test change",
planning) — five tasks for what should be one dev hop.

The cell_pm.md prompt already forbids this pattern. The agents ignore
it. So we add the rule at the gate:

Rule 1 (spine-type concurrency cap): for task_type ∈
{code, planning, documentation}, a parent may have at most ONE
non-terminal subtask of that type — regardless of assignee. These
types are the spine of the lifecycle (dev → QA → doc → PM); the
chain is sequential and there's no merge story for parallel
siblings of the same spine type. PM must complete the existing
child first, or restructure into independent parents.

Rule 2 (same-assignee fallback): unchanged behavior for non-spine
types (research / design / administrative) — same assignee +
same type still rejects.

Error message names the existing sibling id so the PM doesn't
need to query separately, and remediate suggests either
"complete the existing one" or "split parent into two parents
for genuinely parallel work".

Quality: ruff + mypy clean, 417 unit tests pass.
2026-05-11 05:03:06 +02:00
Renn F 6369184b72 docs(prompts): mark i_will_work_on plan param as required, not optional
Smoke run showed be-dev-1 repeatedly calling `i_will_work_on(task_id)`
without `plan`, hitting `tracing_gap missing=['plan']` and retrying with
the same payload. Root cause: prompt's verb table showed the signature
as `plan=None` (optional default) while the gateway requires plan on
every claim — including first claim. The dev followed the signature
line, missed the workflow-table line that pairs it with `plan='...'`.

Tightening the signature to `plan` (no default), explicit "REQUIRED
even on first claim" callout, and a note that resume calls use
`plan='resume: <next step>'`.
2026-05-11 04:59:03 +02:00
Renn F 92badfe6a0 docs(prompts): teach all roles the structured verb shapes (pre-gateway parity)
Counterpart to bcc748c. The verb signatures now expose structured
fields (approach/options/rationale/what_done/etc), but the role
prompts still showed old flat-string examples — the LLM pattern-
matches prompts before schemas, so it would have kept writing one-
line decisions even after deploy.

Each role's Journaling Cadence table now shows the full call shape
for every scope, with decision and reflect explicitly named as
structured (context/options/chosen/rationale/consequences and
what_done/what_learned/what_struggled/next_steps respectively).

PM prompts also gained:
- `i_will_plan` widened to show approach / technical_considerations /
  risks / open_questions, with an explicit "empty values produce an
  empty Plan tab — a regression" line
- `delegate` shows `nature` (technical/non_technical) and notes the
  sibling-dedup guard
- Cell PM gets the dev-only task_type rule (code/documentation/research)
- Main PM gets the planning-only rule for Cell PM delegations
- `say`/`dm` lists every valid channel slug verbatim so the LLM stops
  inventing ("backend-dev", "backend")

Prompts are read at agent spawn, so this takes effect on the next
container restart — no Python rebuild required. Combined with bcc748c
this is the full pre-gateway-parity restoration.
2026-05-11 04:30:36 +02:00
Renn F bcc748c8a3 fix: restore pre-gateway structured verb surfaces (5 fixes)
Smoke run 2026-05-11 showed five regressions stemming from the gateway
consolidating multiple typed endpoints into thin verbs with collapsed
signatures. The choreography is fine; the verb signatures lost the
structured shape that pre-gateway forced agents to fill. Each fix
restores a structured surface so the LLM's tool schema again carries
the constraints that prevent the observed bugs.

A) do_server: list valid channel slugs in say()/dm() docstrings.
   Stops invented channels (`backend`, `backend-dev`) — the LLM now
   sees the closed set in the tool schema.

B) choreographer: add _delegate_sibling_dedup_guard.
   Rejects a delegate that would create a non-terminal sibling with
   the same assigned_to + task_type under the same parent — the dupe
   shape observed on smoke (Main PM creating two planning tasks for
   be-pm; Cell PM creating two code tasks for be-dev-1).

C) choreographer: extend _validate_assignee_task_type to all roles.
   Devs may only get code|documentation|research (not planning/design/
   administrative). QA gets code only. Documenters get documentation
   only. Catches the misroute observed on smoke (Cell PM gave
   be-dev-2 a 'research' coordination task that should have stayed
   with the PM).

D) i_will_plan: thread approach / technical_considerations / risks /
   open_questions from MCP through to TaskService.set_plan as a
   TaskPlan-shaped dict. Empty default keeps back-compat. Panel's
   Plan tab now renders Approach / Sub-Tasks / Technical
   Considerations / Risks / Open Questions instead of an empty pane.

E) note(): scope-specific structured fields restored.
   For 'decision' scope: context, options[], chosen, rationale,
   consequences. For 'reflect' scope: what_done, what_learned,
   what_struggled, next_steps. Rendered as markdown sections into
   the journal entry content so the Decisions and Reflections views
   show named blocks instead of a one-line phrase. Pre-gateway parity.

Files changed:
- roboco/mcp/do_server.py (A, E)
- roboco/mcp/flow_server.py (D)
- roboco/services/gateway/choreographer/_impl.py (B, C, D)
- roboco/services/gateway/content_actions.py (E)
- roboco/api/schemas/v2/flow.py (D)
- roboco/api/schemas/v2/do.py (E)
- roboco/api/routes/v2/flow_main_pm.py (D)
- roboco/api/routes/v2/flow_cell_pm.py (D)
- roboco/api/routes/v2/do.py (E)

Quality: ruff + mypy clean. 89 unit tests pass on the touched surfaces.
2026-05-11 03:45:09 +02:00
Renn F 229797ffe3 fix: unblock smoke run (gateway envelope + alembic + redis + MCP)
Four bugs surfaced by the 2026-05-11 smoke run, all on the path from
Main PM's first delegate to the cell PM accepting a subtask:

- gateway: TaskCompletenessError from _create_subtask_from_inputs leaked
  through Starlette as a 500; agents retried in a tight loop because
  they never saw field_hints. Wrap the call in _create_subtask_and_envelope,
  catch the error, return Envelope.incomplete_input with the
  interrogation-pattern reply the upfront completeness check produces.

- alembic: migration 012 used a 40-char revision id which exceeds
  alembic_version.version_num varchar(32). Upgrade fell back to create_all
  on every boot, silently skipping the migration. Rename to
  012_align_agentrole_foundation (30 chars). File rename + revision string.

- events/stream_bus: external Redis FLUSHALL while orchestrator is running
  (e.g. reset_runtime_state.sh) drops the consumer group; the listen loop
  then spams NOGROUP every block-cycle forever. Catch ResponseError with
  NOGROUP in the message and rebootstrap the group via
  _ensure_consumer_group, then continue. Self-heals without restart.

- mcp/flow_server: delegate took body: dict with no schema, so the LLM
  invented values like nature='standard' and the SDK threw 'unhashable
  type: dict' on nested args. Flatten to typed top-level parameters with
  docstring listing valid enum values for team / task_type / nature /
  estimated_complexity. PLR0913 per-file ignore added for roboco/mcp/**
  because MCP tool signatures ARE the LLM contract — bundling into a
  dataclass would hide the enum hints that prevent the invention bug.
2026-05-11 02:46:54 +02:00
207aaecd72 Feature: lifecycle canonical spec (#14)
* chore: clean make quality baseline on feature/lifecycle-canonical-spec

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(lifecycle): _STATUS_TRANSITIONS table + STATUS_GRAPH view

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(lifecycle): push CLAIM_RULES enforcement into can_invoke_action

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Parity test in tests/lifecycle/test_consumer_parity.py.

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

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

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

Parity tests in tests/lifecycle/test_consumer_parity.py.

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

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

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

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

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

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

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

Parity tests in tests/lifecycle/test_consumer_parity.py.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Plus restored the load-bearing rules that got dropped:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(foundation/policy/communications): Priority + NOTIFY_SENDER_ROLES + ACK_REQUIRED_BY_TYPE

* feat(foundation/policy/communications): CHANNELS catalog (channel topology)

* refactor(agents_config): derive CHANNEL_ACCESS from foundation.communications

* refactor(seeds): derive DEFAULT_CHANNELS / CHANNEL_MEMBERSHIPS from foundation

* refactor(content_actions): derive notify allowlist + priorities from foundation

Replaces _NOTIFY_ALLOWED_ROLES + _VALID_NOTIFY_PRIORITIES literals with
derivations from foundation.communications.NOTIFY_SENDER_ROLES + Priority.

Behavior change: pre-Phase-3 the literal frozenset {cell_pm, main_pm,
product_owner, head_marketing} excluded CEO. Foundation includes CEO
(per spec 5.5). The contradiction with agents_config.NOTIFICATION_PERMISSIONS
(which already granted CEO can_send=True) is now resolved.

* refactor(notification_delivery): requires_ack from foundation.ACK_REQUIRED_BY_TYPE

* refactor(enforcement,agents_config): delete dead notification policy

- enforcement/notification_perms.py deleted (dead at call-graph; only
  the enforcement/__init__.py re-export kept it reachable, and that
  re-export is gone too).
- agents_config.NOTIFICATION_PERMISSIONS dict deleted; agents_config
  .can_send_notifications now derives from
  foundation.policy.communications.NOTIFY_SENDER_ROLES (auditor
  correctly excluded — silent observer per spec §5.5).
- services/permissions.py: _can_role_send_notifications and
  can_agent_send_notifications now derive from NOTIFY_SENDER_ROLES;
  _get_notification_scope encodes the scope rule (cell/all/list)
  locally as a function-of-role and returns list[AgentRole] instead
  of list[slug]; can_notify list-scope branch updated to match.
- enforcement/__init__.py: removed the notification_perms re-export
  and the NotificationPermissionError, get_notification_scope,
  validate_notification_permission names from __all__.

Closes the spec §3 contradiction: gateway content_actions
._NOTIFY_ALLOWED_ROLES (Task 5) and the legacy
agents_config.NOTIFICATION_PERMISSIONS no longer disagree about
whether auditor may call notify(). Both now derive from
foundation.NOTIFY_SENDER_ROLES.

* fix(content_actions): runtime auditor guard in say/dm (defense in depth)

Closes the spec §5.5 gap where the auditor's silent role was enforced
ONLY by manifest exclusion. The manifest pre-filters the tool surface
exposed to the auditor agent, but if anything bypassed it, the auditor
could speak. The new runtime guard in ContentActions.say/dm refuses
with Envelope.not_authorized when the caller's role is "auditor",
regardless of how the call arrived.

* fix(a2a): pass Priority tristate end-to-end (was reduced to boolean)

Pre-Phase-3 path:
  request priority: str -> services/a2a.py reduces to urgent: bool
  -> services/notification.py maps bool back to NotificationPriority
This made Priority.HIGH unreachable through the A2A path.

After this fix the full tristate (NORMAL/HIGH/URGENT) survives end-to-end:

  * services/a2a.py:create_a2a_notification parses metadata["priority"]
    (preferred) or falls back to legacy metadata["urgent"] / config.urgent
    (URGENT-only). Unknown values fall back to NORMAL.
  * services/notification.py:send_a2a_notification now takes
    a2a_context["priority"] (NotificationPriority); a defensive bool/str
    coerce keeps legacy callers from crashing.
  * runtime/orchestrator.py:_build_a2a_prompt reads priority off the
    notification row (the source of truth) instead of a non-existent
    metadata.urgent and renders three tiers: URGENT bold, HIGH softer,
    NORMAL no prefix.

Cosmetic [URGENT] body/subject prefix stays urgent-only; HIGH gets no
prefix but is recorded as HIGH at the NotificationTable.priority column.

Tests:
  * 9 new tests in tests/integration/test_a2a_priority_tristate.py
    pinning the round-trip for HIGH/NORMAL/URGENT through both layers
    plus legacy-bool backcompat.
  * Updated tests/unit/services/test_notification.py::test_send_a2a_notification
    to the new priority= contract.

Closes the spec section 3 contradiction flagged in the audit.

* feat(foundation/policy): agent_loop BudgetPolicy + VERB_RETRY_LIMITS

* refactor(agent_sdk): import budget thresholds from foundation

* refactor(orchestrator): import _PM_RESPAWN_MAX_UNPRODUCTIVE from foundation

* fix(post-tool-budget-hook): exit 1 on loop-halt (was exit 0 / non-blocking)

Pre-Phase-3 the hook printed [Loop] and exit 0'd — agents could ignore it
and keep retrying. The 2026-05-10 smoke run showed i_am_done retried 5+
times within the global 150-tool budget, never hitting a real wall.

Now the hook reads the SDK response's loop_action field (sourced from
foundation.BudgetPolicy.loop_action; default "halt") and exits 1 to
deny the wrapping tool call when the rolling-window loop detector fires
AND loop_action is "halt". Operators can soften via env
ROBOCO_AGENT_LOOP_ACTION=warn for debugging.

Changes:
- BudgetStatus pydantic model: add loop_action: Literal["warn", "halt"]
  (default "halt") so the SDK response carries the policy.
- agent_sdk/server.py: read ROBOCO_AGENT_LOOP_ACTION env override on top
  of foundation default and surface it in _budget_snapshot().
- post-tool-budget-hook.sh: parse .loop_action, exit 1 to stderr when
  loop+halt; falls back to legacy warn-only print if the field is
  missing (older SDK / partial deploy).

* feat(agent_sdk): per-verb retry circuit breaker via foundation.VERB_RETRY_LIMITS

Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke
showed i_am_done retried 5+ times in 2 minutes within the global 150-tool
budget — the agent never hit a real wall.

Now the SDK tracks (verb, task_id) -> deque[timestamp] over a 60s sliding
window. When the count for a verb exceeds foundation.retry_limit_for(verb),
the next attempt receives Envelope.circuit_open with a remediate hint
pointing to i_am_blocked / i_am_idle as graceful exits.

Verbs in foundation.UNLIMITED_RETRY_VERBS (give_me_work, triage,
evidence, etc.) bypass the breaker. Only rejection envelopes
(tracing_gap, invalid_state, not_authorized, incomplete_input) feed
the counter — successful calls do not count.

Wire-up:
- Envelope.circuit_open classmethod + as_dict pass-through
- _SessionState.verb_attempts: defaultdict[(verb, task_id), deque[float]]
- Helpers _record_verb_attempt / _verb_attempt_count / _check_verb_circuit
- POST /verb/attempted: hook posts after a rejected gateway call;
  response carries breaker state + (when open) the wire-format
  Envelope.circuit_open dict the agent should surface to itself
- GET /verb/circuit_status: read-only state probe
- _state.reset() (also POST /budget/reset) wipes the tracker on spawn

* test(foundation): Phase 3 smoke gate + foundation-check extended

Phase 3 closes here:
- foundation/policy/communications.py owns Priority, NOTIFY_SENDER_ROLES,
  ACK_REQUIRED_BY_TYPE, ChannelSpec, CHANNELS, parse_priority
- foundation/policy/agent_loop.py owns BudgetPolicy, VERB_RETRY_LIMITS,
  UNLIMITED_RETRY_VERBS, retry_limit_for
- 6 channel topology fork sites collapsed to one source (CHANNELS)
- Notification sender contradiction closed (CEO included; auditor excluded)
- A2A urgency tristate restored (HIGH reachable end-to-end); A2A
  service now consumes parse_priority instead of inlining branches
  (also drops create_a2a_notification CC from C/13 to A/<10)
- Auditor silent role enforced at runtime in say/dm
- enforcement/notification_perms.py deleted (was dead code)
- 7 hand-set requires_ack callsites consolidated to ACK_REQUIRED_BY_TYPE
- post-tool-budget-hook.sh exits 1 on loop-halt
- Per-verb retry circuit breaker live in agent_sdk (60s sliding window)

make foundation-check now validates communications + tracing + journaling
+ identity drift in one command. make quality green.

* refactor(lifecycle): copy spec.py to foundation/policy/lifecycle.py + shim

Phase 4 Task 1 — relocates the canonical lifecycle spec next to its policy
siblings (task_completeness, tracing, journaling, communications, agent_loop).

The original roboco/lifecycle/spec.py is now an explicit re-export shim;
consumers continue to work unchanged. Subsequent Phase 4 tasks (2-7) migrate
the imports in batches, then Task 8 deletes the shim.

No behavior change — pure code move.

* refactor(services): import lifecycle from foundation (Phase 4 batch)

* refactor(agents,enforcement): import lifecycle from foundation (Phase 4 batch)

* refactor(tests): import lifecycle from foundation (Phase 4 batch)

* refactor(foundation): absorb lifecycle _validate + _generators

Phase 4 Tasks 9 + 10. Moves the lifecycle spec's internal validators
to roboco/foundation/_validate_lifecycle.py and its RAG/prompt artifact
emitter to roboco/foundation/_generators.py.

The lifecycle validators live in a sibling module (not merged with
foundation/_validate.py) because the lifecycle spec imports from
foundation at module load — placing the lifecycle checks alongside the
identity checks would create an import cycle between
roboco.foundation and roboco.foundation.policy.lifecycle (the latter
calls the validators at the bottom of its own definition). The
_validate_lifecycle module defers its policy.lifecycle imports to
function bodies so it loads cleanly when the spec hasn't finished
initialising yet; the per-file PLC0415 exemption in pyproject.toml
documents the reason.

Test files relocated:
- tests/lifecycle/test_spec.py        -> tests/foundation/test_lifecycle_spec.py
- tests/lifecycle/test_generators.py  -> tests/foundation/test_lifecycle_generators.py

scripts/build_lifecycle_artifacts.py now imports the generators from
roboco.foundation; the on-disk artifacts (docs/rag/lifecycle,
panel/lib/lifecycle.json, agents/prompts/_generated/lifecycle-*.md)
regenerate byte-identically.

After this commit, roboco/lifecycle/ contains only the spec.py and
__init__.py re-export shims — Task 8 deletes those.

No behavior change. 6638 tests pass; make quality green.

* refactor(lifecycle): delete legacy roboco/lifecycle/ package

Phase 4 Task 8. All consumers migrated to roboco.foundation.policy.lifecycle
in Tasks 2-7; the internal validators + generators moved to foundation in
Tasks 9-10. The legacy package contained only re-export shims.

Also trims tests/foundation/test_role_reexport.py — the two assertions that
checked the lifecycle.spec shim's object-identity are gone with the shim.
The two models.base shim assertions (AgentRole / Team) are still
meaningful and stay.

Inline docstrings / comments in enforcement/task_lifecycle.py,
services/gateway/role_config.py, services/gateway/content_actions.py,
tests/integration/test_task_service_lifecycle_misc.py and
foundation/policy/lifecycle.py that referenced the now-deleted
roboco.lifecycle.spec module are updated to point at
roboco.foundation.policy.lifecycle.

After this commit, roboco.lifecycle is gone. Lifecycle policy lives only
at roboco.foundation.policy.lifecycle. Adding new lifecycle rules edits
exactly that one file.

* refactor(api): consolidate route-guard role-sets via foundation

Replace hand-written role-name string frozensets in roboco/api/deps.py
(_PM_OR_ABOVE_ROLES, _DEVELOPER_OR_ABOVE_ROLES, _GLOBAL_CELL_ACCESS_ROLES)
and roboco/api/routes/v2/_role_dep.py (require_dev/qa/doc/cell_pm/main_pm/
board/auditor) with foundation-derived expressions over PM_ROLES,
BOARD_ROLES, DEV_ROLES, and Role enum members.

Behavior is preserved: Role is a StrEnum, so the lowercase X-Agent-Role
header still compares equal to its matching member. HEAD_MARKETING stays
excluded from every -or-above set (marketing spokesperson, not approver);
the carve-out is now expressed as (BOARD_ROLES - {Role.HEAD_MARKETING})
instead of an opaque literal.

Adds tests/foundation/test_route_guard_consolidation.py (6 tests) pinning
both the foundation-derived membership and the import contract.

* test(foundation): Phase 4 smoke gate + housekeeping closeout

Phase 4 closes the foundation canonicalization effort (Phases 1-4 spanning
2026-05-10 -> 2026-05-11):

Phase 1 - identity + task_completeness (skeleton-task bug killed)
Phase 2 - tracing + journaling (pre-gateway cadence restored)
Phase 3 - communications + agent_loop (channel/notification/A2A/circuit-breaker)
Phase 4 - housekeeping (lifecycle moved to foundation; consumers migrated)

All cross-cutting policy now lives in roboco/foundation/. Adding a policy
edits exactly one file. The legacy roboco.lifecycle package is gone.
Smoke gates 1-4 enforce: no skeleton tasks, no inline journal:decision
checks, channel topology canonical, A2A tristate preserved, auditor silent
at runtime, lifecycle module path canonical.

make quality + make foundation-check both green.

* fix(mcp/agent_sdk): wire per-verb circuit breaker into response handler

Phase 3 Task 14 added the SDK infrastructure (tracker, endpoints,
Envelope.circuit_open, retry_limit_for) but nothing was actually
recording rejections — the breaker never tripped. This commit wires
the gateway-response path so every rejection envelope (tracing_gap /
invalid_state / not_authorized / incomplete_input) hits
POST /verb/attempted, and if the breaker is open, the envelope is
substituted with the circuit_open response before the agent sees it.

Best-effort: SDK-unreachable / malformed-response failures fall open
(agent sees the original rejection), so the breaker never breaks the
gateway path.

* fix(notification_delivery): retype CEO approval-flow notifications APPROVAL

notify_assignee_of_ceo_rejection and notify_ceo_of_escalation were both
typed NotificationType.TASK_ASSIGNMENT, which the Phase 3 foundation
table (ACK_REQUIRED_BY_TYPE in roboco/foundation/policy/communications.py)
maps to requires_ack=False. Both are approval-flow notifications and
should mandate acknowledgment.

Retyped both to NotificationType.APPROVAL so the table lookup yields
requires_ack=True via ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL].

* test(foundation): move lifecycle parity + smoke-replay tests under tests/foundation/

Phase 4 Task 8 deleted roboco/lifecycle/ but tests/lifecycle/ still held
two files importing roboco.foundation.policy.lifecycle. Mirror the layout
of test_lifecycle_spec.py and test_lifecycle_generators.py (moved in
Phase 4 Tasks 9+10) by relocating them under tests/foundation/ with the
test_lifecycle_* prefix, then delete the now-empty tests/lifecycle/
package.

  tests/lifecycle/test_consumer_parity.py
    -> tests/foundation/test_lifecycle_consumer_parity.py
  tests/lifecycle/test_smoke_replay.py
    -> tests/foundation/test_lifecycle_smoke_replay.py

* build(make): consolidate ci-lifecycle-check into foundation-check

ci-lifecycle-check was a thin wrapper that regenerated lifecycle artifacts
via scripts/build_lifecycle_artifacts.py and gated on git diff. After
Phase 4 it sat alongside foundation-check covering the same drift-gate
intent. Merge the lifecycle-artifact regen + git-diff step into
foundation-check so a single 'make foundation-check' is the canonical
drift gate.

Keep ci-lifecycle-check as a phony alias forwarding to foundation-check
for any external script or CI lane still using the old target name.
Drop the redundant ci-lifecycle-check call from 'make quality'.

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-05-11 02:15:47 +02:00
Renn F 091e4076a2 fix(gateway): reject Cell-PM-assigned subtasks that aren't task_type=planning
Bug B from the 2026-05-09 smoke run. main-pm called
delegate(assigned_to='be-pm', task_type='code'). The chain validator
let it through (be-pm IS in main-pm's allowed targets), the schema
let it through (task_type='code' is a valid enum value), and the
subtask got created mis-typed. Task 0 made it cosmetically work
because PMs can now plan code-typed parents — but the model is
wrong: a Cell PM owns the PLANNING of the slice; the code execution
is what they delegate to devs.

New gate in _delegate_static_guards: when assignee is a Cell PM
(be-pm/fe-pm/ux-pm), task_type MUST be 'planning'. Returns
invalid_state with a remediate hint pointing at the right type.
Devs are unrestricted (could be code OR documentation, depending
on the slice).

Tests: 3139 passing (+ 2 regression tests pinning the rule), 100%
coverage, ruff clean.
2026-05-09 03:29:04 +02:00
Renn F 73e1e96851 Many fixes and cleanups 2026-05-09 03:15:09 +02:00
Renn F d819c28893 fix(gateway): make i_will_work_on plan-precondition atomic; recover claimed-no-plan
Bug A from the 2026-05-09 smoke run. be-dev-1 called i_will_work_on
without `plan` on a pending task; claim() ran first (transitioned to
`claimed`), then the plan check failed → tracing_gap. The natural
retry path then dispatched to `_i_will_work_on_claimed`, which had no
plan-recovery logic and called start() against a still-plan-less task,
returning `start failed` forever. The dev kept looping; the parent
escalated up; the whole slice ended `blocked`.

Two changes (Task-5 atomicity pattern applied to i_will_work_on):

1. `_i_will_work_on_pending`: move the plan precondition BEFORE
   `claim()`. A missing-plan first call now returns tracing_gap with
   the task untouched in `pending`, so the agent's retry-with-plan
   succeeds cleanly.

2. `_i_will_work_on_claimed`: now accepts `plan` and calls set_plan
   before start() if the task has no plan yet. Recovery path for any
   already-stuck task (e.g. left over from the earlier image, or an
   orchestrator restart that left a partial claim).

Also wires `plan` through the dispatcher to the claimed branch.

Tests: 3137 passing (3135 + 2 regression tests pinning the atomic
invariant), 100% coverage, ruff clean.
2026-05-09 03:15:02 +02:00
Renn F b601441da7 fix(audit): record actor's actual role from agents.role at write time
The 2026-05-08 trace caught an audit row with actor=main-pm but
agent_role=cell_pm — the caller had supplied the verb's *expected*
role rather than the actor's actual role. Forensics work that joined
audit_log on agent_role would silently miscategorize the row.

Fix: AuditService now reads the actor's role directly from
agents.role at write time via the new _resolve_actor_role_from_db
helper. Wired into log_task_action_denial,
log_state_transition_denial, and log_notification_denial. The
caller-supplied role param is kept as a best-effort fallback for the
case where the DB lookup fails (singleton-without-DB paths,
permission errors, etc.) so audit writes never block the operation
being audited.

Coverage:
  - 3 unit tests (test_audit.py) for the no-DB / invalid-id paths
  - 1 unit-with-real-DB test (test_audit_real_query.py) verifying
    the persisted row's agent_role is read from DB even when the
    caller passes a deliberately-wrong role
  - 1 unit-with-real-DB test for the no-row case

Tests: 3135 passing, 100% coverage, ruff clean.
2026-05-08 12:18:00 +02:00
Renn F 19f27b4f88 fix(gateway): improve unclaim/resume rejection when task was reassigned
Investigation finding for Task 6 of the gateway introspection plan:
the 2026-05-08 trace's "not your claim" rejection at 02:51:22 / 02:52:48
was NOT caused by a UUID-comparator bug. AGENT_UUIDS in
roboco/seeds/initial_data.py are static so identity is stable across
restarts, and SQLAlchemy + Pydantic both round-trip UUIDs cleanly
(pinned by two new regression tests in test_task_service_misc.py).

The actual cause: the task was REASSIGNED out from under main-pm by
an upstream verb between when main-pm last touched it and when it
tried to unclaim/resume. Common triggers:
  - cell_pm_complete propagates up via _maybe_advance_parent_to_pm_review,
    which reassigns the parent to the cell PM for the team
  - main_pm_complete clears assigned_to to None (CEO acts via UI)
  - unblock with restore=True flips assigned_to back to pre_block_state

Pre-fix the rejection said only "not your claim" — agents can't tell
whether they hit a transient race or whether the task was legitimately
moved on. Fix: surface the current_owner UUID and hint that an
upstream verb did this, telling the agent to call give_me_work() to
find its current work.
2026-05-08 12:03:37 +02:00
Renn F 6806516015 refactor(gateway): rename submit_for_qa to open_pr; pin atomic preconditions
Pre-fix, submit_for_qa opened a PR (side effect) and returned OK with
next='call i_am_done' — agents read the verb name, assumed they were
done with QA handoff, never called i_am_done, and PRs ended up
orphaned (PR #12 in the 2026-05-08 trace).

Two changes:

1. Rename submit_for_qa -> open_pr so the verb name matches the
   semantic. The PR opens here; the actual QA handoff happens at
   i_am_done. Renamed across:
   - choreographer/_impl.py (method)
   - mcp/flow_server.py (tool registration + _TOOLS dict)
   - api/routes/v2/flow_dev.py (route + handler)
   - api/schemas/v2/flow.py (OpenPrRequest)
   - services/gateway/verb_gates.py (_STATE_VERBS)
   - services/gateway/role_config.py (developer flow manifest)
   - services/gateway/content_actions.py (commit-success next= hint)
   - agent_sdk/server.py (post-tool guidance map)
   - runtime/orchestrator.py (developer prompt)
   - agents/prompts/{base,roles/developer,_generated/*}.md
   - tests/unit/gateway/test_submit_for_qa.py -> test_open_pr.py
   - tests/unit/api/routes/v2/test_flow_dev.py
   - tests/unit/gateway/test_verb_gates.py
   - tests/unit/api/test_correlation_id.py
   - tests/unit/mcp_servers/test_flow_server.py
   - tests/integration/test_full_lifecycle_real_db.py

2. New regression test (test_open_pr_does_not_create_pr_if_no_commits)
   pins the atomic invariant: preconditions (assignee, commits,
   no-prior-PR) must be checked BEFORE git.create_pr/push_branch run.
   Any future re-ordering breaks the test.

Tests: 3128 passing (3127 + 1 new), 100% coverage, ruff clean.

Note: TaskService.submit_for_qa() (the v1-layer service method) is
INTENTIONALLY not renamed — it's a different layer used by the v1
routes. The rename here is only the gateway verb surface.
2026-05-08 11:54:31 +02:00
Renn F 2eeefb2ee1 refactor(gateway): consolidate commit + notify role gates into verb_gates
Replaces hardcoded role-string-constants in content_actions.py
(_COMMIT_ALLOWED_ROLES, _NOTIFY_ALLOWED_ROLES) with calls into
verb_gates.is_verb_allowed against a synthetic in-progress task probe.
Pre-fix the same role lists lived in both content_actions and
verb_gates; if one drifted the other would mask it. Now there's one
table.

Adds `notify` to verb_gates._ALWAYS_AVAILABLE for cell_pm, main_pm,
product_owner, head_marketing.

Note: i_will_plan / delegate role checks INTENTIONALLY stay as
explicit `role not in (cell_pm, main_pm)` checks, not is_verb_allowed.
Their state checks must surface as `invalid_state` (a different
agent-side error code) — conflating them with role-state combo
checks breaks the rejection-code semantics agents rely on.

Tests: 3127 passing, 100% coverage, ruff clean.
2026-05-08 11:42:04 +02:00
Renn F ebdbd7fc47 feat(gateway): wire envelope introspection into qa.py + doc.py role mixins
Closes the Task 3 wiring loop: every Envelope construction site in the
QA and Documenter mixins now stamps current_state + valid_next_verbs.
Refactored doc._check_i_documented_inputs to take the loaded task as a
parameter so it can pass through to .with_introspection() without
re-fetching.

Task 3 of the 2026-05-08 gateway introspection plan is now complete
across _impl.py, qa.py, and doc.py.
2026-05-08 11:27:54 +02:00
Renn F 17630bf3fc feat(gateway): wire envelope introspection into i_am_blocked, unclaim, resume,
escalate_up, escalate_to_ceo, submit_up, unblock

Continues Task 3 of the gateway introspection plan. Every developer-
and PM-facing lifecycle verb in _impl.py now stamps current_state +
valid_next_verbs on both the success path and (where the task was
loaded successfully) on rejection paths.

The qa.py / doc.py role mixins still need wiring; that's a follow-up
since their structure mirrors what's already been done here.
2026-05-08 08:25:32 +02:00
Renn F 6092df59b2 feat(gateway): wire envelope introspection into complete + main_pm/cell_pm complete
Trace-driven priority: the 2026-05-08 audit log showed PMs spamming
`complete` against tasks in the wrong status (claimed/in_progress
rather than awaiting_pm_review). Introspection on the rejection path
now tells the PM the actual current_state and which verbs are valid
right now (typically `delegate` / `escalate_up`), shrinking the
trial-and-error loop.

Continues Task 3 of the gateway introspection plan.
2026-05-08 08:18:40 +02:00
Renn F 56fbb97a80 feat(gateway): wire envelope introspection into submit_for_qa, i_am_done
submit_for_qa: ok envelopes (PR-already-open + new-PR) and
not-assigned / no-commits rejections now stamp current_state +
valid_next_verbs.

i_am_done: not-assigned rejection plus tracing-gap and field-gate
rejections all stamp introspection. Success path inherits via the
shared _build_i_am_done_ok helper.

Continues Task 3 of the gateway introspection plan.
2026-05-08 08:15:10 +02:00
Renn F 93c66fa07a feat(gateway): wire envelope introspection into give_me_work, i_will_plan, delegate
Continues Task 3 of the gateway introspection plan: every successful
or rejected envelope from these three verbs now carries current_state +
valid_next_verbs sourced from verb_gates.valid_next_verbs(role, task).

Remaining verbs (submit_for_qa, i_am_done, i_am_blocked, unclaim,
resume, unblock, complete, main_pm_complete, escalate_up,
escalate_to_ceo, submit_up, plus the qa.py / doc.py role-mixins) to be
wired in subsequent commits — the choke-point pattern at each call
site is `.with_introspection(task=t, role=role)` on the constructed
Envelope, so it stays mechanical from here.
2026-05-08 08:09:16 +02:00