Smoke-6 found the agent calling note(scope='decision', context=null,
chosen=null, rationale=null) eight times in a row. Root cause split
across two surfaces:
1. The MCP tool schema declared these fields as `str | None = None`,
producing a JSON schema of `anyOf [string, null]`. minimax-m2.7 read
that and decided null was a valid value — passed it on every retry.
2. The remediate text used `<placeholder>` syntax for the example
without telling the agent "don't pass null" explicitly.
Fixes:
- roboco/api/schemas/v2/do.py NoteRequest: context, chosen, rationale,
what_done, what_learned, what_struggled now typed `str = ""` (no None).
Pydantic on the route rejects literal null with 422 BEFORE the gateway
sees it. Empty string still counts as missing at the gate.
- roboco/mcp/do_server.py note(): matching signature changes so the
MCP tool schema declares the fields as `string` not `anyOf[string,null]`.
- roboco/services/gateway/content_actions.py: remediate text now opens
with "DO NOT pass null" and the example uses concrete values (redis vs
postgres) instead of <angle bracket> placeholders. Reflect remediate
also gets the don't-pass-null intro and concrete values.
8 new tests pin "schema rejects null for each of the 6 string fields"
plus "empty defaults work for unscoped notes".
Smoke-6 surfaced the gap. main-pm called note(scope='decision') with
context: null 8 times in a row — every one returned incomplete_input
and the agent kept retrying. The flow_server had a breaker (C1) but
do_server didn't, so content-tool rejections went uncapped.
Mirror the flow_server pattern:
- _CIRCUIT_REJECTION_KINDS = {tracing_gap, invalid_state,
not_authorized, incomplete_input} (same set)
- _record_and_check_circuit posts to the SDK's /verb/attempted on each
counted rejection
- When the SDK reports open=true, the original rejection envelope is
REPLACED with the circuit_open envelope so the agent stops retrying
The SDK side (agent_sdk/server.py) already accepts arbitrary verb
names; no changes there. note hits the default cap of 3 retries / 60s
from foundation.agent_loop. After the third incomplete_input the
agent gets circuit_open and the loop ends.
9 new tests pinning the contract.
xenon flagged ContentActions.pr_update as rank C — the three-branch
PM-or-assignee guard inlined with the precondition checks pushed it
over the cyclomatic-complexity bound. Extracted the authorization
check into a static helper _pr_update_is_authorized so the verb
itself stays at rank B and the helper carries the role-string +
team-equality branches.
Behavioural no-op; existing tests cover both the assignee path and
the cell_pm-same-team / cell_pm-other-team / main_pm paths.
Adds pr_update to _DEV_DO, _DOC_DO, _CELL_PM_DO, _MAIN_PM_DO so the
spawn manifest builder registers it on those roles' do-servers. QA,
auditor, and Board roles do not get it — QA reviews PRs but does not
edit them; Board operates above the PR layer; auditor is silent.
developer.md and documenter.md grow a verb-table row and a note next
to the open_pr workflow step calling out that pr_update — not bash-
shimmed `gh pr edit` — is the way to fix PR metadata.
ContentActions.pr_update enforces:
- task.pr_number is set (else invalid_state, remediate 'call open_pr')
- at least one of title/body/reviewers is non-None (else invalid_state)
- caller is task assignee OR PM on team (cell_pm same-team / main_pm
cross-team), else not_authorized
- GitError raised by the underlying service maps to invalid_state with
the upstream message preserved
The route at POST /api/v2/do/pr_update binds PRUpdateRequest, whose
model_validator returns 422 on all-None bodies so the verb layer
never sees them. The MCP tool registry adds 'pr_update' so manifest-
scoped do-servers can expose it to roles that opt in (next commit).
Smoke-5 surfaced that be-dev-1 had no gateway-native way to fix a
PR's title/body or request a reviewer after open_pr; `gh pr edit`
is bash-shimmed and the dev correctly escalated rather than bypass
the guard. This adds the GitService primitive: PATCH /pulls/{n}
for title/body and POST /pulls/{n}/requested_reviewers for the
reviewer list, with NotFound + 422 mapped to typed GitError. The
PRUpdateRequest schema enforces 'at least one field' via a
model_validator so the route returns 422 before reaching the verb.
Smoke-5 root cause. Agents wrote 5 decisions / 8 reflections / 1 struggle
during the run — every single entry persisted with task_id=NULL. The C8
tracing gate then never saw them and PMs spiraled forever on
'missing: journal:decision' while their decisions sat orphaned.
Cause: ContentActions.note/say/dm/notify called
TaskService.get_active_task_for_agent for task_id auto-injection. That
helper filters to _DEV_ACTIVE_STATUSES = {claimed, in_progress,
verifying, awaiting_qa, awaiting_documentation}. BLOCKED, PAUSED, and
NEEDS_REVISION fall outside that set — so the moment an agent gets
stuck (which is exactly when they journal), auto-injection returns None
and the entry persists without task_id.
Fix:
- New TaskService.get_journal_context_task_for_agent — same shape as
get_active_task_for_agent but the status set
_JOURNAL_CONTEXT_STATUSES adds BLOCKED, PAUSED, NEEDS_REVISION.
- ContentActions.note/say/dm/notify use the new lookup.
- ContentActions.commit keeps the narrow get_active_task_for_agent —
can't commit from blocked, so the dev-active set is correct there.
Tests:
- tests/unit/services/test_journal_context_lookup.py — 5 tests pinning
the two queries: journal-context INCLUDES blocked/paused/needs_revision,
dev-active EXCLUDES them.
- Existing content-actions tests updated to stub the new method
alongside the old one.
This alone may be 70% of what was killing smoke runs end-to-end.
Smoke run 4 crashed with AttributeError: 'SessionForTasksCreateRequest'
object has no attribute 'config' when main-pm called open_session.
The service _build_session_request reads req.config.max_message_count
(model has nested config). The gateway was passing the API schema
SessionForTasksCreateRequest (flat fields, no config attribute at all)
— so `req.config` blew up with AttributeError, not the safer None.
Fix: gateway now constructs SessionForTasksCreate (the model) with the
enum-typed relationship_type, matching what the route at
roboco/api/routes/sessions.py:230 does. Unknown relationship strings
fall back to DISCUSSION.
Two regression tests pin the contract: service receives the model;
invalid relationship_type defaults to DISCUSSION.
Pure whitespace / line-wrap reformats accumulated when ruff format
ran during earlier waves but weren't included in their commits. No
semantic changes — collection literals reflowed, with-statement
context managers regrouped via PEP 617 parens.
The inner closure inside BaseIndexPlugin.ingest() packed chunk
filtering, metadata merge, embedding, and aborted-transaction retry
into one block (CCN 13). Xenon's --max-absolute B ignores closures
(only top-level callables count), but radon flagged it as the last
rank-C block. Now zero rank-C blocks in the whole codebase.
Extracted:
- _filter_quality_chunks(raw_chunks) — module helper for the
tiny / mostly-markdown chunk filter
- _reset_store_connection(store) — module helper for piragi's
force-close + _init_schema after an aborted transaction
- _chunk_filter_embed_store(doc, metadata) — method that orchestrates
chunk → filter → embed → store (called via asyncio.to_thread)
- _store_with_transaction_retry(store, chunks, count) — method with
the retry loop, using early-return guard instead of nested ifs
ingest() now does: await asyncio.to_thread(self._chunk_filter_embed_store, ...).
No more nonlocal capture; chunk_count flows back through the return value.
Lift terminal-status and spine-task-type sets to class constants. Pull each
rule's rejection envelope into its own builder (_spine_type_dup_envelope,
_same_assignee_dup_envelope) and combine them under _sibling_dup_envelope.
The guard body is now a flat scan: skip terminal siblings, delegate the
per-sibling verdict to the helper, return the first non-None envelope.
Lift the rich-plan field list to a class constant (_RICH_PLAN_FIELDS) and
extract _resolve_effective_plan — the any()-over-5-keys decision between
the raw string plan and the panel-shaped dict. The verb body keeps its
spec-gate / re-entry / sub_tasks-gate sequence but no longer carries the
panel-shape branch.
Extract _build_struggle_body (the reason + optional Blocker Type / What Needed
markdown assembly) and _run_i_am_blocked_intent (the verb-runner dispatch +
try/except → rejection envelope). The verb body keeps its setup / spec-gate
shape but the structured-body branching and runner-exception branching no
longer count against it.
Extract four helpers: _extract_first_commit_sha (dict/model-tolerant sha read),
_already_addressed_criteria (set comprehension over existing status),
_find_existing_entry (preserved-entry lookup) and _new_criterion_entry (build
one fresh row). The main function is now a flat sequence: early-return on
empty criteria, early-return when all already addressed, then one loop with
two cases that each delegate to a helper.
Extract each conditional -v/-e block into a focused helper:
_append_claude_json_mount (claude.json file mount), _append_optional_host_mounts
(settings + briefing), _core_volume_and_env_args (the always-on block),
_append_provider_env (Anthropic-base/token), _append_manifest_args (spawn
manifest + gateway flag), _append_workspace_cwd (role-based -w). Two role
membership sets are lifted to class constants. The top-level function is
now a flat sequence of calls — no nested conditionals.
Extract the per-cycle XREADGROUP + dispatch into _listen_tick and the NOGROUP
self-heal branch into _handle_response_error. The outer loop is now a flat
while/try/except sequence: cancel breaks, response-error delegates the
recover-or-sleep decision to the helper, generic exceptions sleep. No
behavior change; the two new helpers preserve identical log messages and
ordering.
Lift the two scope-required field tables to module-level constants and route
through a shared _collect_required helper. The options-specific minimum-count
check and the generic scalar-empty check are each a one-line predicate
(_options_field_missing / _scalar_field_missing). The outer function is now a
dict lookup plus one call.
Lift the scope→sections lookup into a module-level dict (_SCOPE_SECTIONS) and
extract per-value rendering (options list / generic list / scalar) into
_render_section_value. The outer loop is now a flat dispatch with one early
continue per branch; the chained ternary and the list/dict branch ladder
that drove the CCN to 16 are gone.
TodoWrite is Anthropic's private session-local scratchpad — agents use
it to track their own immediate next steps. It does NOT surface to the
panel's Progress tab and is NOT a substitute for
progress(task_id, message, percentage). Smoke run 3 didn't show this
conflation yet, but Wave D's new progress() directive risks it.
- base.md gets the canonical "TodoWrite vs progress()" callout
- developer.md + documenter.md (the two roles with progress()) get
inline reminders in their verb tables: "NOT TodoWrite"
Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section E4.
Smoke run 3 showed agents loading builtin Anthropic connectors
(mcp__claude_ai_Gmail__authenticate, Google Calendar, Notion, Drive)
alongside our 5 roboco MCP servers. The connectors bloat the tool
surface and give the LLM 'discover' targets it shouldn't have.
The Claude Code CLI's --strict-mcp-config flag tells it to load ONLY
the servers from --mcp-config, ignoring all builtin defaults. Added
to _append_image_and_claude_args next to --mcp-config.
Note: the existing --tools allowlist (Read,Write,Edit,Bash,Grep,Glob,
Task,TodoWrite) only filters builtin tools, not MCP-prefixed ones —
that's why the connectors slipped through.
Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md
section E3.
Pin the SQLAlchemy enum-naming invariant: every column typed Role
(aliased as AgentRole) MUST bind to the postgres enum named
'agentrole', not 'role'. Wave A5's smoke regression came from the
inferred 'role' type colliding with migration 001's 'agentrole'.
The fix already shipped (roboco/db/tables.py::_PG_ENUM_NAME_OVERRIDES);
this test locks it in. Two assertions: agents.role.udt_name == agentrole;
no stray 'role' enum type exists in pg_type.
QA / Documenter / Board don't have i_am_blocked in their manifests. The
D1 snippet's "escalate via i_am_blocked" line is now role-correct:
- QA / Documenter: unclaim(task_id) + dm(cell-pm, ...) with rejection
- Board: dm(ceo, ...) for PO/HoM; Auditor uses note(scope='reflect', ...)
All role prompts now mention channels() as the way to list valid
channel slugs. Smoke run 3 showed agents inventing slugs ('backend-dev',
'backend') and getting Channel not found. The channels() verb was
added in Wave 2 G6 but unused — making the directive explicit in
every prompt.
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.
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.
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.
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.
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.
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.
_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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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).
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).