| name | sdlc |
| description | Run an egg SDLC pipeline: full lifecycle (default) or lightweight coder+reviewer with --short. |
| disable-model-invocation | true |
| argument-hint | [--short] [--qualifier <name>] [JIRA-1234 or issue# or description] [--repo owner/name] |
| allowed-tools | Monitor TaskStop Bash(${CLAUDE_SKILL_DIR}/bin/wait-status:*) Bash(gh issue view:*) Bash(gh issue list:*) Bash(gh pr list:*) Bash(gh pr view:*) Bash(git remote:*) Bash(git -C * remote:*) AskUserQuestion mcp__egg__submit_task mcp__egg__get_status mcp__egg__provide_input mcp__egg__answer_feedback mcp__egg__list_tasks mcp__egg__cancel_task mcp__egg__check_health mcp__egg__list_containers mcp__egg__get_container_logs mcp__egg__send_message mcp__egg__get_consensus_status mcp__egg__get_phase mcp__egg__get_pipeline_snapshot mcp__egg__get_contract |
SDLC Pipeline
You are guiding the user through an egg SDLC pipeline using MCP tools.
Argument Parsing (before any phase)
Parse the arguments provided after /sdlc. Check for the --short flag first:
- If
--short is present, remove it from the arguments and branch into the Short Flow below.
- Otherwise, continue with the Full Flow (default) — walk through 6 phases: Seed, Pre-Refine, Submit, Monitor, HITL, and Complete.
JIRA Ticket Detection
Any argument matching the pattern <LETTER><ALPHANUMERIC>-<DIGITS> (e.g., PROJ-1234, ENG-42, PLAT-999) is a JIRA ticket identifier. This applies to both the Full Flow and Short Flow. When detected:
- The ticket ID is extracted and stored as
jira_ticket_id
- JIRA and Confluence context is fetched automatically (see JIRA & Confluence Context Gathering below)
- The ticket summary becomes the task description, enriched with JIRA context
The regex pattern for detection: ^[A-Z][A-Z0-9]+-\d+$ (case-insensitive match, then uppercase for API calls).
Full Flow
The full pipeline lifecycle with HITL gates, multi-phase execution, and comprehensive monitoring. Phases: Seed → Pre-Refine → Submit → Monitor → HITL → Complete.
Phase 1 — Seed
Collect the repository, task description, and optionally a GitHub issue number. Your goal is zero questions on the happy path and at most one question to get started otherwise (the "Browse recent" flow may need a second to present the issue list).
Step 1: Auto-detect the repository (NEVER ask if detectable)
Before asking the user anything, try to detect the repo automatically:
- Run
git remote get-url origin 2>/dev/null (or git remote -v) in the working directory to detect the target repo to operate on — the repo the pipeline will act on, which is distinct from the egg checkout that hosts the orchestrator
- Parse the
owner/name from the URL (e.g. https://github.com/jwbron/egg.git → jwbron/egg)
- If a
--repo flag was passed, use that instead
Only ask for the repo if detection fails AND no --repo flag was provided.
Step 2: Parse arguments (skip questions when possible)
If the user provided arguments after /sdlc, parse them:
| Input | Interpretation |
|---|
/sdlc 1059 | Issue number (bare integer) |
/sdlc #1059 | Issue number (with hash) |
/sdlc PROJ-1234 | JIRA ticket (matches <LETTER><ALPHANUMERIC>-<DIGITS> pattern) |
/sdlc Add retry logic for API calls | Free-text task description |
/sdlc --repo owner/repo 1059 | Repo override + issue number |
/sdlc --issue 1059 | Issue number (legacy flag, same as bare integer) |
/sdlc --repo owner/repo PROJ-1234 | Repo override + JIRA ticket |
/sdlc PROJ-1234 --qualifier backend | JIRA ticket + qualifier (pipeline: PROJ-1234-backend, branch: egg/PROJ-1234-backend) |
/sdlc 1059 --qualifier frontend | Issue number + qualifier (pipeline: issue-1059-frontend, branch: egg/issue-1059-frontend) |
When --qualifier <name> is provided, it is appended to the pipeline ID and branch name. This allows multiple pipelines for the same ticket or issue. Store the qualifier value as pipeline_qualifier for use in Phase 2 (Submit).
When an issue number is provided, fetch it immediately with gh issue view <N> --repo <repo> --json title,body,comments,labels,assignees and use the title+body as the task description. Proceed directly to Phase 1.5 (Pre-Refine) — no questions needed. Retain the full response (including comments, labels, and assignees) for use in Phase 1.5.
When a JIRA ticket ID is provided (matches ^[A-Z][A-Z0-9]+-\d+$ case-insensitive), run the JIRA & Confluence Context Gathering procedure. Use the ticket summary as the task description, enriched with the gathered context. Proceed directly to Phase 1.5 (Pre-Refine) — no questions needed.
When a free-text description is provided and the repo was auto-detected, proceed directly to Phase 1.5 (Pre-Refine).
Step 3: Ask only what's missing
If the user ran /sdlc with no arguments, ask a single AskUserQuestion:
- Question: "What should the pipeline work on? Type an issue number, JIRA ticket (e.g. PROJ-1234), or task description below, or browse recent issues."
- Header: "Task"
- Options:
- "Browse recent issues" — description: "List recent open issues to pick from"
- "Help me scope the task" — description: "Ask clarifying questions about requirements before submitting"
The user will select an option or type in the auto-added "Other" field.
Handle each response:
- Other (matches
<LETTER><ALPHANUMERIC>-<DIGITS>) → Treat as a JIRA ticket ID. Run JIRA & Confluence Context Gathering and proceed to Phase 1.5 (Pre-Refine).
- Other (integer) → Treat as an issue number. Fetch with
gh issue view <N> --repo <repo> --json title,body,comments,labels,assignees and proceed to Phase 1.5 (Pre-Refine).
- Other (text) → Treat as a free-text task description. Proceed to Phase 1.5 (Pre-Refine).
- Browse recent issues → Run
gh issue list --repo <repo> --state open --limit 10 --json number,title and present the results as a second AskUserQuestion with each issue as an option. Once the user selects an issue, fetch it with gh issue view <N> --repo <repo> --json title,body,comments,labels,assignees and use the title+body as the task description. Then proceed to Phase 1.5 (Pre-Refine).
- Help me scope the task → Ask 1–2 follow-up questions about scope and acceptance criteria. Synthesize the user's answers into a refined task description (incorporating scope boundaries and acceptance criteria) before proceeding to Phase 1.5 (Pre-Refine).
Never ask for the repo and the task in separate questions. If the repo could not be auto-detected, include a repo question in the same AskUserQuestion call (multi-question mode).
JIRA & Confluence Context Gathering
When a JIRA ticket ID is detected (e.g., PROJ-1234), gather context from JIRA and Confluence before proceeding. This runs automatically — no user interaction needed.
Step 1: Fetch the JIRA ticket
Fetch the ticket via the JIRA REST API:
curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \
"$JIRA_BASE_URL/rest/api/3/issue/<TICKET_ID>?expand=renderedFields" \
2>/dev/null
Extract from the response:
fields.summary — ticket title
fields.description (or renderedFields.description) — full description
fields.status.name — current status
fields.priority.name — priority
fields.labels — labels
fields.components — components
fields.assignee.displayName — assignee
fields.comment.comments — comments (last 10)
fields.issuelinks — linked issues (blockers, relates-to, etc.)
fields.subtasks — subtasks if any
fields.parent — parent epic/story if this is a subtask
Fallback — If the API fails (e.g., no credentials configured, private mode), inform the user:
Could not fetch JIRA ticket <TICKET_ID>. JIRA credentials may not be configured.
Proceeding with the ticket ID as the task description.
Use the raw ticket ID as the task description and continue — do not block the pipeline.
Step 2: Search for related Confluence documentation
Use the JIRA ticket's project key, summary, and labels to find relevant Confluence docs:
Search via the Confluence REST API:
curl -s -u "$CONFLUENCE_USERNAME:$CONFLUENCE_API_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=text~\"<TICKET_ID>\" OR text~\"<key terms from summary>\"&limit=5" \
2>/dev/null
For each matching page, fetch its body:
curl -s -u "$CONFLUENCE_USERNAME:$CONFLUENCE_API_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/<page_id>?expand=body.storage" \
2>/dev/null
Fallback — If Confluence is unavailable, skip silently. Confluence context is supplementary, not required.
Step 3: Build enriched task description
Compose the task description from the gathered context:
## JIRA Ticket: <TICKET_ID>
**Summary**: <ticket summary>
**Status**: <status> | **Priority**: <priority>
**Labels**: <labels> | **Components**: <components>
**Assignee**: <assignee>
### Description
<ticket description — rendered as markdown>
### Key Comments
<last 3-5 substantive comments, with author and date>
### Linked Issues
<linked issues with relationship type, key, summary, and status>
## Confluence Context
<relevant Confluence page excerpts, if found — include page title and a concise summary of each>
This enriched description replaces the raw ticket ID as the task description for all downstream phases.
Step 4: Determine the repository (if not already known)
If --repo was not provided and the repo was not auto-detected, try to infer it from the JIRA ticket:
- Check the ticket's
components or labels for a repo name
- Check if the project key maps to a known repo (e.g., project metadata or custom fields)
- If still unknown, ask the user via
AskUserQuestion
Phase 1.5 — Pre-Refine
Why "1.5"? Phases 2–5 are referenced throughout this document, the orchestrator, and external docs. Renumbering them would cascade across many files for no functional benefit. "1.5" signals that this phase was inserted between Seed and Submit without breaking existing phase references.
A quick local triage pass to ensure the task description is clear and complete before submitting to the remote refiner. This is NOT a full code analysis (the remote refiner handles that) — it's a lightweight check focused on task clarity, scope, and acceptance criteria.
Step 1: Review issue context (if available)
If an issue number was provided, use the data already fetched in Phase 1 (which includes title,body,comments,labels,assignees). Do not re-fetch the issue.
If a JIRA ticket was provided, the enriched description from JIRA & Confluence Context Gathering is already available. Use the JIRA ticket's linked issues, comments, and Confluence context to inform the code scan in Step 2. Do not re-fetch the ticket.
Note any linked PRs or referenced issues mentioned in the body or comments — these provide useful context for the refiner.
Step 2: Quick code scan
Based on the task description, do a lightweight search (2–3 Glob + Grep queries) to identify the general area of the codebase affected. This is just enough to check feasibility and ask informed questions — NOT the full analysis the short flow's S2 phase does.
Examples:
- If the task mentions "health checks", search for health-related files
- If the task mentions a specific component, confirm it exists and note its location
- If the task mentions an API endpoint, find the route definition
Step 3: Evaluate task clarity
Skip this step if the task came through Phase 1's "Help me scope the task" path — scope and clarity were already evaluated there.
Check the task description for:
- Clear problem statement — Is it clear what's wrong or what's needed?
- Defined scope — Is it clear what should change and what shouldn't?
- Acceptance criteria — How will we know it's done? Are there success conditions?
- Ambiguous terms — Are there vague phrases like "improve performance", "clean up", or "fix the issue" without specifics?
Step 4: Ask clarifying questions (if needed)
Skip this step if the task came through Phase 1's "Help me scope the task" path, or if the task is already well-defined with clear goals and scope.
If the task is ambiguous or missing key information, present 1–3 targeted questions via a single AskUserQuestion call. Examples:
- "The issue mentions 'improve performance' — what specific metric or threshold?"
- "Should this change be backwards-compatible with the existing API?"
- "The issue references both X and Y — should both be addressed in this pipeline?"
Step 5: Present summary and confirm (conditional)
Auto-proceed: If (a) Step 3 evaluated clarity as "Good" and Step 4 was skipped (no clarification was needed), OR (b) Steps 3 and 4 were both skipped because the task came through Phase 1's "Help me scope the task" path, skip the confirmation dialog and proceed directly to Step 6 → Phase 2. There is no value in prompting the user when nothing was surfaced or when scoping was already completed.
Otherwise, show a brief pre-refine summary:
### Pre-Refine Summary
**Task**: <1-sentence summary>
**Scope**: <general area — e.g., "orchestrator health checks", "gateway auth middleware">
**Clarity**: Good / Needs clarification
**Notes**: <any context added from clarification, or "None">
Then use AskUserQuestion to confirm:
- Question: "Ready to submit to the refiner?"
- Header: "Pre-Refine"
- Options:
- "Submit" — description: "Proceed to submit the task to the remote refiner"
- "Add more context" — description: "Provide additional context to append to the description"
- "Skip pre-refine" — description: "Proceed directly with the original description unchanged"
Handle each response:
- Submit → Proceed to Step 6, then Phase 2 (Submit) with the enriched description.
- Add more context → Collect the user's additional context via a follow-up question, then proceed to Step 6.
- Skip pre-refine → Proceed to Phase 2 with the original description unchanged (skip Step 6).
Step 6: Enrich description and transition to Phase 2
This is the single exit point from Phase 1.5 (except for "Skip pre-refine" which bypasses directly to Phase 2). If clarifications were collected in Steps 4 or 5, append them to the task description as an ## Additional Context section before submission. This gives the remote refiner the benefit of the user's answers without requiring another HITL round.
<original task description>
## Additional Context
<clarifications and additional context collected during pre-refine>
If no clarifications were needed (task was already clear), pass the description through unchanged. For the "Help me scope" path specifically, scoping answers are already incorporated into the task description during Phase 1 synthesis — no additional appending is needed here. Then proceed to Phase 2.
Phase 2 — Submit
Call the submit_task MCP tool with the gathered parameters:
Tool: submit_task
Arguments:
description: <task description — enriched with JIRA/Confluence context if a JIRA ticket was provided>
repo: <owner/name>
issue_number: <number, if provided>
jira_ticket: <TICKET_ID, if source is a JIRA ticket>
qualifier: <qualifier, if --qualifier was provided>
When a JIRA ticket was the source, the description field should contain the full enriched description built in JIRA & Confluence Context Gathering Step 3 (including the JIRA ticket details, comments, linked issues, and any Confluence context). This ensures the pipeline agents have full context without needing JIRA access themselves.
The jira_ticket field drives pipeline naming: the pipeline ID and branch are derived from the ticket ID (e.g., PROJ-1234 → pipeline PROJ-1234, branch egg/PROJ-1234). When a qualifier is provided, it is appended (e.g., PROJ-1234-backend / egg/PROJ-1234-backend). The same qualifier logic applies to issue-driven pipelines (e.g., issue-123-backend / egg/issue-123-backend).
Branch conflict handling
If submit_task returns a 409 error indicating the branch already exists on the remote:
- Inform the user: "Branch
egg/<name> already exists. A qualifier is needed to create a separate pipeline."
- Ask the user to provide a qualifier via
AskUserQuestion:
- Question: "Branch
egg/<name> already exists. Provide a qualifier to differentiate this pipeline (e.g. 'backend', 'v2', 'fix'):"
- Header: "Qualifier"
- Options: 2-3 contextual suggestions based on the task description + "Other" (always available)
- Retry
submit_task with the qualifier appended.
Store the returned task_id. Confirm submission to the user:
Task submitted successfully.
Task ID: <task_id>
Source: JIRA <TICKET_ID> (or GitHub Issue #<N>, or free-text)
Pipeline: <pipeline_id> | Branch: <branch>
Description: <description summary — first line of the enriched description>
Repository:
Phase 3 — Monitor
Drive the pipeline through one Monitor invocation per quiet stretch. On entry:
-
First poll — call the get_status(task_id) MCP tool to render the initial dashboard. get_status returns the full snapshot. It does not return a cursor; the cursor is produced by wait-status only. Cache the snapshot in conversation context as last_status. Initialize last_cursor = "" (empty — the first wait-status call snaps to the tip of both event sources).
-
Blocking wait — invoke ${CLAUDE_SKILL_DIR}/bin/wait-status through the Monitor tool, not Bash. The Monitor tool delivers each stdout line as a separate notification, so the LLM wakes on every emitted event in real time — exactly what the JSON-line streaming model assumes. The launcher is self-contained (pure stdlib, no egg checkout) and resolves from any working directory via ${CLAUDE_SKILL_DIR}. The block below is pseudocode for the Monitor tool input — the actual call uses the Monitor tool's JSON parameter shape (description, command, timeout_ms, persistent):
# pseudocode — see Monitor tool for the JSON parameter shape
Monitor(
description: "wait-status <task_id>",
command: "${CLAUDE_SKILL_DIR}/bin/wait-status <task_id> --since \"<last_cursor>\"",
timeout_ms: 3600000, # ignored while persistent: true — kept for reference
persistent: true,
)
persistent: true is required. SDLC pipelines routinely run for multiple hours (multi-slice cleanups, deep refines, HITL gates between phases), exceeding the Monitor's timeout_ms cap (1h max per the schema). With persistent: true, timeout_ms is ignored and the Monitor lives for the session — only exiting on TaskStop, a CLI exit code, or the auto-stopped guard. Without it, every event after the first hour drops silently (#2801).
Keep the escaped quotes around <last_cursor> — the cursor is shaped msg:<id>|evt:<seq> and the literal | is shell-significant; without quoting the shell would treat it as a pipe.
Cache the Monitor's task_id as monitor_task_id in conversation context (returned in the Monitor tool's invocation response). You'll need it to call TaskStop before re-arming a new Monitor after HITL (see HITL-driven re-arms below) — wait-status does not exit on decision.created, so without an explicit stop the prior CLI keeps polling in parallel and double-emits the next event.
The launcher is a self-contained stdlib client — no .venv, no PYTHONPATH, no egg checkout — that loops the orchestrator's /status/wait route server-side, threading the cursor between calls. It needs only a reachable orchestrator: set EGG_ORCHESTRATOR_URL if it isn't at the default http://localhost:9849 (note: localhost is reachable from the host shell but not from inside the Claude Code Bash sandbox — disable the sandbox or point at a host-reachable address when driving from there). Stdout is JSON-lines — one line per pipeline-relevant event, surfaced to the LLM as one notification per line. The CLI is silent on no_change, so the LLM only wakes when something happened. Exit codes (Monitor reports them as the watch's exit code):
| Exit code | Meaning | Skill action |
|---|
0 | Pipeline reached terminal state (complete / failed / cancelled) | Exit the monitor loop, move to Phase 5 |
2 | Transient error budget exceeded after backoff | Re-invoke Monitor with the same last_cursor |
3 | Permanent error (4xx, malformed cursor, unknown pipeline) | Surface stderr to user; do NOT silently retry |
| (auto-stopped) | Monitor stopped on its own with a high-volume notice (busy implement-phase BRC bursts can trip this) | Re-invoke Monitor with the latest last_cursor |
(No timeout row — persistent: true disables the Monitor timeout. See #2801.)
Re-invocation rule — when to TaskStop first. The cases in the table above (auto-stopped, exit-code 2) all leave the prior CLI already terminated, so re-invoking Monitor is safe — nothing else is polling /status/wait for this task_id. The other re-arm case is HITL-driven: you're returning from Phase 4 after a provide_input submission and the prior Monitor is still alive (decision.resolved is excluded from the trigger allowlist, so it didn't self-wake). Before re-invoking Monitor in that case, call TaskStop on the prior Monitor's task ID first. Two live Monitors for the same task_id each advance their own evt cursor independently against /status/wait, and the next pipeline event causes both to emit the same JSON-line — producing duplicate per-event notifications to the LLM (#2613). If TaskStop itself fails (rare), proceed with the re-invocation but surface the failure to the user so they can stop the prior task manually — duplicate notifications are noisy but recoverable.
Why Monitor and not Bash? wait-status is designed to emit one JSON-line per event over the lifetime of a single CLI invocation. Foreground Bash blocks the LLM until the command exits and batches all events emitted in that window into one wake — so a decision.created that lands 30 seconds in won't be visible until the next event flushes the buffer. Background Bash sends a single completion notification when the whole CLI exits and forces file-polling for stdout. Monitor's per-line notification semantics match the streaming-stdout contract directly.
Bash fallback: If Monitor is unavailable in the harness, fall back to a foreground Bash invocation (${CLAUDE_SKILL_DIR}/bin/wait-status <task_id> --since "<last_cursor>") — but be aware that events emitted within a single 10-minute Bash window will be batched at exit, not surfaced as they arrive. On Bash-cap timeout, re-invoke with the latest last_cursor from the batched output.
-
Read each emitted JSON line as it arrives. The line shape is:
{
"trigger": "event",
"event_type": "phase.started",
"cursor": "msg:1738012734-0|evt:142",
"current_phase": "plan",
"status": "running",
"phase_elapsed_seconds": 127,
"concurrent": { "consensus": { ... } }
}
For trigger: "message" the line carries messages: [...] instead of event_type. Update last_cursor from each line's cursor field. The cursor is opaque (shape msg:<id>|evt:<seq>) — treat it as a string and thread it through --since on the next Monitor invocation.
Trigger allowlist: OVERSEER_ALERT, CONSENSUS_CONFIRMED, CONSENSUS_NACK, CONSENSUS_RE_REVIEW, phase.started, phase.completed, pipeline.completed, pipeline.failed, pipeline.cancelled, decision.created. decision.resolved is deliberately excluded so the host doesn't self-wake on a provide_input it just submitted.
-
Render the dashboard on each line. There are two render paths — pick based on whether the line carries concurrent.consensus:
Path A — non-BRC line (no concurrent.consensus): the 3-line compact form.
--- Pipeline Status ---
Phase: <current_phase> | Status: <status> | Elapsed: <phase_elapsed_seconds>s
Recent: <event_type or first messages[] entry>
For Path A only, you may render deltas-only on subsequent emits (skip lines that haven't changed) to keep the output concise.
Path B — BRC line (concurrent.consensus is present): a per-role status table. See Consensus Monitoring for column derivation. Always render the full table on every emit — the table is the operator's at-a-glance scan, so partial renders defeat the point.
Phase: <current_phase> | Status: <status> | Elapsed: <phase_elapsed_seconds>s | Consensus: <N>/<total> | NACKs: <K>
| Role | Phase | Confirmed | Latest activity |
|-------------------------|----------------------|-----------|----------------------------------------------|
| coder | PROPOSED | ✓ | re-proposed at 19:03:40, accepted |
| documenter | PROPOSED | ✓ | no-op attestation (slice-1 is code-only) |
| tester | WORKING / REVIEWING | | writing TASK-1-2 tests against coder's diff |
| reviewer_code | WORKING | | reviewing |
| reviewer_security | CONFIRMED | ✓ | ACK at 19:05:41 |
⚠️ reviewer_concurrency → coder: "missing lock around _producer_phases" (only when unresolved_nacks is non-empty)
⚠️ reviewer_contract: silent for ~12m — no BRC messages (only when a silent agent is detected)
The header values come straight from the JSON-line: current_phase, status, phase_elapsed_seconds. Consensus: <N>/<total> counts agents with confirmed: true over len(agents); NACKs: <K> is len(unresolved_nacks).
Use the server-computed phase_elapsed_seconds from the line. The line carries only the dashboard-relevant subset (current_phase, status, phase_elapsed_seconds, concurrent.consensus) — it does not include the full snapshot (running_agents, completed_agents, recent_messages, pipeline metadata, pending_decisions). When you need the full envelope — for example to enrich an OVERSEER_ALERT with recent_messages, or to render pending_decisions ahead of HITL on a decision.created line — call get_status(task_id) again as a one-shot snapshot and refresh last_status.
-
Check for overseer alerts on each trigger: "message" line where any entry's type is OVERSEER_ALERT — see Overseer Alert Detection below.
-
Check consensus health on each line carrying concurrent.consensus — see Consensus Monitoring below. The wait-status JSON-line ships concurrent.consensus whenever the route saw it, so consensus drift never goes invisible during quiet phases on BRC pipelines.
-
State transitions:
- On
event_type: "decision.created" → re-fetch the full snapshot via get_status(task_id) (the JSON-line does not carry pending_decisions) and move to Phase 4 (HITL).
- On
status: "complete" or event_type: "pipeline.completed" → exit the monitor loop and move to Phase 5.
- On
status: "failed" or event_type: "pipeline.failed" → apply the failed status grace period (see below) before exiting.
-
Track elapsed time using each line's phase_elapsed_seconds (server-computed). Fall back to local wall-clock only when this field is absent (phase boundaries, pending phases). Used for Long-Running Phase Detection.
Important: wait-status blocks server-side and emits events as they arrive. Do NOT wrap the Monitor invocation in an outer for-loop or sleep — the CLI is already the loop, server-side, and Monitor surfaces each emitted line as its own notification. The skill's liveness guarantee comes from the CLI re-issuing the route call with the threaded cursor on every Path-B no-change return; intra-process loop, no LLM turn. Because the Monitor runs with persistent: true, it does not time out — the CLI runs for the session unless it self-exits or you call TaskStop. On a re-armable self-exit (exit code 2 or auto-stopped — see the exit-code table above), re-invoke with the latest last_cursor from your conversation context; the prior CLI is already dead, no TaskStop needed. Exit code 0 is terminal (move to Phase 5); exit code 3 is permanent (surface stderr, do not silently retry). On the Bash fallback path, the 10-min Bash cap still applies; re-invoke the same way when the cap forces the CLI to terminate. HITL-driven re-arms are different — the prior Monitor is still alive — so call TaskStop(task_id=monitor_task_id) first; see HITL-driven re-arms below (Monitor only — on the Bash fallback the prior CLI already exited when the decision.created line surfaced; see the re-invocation rule under the exit-code table above). The overseer is the primary deadlock detector and emits OVERSEER_ALERT on stalls, which is in the trigger allowlist. See Host-Side Waits for the full event allowlist, exit-code contract, and concurrency model.
HITL-driven re-arms: stop the prior Monitor first
The exit-code re-arms above (exit code 2, auto-stopped) are safe to re-invoke without ceremony — the prior CLI has already terminated. HITL-driven re-arms are different. When wait-status emits decision.created, the CLI does not exit — it just yields the JSON line and keeps polling for the next allowed trigger (decision.resolved is deliberately off the allowlist, so submitting provide_input doesn't wake or terminate it either). If you re-invoke Monitor after HITL without first stopping the prior one, two wait-status processes will poll the same task_id concurrently, each advancing its own in-process cursor — and both will emit the next allowed event, producing duplicate notifications to the LLM.
Rule: before re-arming Monitor after handling HITL, call TaskStop(task_id=monitor_task_id) on the cached id from step 2, then start the new Monitor and overwrite monitor_task_id with the new id. If TaskStop itself fails (rare), proceed with the re-invocation but surface the failure to the user so they can stop the prior task manually — duplicate notifications are noisy but recoverable.
Failed Status Grace Period
During phase cycle transitions (e.g., plan phase review cycles), the orchestrator may briefly report status: failed while spawning new containers. Treating this as terminal prematurely ends monitoring.
Before treating failed as terminal, apply these checks:
- If
status is failed but running_agents is non-empty → treat as "transitioning", not failed. Log: "Status shows failed but agents still running — treating as cycle transition." Continue polling.
- If
status is failed and running_agents is empty → call get_pipeline_snapshot MCP tool with the task_id to confirm actual state before exiting. If the snapshot shows active containers or recent messages, continue polling.
- Only exit to Phase 5 when
status is failed, running_agents is empty, and the secondary check confirms the pipeline is genuinely stopped.
Post-Consensus Reviewer Behavior
After BRC consensus completes in a phase, the orchestrator may spawn a post-consensus reviewer for a final review pass. If this reviewer requests changes, it triggers a new review cycle (new containers are spawned). This is a known pattern — track it as a cycle transition, not a failure. To detect this, compare the running_agents count between consecutive polls — if new agents appear after consensus was complete, a post-consensus review cycle has started. Update the dashboard:
Note: Post-consensus review triggered — new review cycle started.
Overseer Alert Detection
When the pipeline has an overseer agent enabled, it broadcasts OVERSEER_ALERT messages to the message bus whenever it detects an anomaly. These appear in recent_messages with type: "OVERSEER_ALERT" and from_role: "overseer".
On each poll cycle, scan recent_messages for entries with type: "OVERSEER_ALERT". When found:
- Display the alert prominently:
### Overseer Alert
**<subject>**
<body — full text>
- Use
AskUserQuestion to let the user decide next steps:
- Question: "The overseer detected an anomaly: ''. How would you like to proceed?"
- Header: "Alert"
- Options:
- "Check agent logs" — description: "View recent logs for the affected agent"
- "Acknowledge" — description: "Note the alert and continue monitoring"
- "Cancel pipeline" — description: "Stop the pipeline if the issue is critical"
Handle each response:
- Check agent logs → Extract the agent role from the alert subject (format:
<anomaly_type>: <agent_role> [<priority>]). Call the get_container_logs MCP tool with task_id and agent_role. Show the output and let the user decide next steps.
- Acknowledge → Resume monitoring. Track acknowledged alerts to avoid re-prompting for the same alert.
- Cancel pipeline → Confirm with the user, then call
cancel_task with task_id and cleanup: true.
Before offering the generic options above, if the alert subject is stuck-phase-transition (or its body otherwise says a HITL gate is awaiting operator input / names an unanswered feedback-N / Q<n> or an unresolved cq-N), first check for unanswered contract decisions or feedback — either an unresolved cq-N HITL decision (see Answering pre-proposal contract HITL decisions) or an unanswered feedback-N (see Answering pre-proposal contract feedback) — that is usually the actionable resolution, and neither "Check agent logs" nor "Acknowledge" will clear it.
Deduplication — Maintain a set of seen alert message id values (UUIDs from the Message model) across poll cycles. Only prompt the user for alerts not previously seen or acknowledged. Do not use subject strings for deduplication — distinct alerts may share the same anomaly type, role, and priority.
Answering pre-proposal contract feedback
An agent can register an open-ended feedback request on the SDLC contract before it produces any draft — most commonly a refiner asking the operator to supply a goal/success criteria when the contract is empty (free-text / Confluence / no-issue submissions). This pre-proposal feedback is written to the contract as feedback-N and the agent then blocks waiting for the answer, so no phase_gate is ever reached.
This feedback does NOT appear in pending_decisions. It only becomes an orchestrator decision after a phase_gate is approved (Wave 2 of two-wave surfacing) — which never happens here because the agent is blocked before the gate. As a result:
- It never shows up in a
get_status snapshot's pending_decisions, so Phase 4's normal HITL flow won't surface it.
provide_input(decision_id="feedback-N", ...) returns HTTP 404 — there is no such orchestrator decision.
- The pipeline deadlocks; the overseer detects this and emits a
stuck-phase-transition OVERSEER_ALERT.
Detection. When a stuck-phase-transition alert fires (or whenever a pipeline sits blocked with an empty pending_decisions), call get_contract(task_id) and inspect the feedback field. If it is non-null with submitted: false, its questions[] are awaiting the operator.
Answer it via answer_feedback, NOT provide_input:
-
Display the questions to the user. Present them with AskUserQuestion, batching up to 4 per call (same as the feedback decision_type handler). For a refiner-on-empty-contract request, the user's answer is the task goal / constraints — give them an "Other" field to type it.
-
Collect answers into a dict keyed by each question's id (e.g. {"Q1": "Add retry logic to the API client", "Q2": "p99 < 200ms"}).
-
Call the answer_feedback MCP tool:
Tool: answer_feedback
Arguments:
task_id: <task_id>
answers: {"Q1": "<answer>", "Q2": "<answer>"}
feedback_id: <contract feedback id, e.g. "feedback-1"> # optional staleness guard
answer_feedback writes the answers into the contract and marks the feedback submitted, so the blocked agent unblocks on its next contract poll and proceeds to produce its proposal. A partial answer set is allowed — the feedback is still marked submitted, so don't leave a question blank unless the user intends to skip it.
-
Resume monitoring (Phase 3). Re-arm the Monitor, stopping the prior one first per HITL-driven re-arms — the agent producing its proposal will emit the next phase.* event.
Answering pre-proposal contract HITL decisions
An agent can register a multiple-choice HITL decision on the contract (id cq-N) before
producing any draft — most commonly a coder or planner blocked on a scope question, or the
impasse-escalation router escalating a stalled agent via mcp__sdlc__register_open_question.
Like feedback-N, these decisions only enter the orchestrator queue after a phase_gate is
approved; an agent blocked pre-proposal never reaches the gate.
Before #3071, provide_input(decision_id="cq-N", ...) returned HTTP 404 and the pipeline
deadlocked. As of #3071, the provide_input tool falls back to the contract and resolves the
decision directly.
Detection. As of #3374, get_status surfaces these directly: unresolved cq-N HITL
decisions that the queue does not yet know about appear in a sibling pending_contract_decisions
list (each entry carries id, question, phase, options, and scope: "contract", plus
type: "hitl" and a note pointing at the provide_input flow). Check it on every snapshot — do
not rely on pending_decisions alone, which only lists queue decisions. As a fallback (or to see
resolved history), call get_contract(task_id) and inspect the decisions array; any entry with
resolved: false is awaiting the operator.
Duplicates. A question already open and unresolved under the same phase is no longer
re-minted as a new cq-N by a re-run agent or a re-escalated impasse — register_open_question
(and the impasse router) dedupe on the normalized question keyed by phase and adopt the existing
decision (#3374). The dedup is phase-scoped: a genuine cross-phase re-ask (a different phase tag)
is a distinct question and does get a fresh cq-N by design. Within one phase you should not
see two cq-N for the same question; if you do, it predates the fix.
Answer it via provide_input:
-
Display the question and options to the user via AskUserQuestion.
-
Call provide_input with the cq-N id and the chosen option label:
Tool: provide_input
Arguments:
task_id: <task_id>
decision_id: <contract decision id, e.g. "cq-1">
response: "<chosen option label>"
-
Resume monitoring (Phase 3). Re-arm the Monitor, stopping the prior one first per
HITL-driven re-arms.
If provide_input returns HTTP 409 with a pointer to a mirror id (e.g. decision-M), the
bridge already promoted cq-N into the queue. Resolve the queue id instead.
For open-ended feedback-N, use answer_feedback instead — see
Answering pre-proposal contract feedback.
Consensus Monitoring
When the pipeline uses concurrent agents (BRC protocol), each wait-status JSON-line and the cached last_status may include a concurrent.consensus object. The CLI ships concurrent.consensus on every emitted line whenever the route saw it, so consensus drift never goes invisible during quiet phases on BRC pipelines. On each emitted line, check this data for red flags and surface problems to the user before they escalate.
Per-role status table (Path B render). When consensus data is present, the dashboard from step 4 is the per-role table — there is one rendering path for BRC, not a separate "consensus block" stacked under the compact form. Column derivation:
| Column | Source |
|---|
| Role | Keys of concurrent.consensus.agents, sorted producers-first then reviewers. Use concurrent.consensus.review_graph.producers for the producer block and review_graph.reviewers for the reviewer block (both already alphabetical in the payload — peer_consensus.evaluate() sorts them in ReviewGraph.to_dict()). Skip any role from the reviewer block that already appeared in the producer block — dual-role agents (tester is the canonical case, present in both lists for the implement graph) render once, in the producer block, with the combined phase per the Phase column rule below. This generalizes across phases — refine has refiner, plan has architect / task_planner / risk_analyst, implement has coder / tester / documenter. Producer/reviewer is decidable from which of producer_phase / reviewer_phase is set on the agent entry; review_graph is the canonical source. |
| Phase | producer_phase for producers, reviewer_phase for reviewers. For dual-role agents (tester is the canonical case — both producer_phase and reviewer_phase set) render <producer_phase> / <reviewer_phase> (e.g. WORKING / REVIEWING). |
| Confirmed | ✓ if agents[role].confirmed is true, blank otherwise. |
| Latest activity | Free-form, derived from the cached last_status.recent_messages combined with any messages[] ferried by a trigger: "message" line — not a fresh get_status per emit. Pick the most recent entry where from_role == role; render its subject (truncated to ~50 chars). Fall back to — when the role hasn't sent any messages this phase. |
Header line (one line above the table):
Phase: <current_phase> | Status: <status> | Elapsed: <phase_elapsed_seconds>s | Consensus: <N>/<total> | NACKs: <K>
<N>/<total> = sum(1 for a in agents.values() if a.confirmed) / len(agents).
<K> = len(unresolved_nacks).
Optional rows below the table:
- Unresolved NACK rows — one per entry in
concurrent.consensus.unresolved_nacks (structured field: {reviewer, producer, reason, version}). Render as ⚠️ <reviewer> → <producer>: "<reason>". This replaces the previous separate NACKs: line.
- Silent agent rows — for any role in
running_agents whose elapsed_seconds exceeds the silent threshold (10+ minutes by default) AND has zero messages in recent_messages, render ⚠️ <role>: silent for ~<N>m — no BRC messages. This is a passive dashboard row only; the overseer owns silent-agent detection and surfaces it as an OVERSEER_ALERT (see Overseer Alert Detection).
The optional rows render only when their condition holds; omit them otherwise.
Consensus Fallback (when concurrent.consensus is missing)
The concurrent.consensus object may not be present in all status responses (e.g., for non-BRC pipelines). When it is absent, fall back to message-based consensus tracking by classifying entries in recent_messages. (The wait-status JSON-line does not ship recent_messages; combine the cached last_status.recent_messages with any messages array ferried by a trigger: "message" JSON-line.):
- Classify messages using the
type field (primary) — each recent_messages entry includes a type field with reliable enum values: CONSENSUS_PROPOSE, CONSENSUS_ACK, CONSENSUS_NACK, CONSENSUS_CONFIRMED. Use these for classification, not subject parsing.
- Identify roles using the
from_role field — each message includes from_role indicating which agent sent it.
- Maintain an in-memory map of
{role: {last_message_type, last_message_time, message_count}} built from recent_messages
- Infer consensus state: if all roles listed in
running_agents have sent CONSENSUS_CONFIRMED messages, consensus is likely complete
- For the per-role table (Path B above), approximate the fields when
concurrent.consensus is missing:
Phase cell: render — for every row. The producer_phase / reviewer_phase source is gone in fallback mode and message types do not give a reliable per-role phase mapping (e.g. a CONSENSUS_PROPOSE from a producer means the producer is in PROPOSED, but says nothing about reviewer phases on its own). — is the safe floor; do not invent a message-type-to-phase mapping.
Confirmed cell: ✓ if the role has emitted a CONSENSUS_CONFIRMED message, blank otherwise
- Header
<N>/<total> confirmed: count of roles with CONSENSUS_CONFIRMED messages
- Optional NACK rows:
CONSENSUS_NACK messages not followed by a CONSENSUS_PROPOSE from the named producer (use subject to extract the reason)
- Use
subject only for supplementary detail (e.g., extracting NACK reasons or human-readable context for the dashboard)
Unresolved NACK (render-on-alert) — The overseer owns stall / silent-agent / unresolved-NACK detection; the host no longer runs its own timers for these. When the host receives an OVERSEER_ALERT whose subject starts incomplete_consensus_stall (the overseer's blocked-consensus / unresolved-NACK emitter — deterministic _check_incomplete_consensus_stall in orchestrator/overseer/monitor/_consensus_stall.py), render the ### Unresolved NACK AskUserQuestion flow below, deriving <reviewer> / <producer> / <reason> from the alert body and from concurrent.consensus.unresolved_nacks rather than from any host-side timer:
### Unresolved NACK
**<reviewer>** NACKed **<producer>**: "<reason>"
The overseer has flagged this as blocking consensus.
Then use AskUserQuestion to offer options:
- Question: "Unresolved NACK from → is blocking consensus. How would you like to proceed?"
- Header: "NACK"
- Options:
- "Check producer logs" — description: "View the producer's recent logs to see if it's working on fixes"
- "Check reviewer logs" — description: "View the reviewer's full reasoning for the NACK"
- "Nudge producer" — description: "Send a message asking the producer to address the NACK and re-propose"
- "Wait longer" — description: "The producer may be working on fixes — give it more time"
Handle each response:
- Check producer logs → Call the
get_container_logs MCP tool with task_id and agent_role set to the producer's role (lines: 50). Show the output and let the user decide next steps.
- Check reviewer logs → Call the
get_container_logs MCP tool with task_id and agent_role set to the reviewer's role (lines: 50). Show the output and let the user decide next steps.
- Nudge producer → Call the
send_message MCP tool with task_id, to_role set to the producer role, message_type: "STATUS", and body: "Overseer check: unresolved NACK from <reviewer> — please address and re-propose." Resume monitoring.
- Wait longer → Resume monitoring; the alert-id dedup in Overseer Alert Detection prevents re-prompting for the same alert.
Long-Running Phase Detection
This proactive early-exit affordance is host-side by design: it fires on phase duration (a healthy but slow phase), not on an anomaly, so the anomaly-driven overseer has no equivalent emitter — it is deliberately retained on the host (issue #3364, cq-4). A follow-up may add a phase-duration detector to the overseer, at which point this can move.
Track elapsed time for each phase using the server-computed phase_elapsed_seconds field from the latest source — emitted on each wait-status JSON-line and on the get_status snapshot. Fall back to wall-clock tracking only when this field is unavailable. When the implement phase has been running for 60+ minutes and consensus appears mostly complete (majority of agents confirmed), proactively offer the user an early exit:
### Long-Running Implement Phase
The implement phase has been running for ~<N> minutes.
Consensus status: <confirmed_count>/<total> agents confirmed.
Then use AskUserQuestion:
- Question: "The implement phase has been running for ~ minutes. Most agents have confirmed consensus. How would you like to proceed?"
- Header: "Long run"
- Options:
- "Keep monitoring" — description: "Continue waiting for full completion"
- "Open PR with current work" — description: "Extract completed work and create a draft PR"
- "Check what's blocking" — description: "Investigate which agents haven't confirmed and why"
Handle each response:
- Keep monitoring → Resume polling. Reset the timer threshold (don't re-alert for another 30 minutes).
- Open PR with current work → Proceed to Stuck Pipeline Rescue.
- Check what's blocking → Call
get_consensus_status and list_containers MCP tools with the task_id, then show blocking agents and their recent logs (via get_container_logs). Let the user decide next steps.
This threshold is configurable — adjust based on task complexity. The 60-minute default balances patience for legitimate long-running work against catching stuck pipelines.
Stuck Pipeline Rescue
This is a user-initiated workflow — the host no longer runs its own stuck-pipeline detection timer. It is invoked either when the user acts on a surfaced post_consensus_stall OVERSEER_ALERT (the overseer's deterministic "consensus complete but phase has not transitioned" emitter — see Overseer Alert Detection) or when the user picks "Open PR with current work" from the Long-Running Phase prompt. Steps 1–3 below stay in the host.
When the user initiates a rescue (from a surfaced post_consensus_stall alert or the "Open PR with current work" option), follow this workflow to extract completed work:
Step 1: Check for committed work on the branch
The branch name can be found in the pipeline block of the cached last_status (returned by get_status — look for branch), or derive it from the pipeline's task description using the egg/<description> naming convention.
git fetch origin
git log --oneline origin/egg/<branch> ^origin/main
If commits exist, the branch has usable work.
Step 2: Check containers for uncommitted work
Call the list_containers MCP tool with the task_id. For each running container with agent work, call get_container_logs with task_id and the container's agent_role (lines: 50). Look for signs of uncommitted changes (agents mention "modified files" or "working on" in logs).
Step 3: Offer rescue options via AskUserQuestion
- Question: "Pipeline appears stuck. How would you like to proceed with the completed work?"
- Header: "Rescue"
- Options:
- "Open PR with committed work" — description: "Create a draft PR from commits already on the branch"
- "Cancel and retry" — description: "Kill this pipeline and re-submit the task"
- "Keep waiting" — description: "Continue monitoring — the pipeline may still recover"
Handle each response:
-
Open PR with committed work →
- Verify branch has commits:
git log --oneline origin/egg/<branch> ^origin/main
- Create a draft PR:
gh pr create --head egg/<branch> --title "<task summary>" \
--body "Draft PR with work completed before pipeline stall. Manual review recommended." \
--base main --draft
- Inform the user of the PR link and that manual review is recommended since not all agents completed.
- Call
cancel_task with task_id and cleanup: true to clean up the pipeline. If cancel_task fails, inform the user and offer to retry — the draft PR is already created so work is preserved.
-
Cancel and retry → Confirm with the user, then call cancel_task with task_id and cleanup: true, followed by submit_task with the original parameters. Resume from Phase 3 with the new task_id. If cancel_task fails, inform the user and offer to retry. If cancel_task succeeds but submit_task fails, inform the user that the previous pipeline was cancelled and offer to retry the submission.
-
Keep waiting → Resume monitoring.
Last-resort debugging
Monitoring, stall/anomaly detection, and recovery are owned by the orchestrator and overseer; the skill's job is to run + report + broker HITL. When you nonetheless need to intervene from the host, two backstop rules are load-bearing:
- Never blind-action a destructive recommendation. An
OVERSEER_ALERT (or any surfaced recommendation) may suggest a destructive action — cancelling the pipeline, restarting a phase, discarding work, force-pushing. Do not execute it automatically. Always route a destructive recommendation through AskUserQuestion and let the human decide; only the human authorizes cancel_task, phase restart, or any other irreversible step.
TaskStop the Monitor before re-arming it. Before starting a new wait-status Monitor, call TaskStop(task_id=monitor_task_id) on the prior Monitor's cached id (see HITL-driven re-arms). Two live Monitors on the same task_id each advance their own cursor and double-emit every event. If TaskStop itself fails, proceed with the re-arm but surface the failure so the user can stop the prior task manually.
Phase 4 — HITL (Human-in-the-Loop)
When the cached last_status (sourced from get_status, re-fetched after a wait-status line emits event_type: "decision.created") carries a non-empty pending_decisions list, partition the batch by decision_type and handle each group as described below. wait-status wakes immediately on decision.created, so a freshly-created decision is visible on the very next emitted line — re-fetch the full snapshot via get_status to get the enriched pending_decisions envelope. A single snapshot can surface multiple pending decisions at once (e.g. a refiner that registered 10 choice decisions via register_open_question); when that happens, group them so the user sees up to 4 per AskUserQuestion call rather than one prompt per decision.
Two-wave surfacing
A phase that registers agent-level choice / feedback decisions (via register_open_question / register_feedback_request) surfaces them to the operator in two waves, not a single batch:
-
Wave 1 — phase_gate only. When the phase first reaches awaiting_human, pending_decisions contains exactly one entry: the phase_gate. The agent-registered choice/feedback decisions are deferred behind the gate and are not yet in pending_decisions, even if the draft document enumerates them.
The operator resolves the phase_gate via provide_input (approve / request_changes / change_approach).
-
Wave 2 — deferred decisions. On approve, the pipeline stays in awaiting_human and the orchestrator moves the deferred choice/feedback decisions into pending_decisions. They wake the next wait-status Monitor invocation via decision.created. The next phase does not start until all of them are resolved. On request_changes / change_approach, the deferred decisions are discarded with the phase reset — no Wave 2.
Converge-before-advance (#3392). When Wave 2 resolves one or more decisions, the phase does not advance — it re-runs so the draft reflects the resolutions, then re-surfaces the gate. Already-answered questions are not re-asked (the orchestrator carries resolved answers forward), so each round's open-decision set shrinks; any new decision a resolution induces surfaces in the next round. The phase advances only when a round resolves nothing new and the operator approves. Practical effect for the operator: after resolving deferred decisions, expect the phase to re-run and the gate to re-appear (often quickly, since the change is small) rather than the next phase starting immediately. There is no force-advance when a human is in the loop: with hitl_gates: true (the default) refine/plan run this human-gated converge loop, and a loop that runs many rounds emits a non-fatal overseer non-convergence alert rather than advancing with decisions unresolved. With hitl_gates: false the converge loop cannot run (no human to answer), so refine/plan surface a non-blocking gate event and advance autonomously instead of hanging.
Operator messaging implications — when narrating a phase_gate approval to the user, do not say "approves and moves to the next phase". The accurate framing is: "approves the draft; if the phase registered deferred decisions, they will surface next for you to resolve before <next phase> starts." When the draft lists open questions that are not in the current pending_decisions snapshot, frame them as "these will surface as <phase>-phase decisions once the gate is approved", not "these will come up in the <next phase> phase".
Gate-approval guard (#3374) — the provide_input response for a phase_gate includes an outstanding_contract_decisions list when the approval leaves later-phase cq-N HITL questions unanswered (current-phase ones are promoted by Wave 2; only questions tagged for a future phase remain genuinely outstanding). Surface these to the user so an approval is never narrated as "nothing else pending" while HITL questions sit unanswered downstream. They also appear in every get_status snapshot under pending_contract_decisions until resolved.
Handling rules by decision_type:
phase_gate — always alone. Handle individually per the section below.
choice — may arrive in multiples. Apply the resolved_questions_map auto-resolution check (see below) to each one first; auto-resolved decisions are submitted immediately via provide_input and omitted from the prompt. For the remaining decisions, group up to 4 into a single multi-question AskUserQuestion call (one question per decision, that decision's options as the choices). After the user answers, call provide_input once per decision_id with {"action": "select", "selected": "<chosen option>"}. Repeat in groups of 4 until every choice decision is resolved. This collapses what was previously N prompts and N polling cycles into ~⌈N/4⌉ prompts and one cycle (#1956).
feedback — typically at most one per phase. Handle individually; within a single feedback decision, continue to batch its questions[] array up to 4 per AskUserQuestion call (existing behavior, see the feedback subsection below).
For the rest of this section, "the decision" refers to a single entry being processed. When multiple choice decisions are pending, apply the batching rule above rather than prompting one at a time.
Resolved Questions Map
Maintain a single session-scoped, in-memory dict named resolved_questions_map for the lifetime of the current /sdlc session. It maps normalized_question_text → answer, where:
- Normalization rule: apply
question.strip().lower() — trim leading/trailing whitespace and lowercase. Use the same rule on every read and every write so lookups are symmetric. Do not normalize the stored answer value; keep the user's answer verbatim so downstream handlers can compare it against option lists exactly as the user gave it.
- Scope: the map lives in memory for the session only (no persistence to disk). Across multiple
phase_gate events in the same pipeline, newer answers overwrite older ones at the same normalized key — no explicit clearing is needed.
- Writers: Step 5 of the
phase_gate decision handler (below) populates this map as it collects answers to draft-embedded questions.
- Readers: the
choice and feedback decision handlers (below) consult this map before prompting the user, so that questions the user already answered in a prior phase_gate are auto-resolved instead of re-prompted. Every auto-resolution prints a user-visible one-line note so an incorrect match is catchable.
If resolved_questions_map does not yet exist when a handler tries to read it, treat it as empty and fall through to the normal prompt flow.
For phase_gate decisions (phase approval gates):
The full status snapshot from get_status enriches phase_gate decisions with draft_content (the phase's output document), completed_agents_summary (role + status for each completed agent), and reviewer_feedback (list of reviewer verdicts). wait-status JSON-lines do not carry these fields — re-fetch the snapshot via get_status whenever the line surfaces a phase_gate decision.
-
Show the draft document — Display the draft_content field from the decision. If the content is long, show a summary of the key sections (headings and first paragraph of each) followed by the full content in a collapsed format. If draft_content is missing, note that no draft was found.
-
Show completed agents — Display completed_agents_summary as a compact table:
Agents: refiner (complete), reviewer_refine (complete), reviewer_agent_design (complete)
-
Show reviewer feedback — Display each entry from the reviewer_feedback list. For each reviewer:
- Show the reviewer role, verdict, and summary
- Show analysis if present (this contains the detailed reasoning and is typically the most substantive field)
- If verdict is NOT "approved", prominently flag it with a warning prefix
- Show suggestions if present
- Show blocking feedback if present (verdict "needs_revision")
Format each reviewer as:
### Reviewer Feedback
**reviewer_refine** — Approved
> [summary]
Analysis: [analysis]
Suggestions: [suggestions]
**reviewer_agent_design** — Needs Revision
> [summary]
Analysis: [analysis]
Blocking concerns: [feedback]
Suggestions: [suggestions]
If reviewer_feedback is empty or missing, skip this section.
-
Highlight key disagreements — If any reviewer has a verdict other than "approved", present a prominent "Key Concerns" section before asking for approval:
### Key Concerns (require attention)
- **reviewer_agent_design** (needs_revision): [core blocking concern from feedback field]
This ensures the human sees blockers before deciding.
-
Present open questions to the user — After reviewing the draft content, reviewer feedback, and key concerns, determine whether there are unresolved questions, decisions, or areas where the user's input would be valuable before proceeding. This is a judgment call — use the full context of the draft document, not pattern matching.
Common situations where you should prompt the user:
- The draft proposes multiple options/approaches without a clear recommendation
- The draft explicitly asks for human input on trade-offs or priorities
- Reviewers raised concerns that require a human judgment call (not just a code fix)
- The draft mentions risks, unknowns, or assumptions that the user should validate
- There are scope or strategy decisions that affect downstream phases
Deduplication — Some questions in the draft may also appear as separate pending_decisions (choice/feedback type) that you'll handle individually in the next sections. To avoid double-prompting, compare question text (case-insensitive, trimmed) against the question field of all pending_decisions in the current batch. If a draft question matches a pending decision, skip it here — it will be handled when you process that decision type below. (Under two-wave surfacing, this check is a no-op during Wave 1 since choice/feedback decisions are deferred; resolved_questions_map handles cross-wave deduplication.)
For each question or decision you identify, present an AskUserQuestion:
- For decisions with discrete options: list the options from the draft as choices
- For open-ended questions: use options like "Not sure / skip" and "N/A", letting the user type their answer in the "Other" field
- Group related questions into a single multi-question
AskUserQuestion call when possible (up to 4 questions per call)
Collect all responses into a structured summary:
## Resolved Questions
**<question>**
Answer: <user's response>
As you collect each answer, also store it in resolved_questions_map keyed by the normalized question text (question.strip().lower()) with the user's answer as the value. This happens in addition to building the Resolved Questions display block — both must be populated. Later choice and feedback decisions in the same session will consult this map to auto-resolve follow-up questions the user already answered here.
If nothing in the draft requires user input beyond the approval itself, skip this step entirely — do not manufacture questions.
-
Ask for approval — Use AskUserQuestion:
- Question: "Phase '' is complete. Do you approve the output above to proceed?"
- Header: "Approval"
- Options:
- "Approve" — description: "Accept the draft and proceed (if the phase registered deferred decisions, they surface before the next phase begins)"
- "Request changes" — description: "Send feedback for agents to address, then re-review"
- "Change approach" — description: "Reject this approach entirely and re-run the phase from scratch with new direction"
- "Cancel and re-run pipeline" — description: "Fundamental issues — cancel this pipeline and start over"
- The user can also type custom feedback in the "Other" field
-
Submit the response — Use structured JSON payloads so the orchestrator's _parse_resolution can properly route the resolution. The response parameter to provide_input is always a string — serialize JSON payloads before passing them. Build the JSON based on the user's choice:
7a. Build the context string from step 5 responses (if any). Include the "Resolved Questions" summary as a readable string. If no questions were asked in step 5, omit this field. Note: _parse_resolution only extracts action and feedback — the context is preserved in the raw resolution but not actively routed to agents yet.
7b. Submit based on the user's approval choice:
-
If "Approve" → call provide_input with:
{"action": "approve", "context": "<resolved questions from 7a, or omit>"}
-
If "Request changes" → ask a follow-up AskUserQuestion:
- Question: "What changes should the agents address?"
- Header: "Feedback"
- Options:
- "Address reviewer concerns" — description: "Fix the blocking issues flagged by reviewers above"
- "See my notes below" — description: "I'll type specific feedback"
Then call
provide_input with:
{"action": "request_changes", "feedback": "<user's feedback text>", "context": "<resolved questions from 7a, or omit>"}
-
If "Change approach" → ask a follow-up AskUserQuestion:
- Question: "What direction should the agents take instead?"
- Header: "Direction"
- Options:
- "Try a completely different approach" — description: "Let agents explore alternatives"
- "See my notes below" — description: "I'll describe the approach I want"
Then call
provide_input with:
{"action": "change_approach", "feedback": "<user's direction text>", "context": "<resolved questions from 7a, or omit>"}
This resets the current phase and re-runs it with the new direction. Note: in the current orchestrator, change_approach and request_changes both result in is_approved=False and trigger a phase reset. The distinct UX framing encourages users to provide different types of feedback (incremental fixes vs. directional pivots), but the orchestrator processing is the same.
-
If "Cancel and re-run pipeline" → confirm with the user ("This will cancel the current pipeline and start a new one. Proceed?"), then:
- Call
cancel_task with task_id and cleanup: true
- Ask the user if they want to modify the original task description
- Call
submit_task with the (possibly updated) description
- Resume from Phase 3 (Monitor) with the new
task_id
Error handling: If cancel_task fails, inform the user and offer to retry. If cancel_task succeeds but submit_task fails, inform the user that the previous pipeline was cancelled and offer to retry the submission — do not leave the user stranded with a cancelled pipeline and no replacement.
-
If custom text (Other) → treat as request_changes feedback. Call provide_input with:
{"action": "request_changes", "feedback": "<user's text>", "context": "<resolved questions from 7a, or omit>"}
After resolving this decision, move to the next pending decision (if any) before resuming monitoring. On approve, resume monitoring — do not announce the next phase has started yet. If the phase had deferred choice/feedback decisions, the pipeline stays in awaiting_human and Wave 2 surfaces them (see Two-wave surfacing); tell the user: "Approved. The phase's deferred decisions will surface next." If no deferred decisions exist, the pipeline transitions to the next phase normally — you will see the phase change on the next emitted wait-status JSON-line. Once all pending decisions are resolved and you are about to invoke the next Monitor, call TaskStop(task_id=monitor_task_id) on the cached id from Phase 3 step 2 — see HITL-driven re-arms. Do this once, before the re-arm — not after each intermediate approve in a multi-decision batch.
For choice type decisions:
Before prompting — check resolved_questions_map for a captured answer:
- Compute
normalized_q = decision.question.strip().lower().
- Look up
resolved_questions_map[normalized_q]. If the key is absent (or the map doesn't exist yet), fall through to the normal prompt flow below.
- If a stored answer is present, compare it against each entry of
decision.options using the same normalization (option.strip().lower() == stored_answer.strip().lower()). Pick the first matching option if any.
- On a compatible match: skip
AskUserQuestion entirely and auto-resolve the decision. Call provide_input with the matched option verbatim (use the option text from decision.options, not the normalized form):
{"action": "select", "selected": "<matched option verbatim>"}
Then print a one-line user-visible note:
Auto-resolved <decision_id>: selected '<option>' from captured context.
Proceed to the next pending decision.
- On no match, or if the stored answer is a free-text / "Other" value that doesn't correspond to any option in
decision.options: fall through to the normal prompt flow below. Do not force an invalid selection.
When multiple choice decisions are pending in the same batch, group up to 4 into a single multi-question AskUserQuestion call (see the Phase 4 intro above). The per-decision formatting rules below still apply — each decision contributes one question (its question field) and a set of options (its options array) to the batched prompt. After collecting the user's answers, call provide_input once per decision_id.
If the decision includes a draft_content field, display it to the user first as context for the decision. If the content is long, show a summary of the key sections (headings and first paragraph of each) followed by the full content. This is especially important for decisions from the refine and plan phases, where the draft contains the analysis or plan that motivates the decision.
Show the decision's question and context (if non-empty) prominently, then use AskUserQuestion to present the options:
- Question: the decision's
question field
- Options: the decision's
options array (each as a label with empty description)
After the user selects, call provide_input with:
{"action": "select", "selected": "<chosen option text>"}
If the user types custom text via "Other", send:
{"action": "select", "selected": "<user's custom text>"}
For feedback type decisions:
Before prompting — consult resolved_questions_map for each question:
- Initialize an empty
prefilled_answers dict and an empty unmatched_questions list.
- For each entry in the decision's
questions array:
- Determine the answer key: use the question's
id field if present, otherwise fall back to q-<1-based index> (as described in the paragraph below).
- Compute
normalized_q = question.question.strip().lower().
- Look up
resolved_questions_map[normalized_q]. If present, add prefilled_answers[<answer_key>] = <stored answer verbatim>. Otherwise, append the question entry to unmatched_questions.
- All-matched fast path: if
unmatched_questions is empty, skip AskUserQuestion entirely. Call provide_input with the prefilled answers:
{"action": "submit_feedback", "answers": { ...prefilled_answers }}
Then print a one-line user-visible note naming the decision ID and the question IDs that were auto-resolved, for example:
Auto-resolved <decision_id>: answers for [q-1, q-2] prefilled from captured context.
Proceed to the next pending decision.
- Partial match: if
unmatched_questions is non-empty, present only those questions via AskUserQuestion (using the normal grouping rules below — up to 4 questions per call). Collect the user's answers into a new_answers dict keyed the same way (question id or q-<1-based index>, preserving each question's original index in the full questions array). Merge: answers = {...prefilled_answers, ...new_answers}. Then call provide_input with the single merged payload:
{"action": "submit_feedback", "answers": { ...merged answers ... }}
Print a one-line user-visible note naming the decision ID and listing which question IDs were auto-resolved from captured context (and, implicitly, which were prompted), for example:
Auto-resolved <decision_id>: prefilled [q-1] from captured context; prompted for [q-2, q-3].
- No matches: if
prefilled_answers is empty after the scan, fall through to the normal prompt flow below without any auto-resolution note.
If the decision includes a draft_content field, display it to the user first as context for the feedback request. If the content is long, show a summary of the key sections (headings and first paragraph of each) followed by the full content. This is especially important for decisions from the refine and plan phases, where the draft contains the analysis or plan that motivates the questions.
Feedback decisions include a questions array — each entry has id, question, and an empty answer field. Present each question to the user:
- If there is a single question: show it with
AskUserQuestion using options like "N/A" and "Not sure / skip", with the user typing their answer in "Other"
- If there are multiple questions: group them into
AskUserQuestion calls (up to 4 questions per call). For each question, present it clearly and collect the response.
After collecting all answers, call provide_input with:
{"action": "submit_feedback", "answers": {"<question_id>": "<answer>", "<question_id>": "<answer>"}}
Use the id field from each question entry as the key (e.g., "Q1", "Q2" from egg-contract add-feedback, or fallback "q-1", "q-2" from sdlc_hitl.py for questions missing an id). If a question has no id, use "q-<1-based index>" as the fallback key.
Submitting choice/feedback responses:
Call the provide_input MCP tool (phase_gate decisions already handle this in step 7 above):
Tool: provide_input
Arguments:
task_id: <task_id>
decision_id: <decision_id>
response: <JSON string — the stringified JSON payload from above>
Important: The response parameter is a string. Serialize the JSON payload to a string before passing it (e.g., response: '{"action": "select", "selected": "Option A"}').
Confirm the input was submitted, then proceed to the next pending decision. Once all decisions are resolved, resume monitoring (Phase 3). Before invoking the next Monitor, call TaskStop(task_id=monitor_task_id) on the cached id from Phase 3 step 2 — see HITL-driven re-arms.
Phase 5 — Complete
The monitoring loop has exited. Summarize:
On success:
Pipeline Complete
Status: Success
Phase: <final phase>
- Show PR link if available in the pipeline data
- List agents that ran (from
completed_agents)
- Note any agents that failed
- If the final emit (or the cached
last_status) carried concurrent.consensus, render the per-role table from Consensus Monitoring one final time — gives the operator a closing snapshot of which roles confirmed and any leftover NACK / silent rows for the record.
On failure:
Pipeline Failed
Status: Failed
Phase: <phase where failure occurred>
-
Show error information if available
-
List what completed before failure
-
Offer the user a choice via AskUserQuestion:
- "Re-run pipeline" — description: "Cancel this pipeline and start a new one with the same task"
- "Re-run with changes" — description: "Cancel and start a new pipeline with modified description"
- "Done" — description: "No further action needed"
If re-running:
- Call
cancel_task with the failed task_id and cleanup: true
- If "Re-run with changes", ask the user for the updated description
- Call
submit_task with the description (original or updated) and same repo/issue
- Resume from Phase 3 (Monitor) with the new
task_id
Error handling: If cancel_task or submit_task fails, inform the user and offer to retry the failed step.
Troubleshooting
When the pipeline is stuck, failing, or behaving unexpectedly, use MCP tools to investigate before asking the user to re-run:
| Scenario | MCP Tool | Notes |
|---|
| Pipeline stuck or unclear state | get_pipeline_snapshot | Comprehensive view: pipeline state, containers, messages, decisions |
| Check orchestrator + gateway health | check_health | Verifies both services are reachable |
| View agent logs | get_container_logs | Auto-selects container by role; set lines for more output |
| List containers in pipeline | list_containers | Find container IDs, statuses, and agent roles |
| BRC consensus state | get_consensus_status | Agent phases, blocking agents, unresolved NACKs |
| SDLC contract state | get_contract | Task progress, pending decisions |
| Send message to agent | send_message | Nudge agents, request status updates |
| Phase details | get_phase | Current phase, execution timing, review cycles |
| Message bus stats | Via get_status snapshot or wait-status JSON-line | concurrent.consensus field |
When to use these during the workflow: