Imported from jwbron/egg (
skills/sdlc/SKILL.md). Install upstream withnpx skills add jwbron/egg --skill sdlc. Copyright stays with the author.
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
--shortis 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(orgit 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/namefrom the URL (e.g.https://github.com/jwbron/egg.git→jwbron/egg) - If a
--repoflag 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,assigneesand 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,titleand present the results as a secondAskUserQuestionwith each issue as an option. Once the user selects an issue, fetch it withgh issue view <N> --repo <repo> --json title,body,comments,labels,assigneesand 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 titlefields.description(orrenderedFields.description) — full descriptionfields.status.name— current statusfields.priority.name— priorityfields.labels— labelsfields.components— componentsfields.assignee.displayName— assigneefields.comment.comments— comments (last 10)fields.issuelinks— linked issues (blockers, relates-to, etc.)fields.subtasks— subtasks if anyfields.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
componentsorlabelsfor 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)
- Question: "Branch
- Retry
submit_taskwith 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_statusreturns the full snapshot. It does not return acursor; the cursor is produced bywait-statusonly. Cache the snapshot in conversation context aslast_status. Initializelast_cursor = ""(empty — the firstwait-statuscall snaps to the tip of both event sources). -
Blocking wait — invoke
${CLAUDE_SKILL_DIR}/bin/wait-statusthrough 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: trueis required. SDLC pipelines routinely run for multiple hours (multi-slice cleanups, deep refines, HITL gates between phases), exceeding the Monitor'stimeout_mscap (1h max per the schema). Withpersistent: true,timeout_msis ignored and the Monitor lives for the session — only exiting onTaskStop, 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 shapedmsg:<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_idin conversation context (returned in the Monitor tool's invocation response). You'll need it to callTaskStopbefore re-arming a new Monitor after HITL (see HITL-driven re-arms below) —wait-statusdoes not exit ondecision.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, noPYTHONPATH, no egg checkout — that loops the orchestrator's/status/waitroute server-side, threading the cursor between calls. It needs only a reachable orchestrator: setEGG_ORCHESTRATOR_URLif it isn't at the defaulthttp://localhost:9849(note:localhostis 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 onno_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 0Pipeline reached terminal state ( complete/failed/cancelled)Exit the monitor loop, move to Phase 5 2Transient error budget exceeded after backoff Re-invoke Monitor with the same last_cursor3Permanent 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: truedisables the Monitor timeout. See #2801.)Re-invocation rule — when to
TaskStopfirst. The cases in the table above (auto-stopped, exit-code2) all leave the prior CLI already terminated, so re-invoking Monitor is safe — nothing else is polling/status/waitfor thistask_id. The other re-arm case is HITL-driven: you're returning from Phase 4 after aprovide_inputsubmission and the prior Monitor is still alive (decision.resolvedis excluded from the trigger allowlist, so it didn't self-wake). Before re-invoking Monitor in that case, callTaskStopon the prior Monitor's task ID first. Two live Monitors for the sametask_ideach advance their ownevtcursor 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). IfTaskStopitself 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-statusis 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 adecision.createdthat 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 latestlast_cursorfrom the batched output. -
Read each emitted JSON line as it arrives. The line shape is:
{ "trigger": "event", "event_type": "phase.started", // wire value — phase.started / decision.created / pipeline.completed / etc. "cursor": "msg:1738012734-0|evt:142", "current_phase": "plan", "status": "running", "phase_elapsed_seconds": 127, "concurrent": { "consensus": { ... } } }For
trigger: "message"the line carriesmessages: [...]instead ofevent_type. Updatelast_cursorfrom each line'scursorfield. The cursor is opaque (shapemsg:<id>|evt:<seq>) — treat it as a string and thread it through--sinceon 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.resolvedis deliberately excluded so the host doesn't self-wake on aprovide_inputit 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.consensusis 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 withconfirmed: trueoverlen(agents);NACKs: <K>islen(unresolved_nacks).Use the server-computed
phase_elapsed_secondsfrom 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 anOVERSEER_ALERTwithrecent_messages, or to renderpending_decisionsahead of HITL on adecision.createdline — callget_status(task_id)again as a one-shot snapshot and refreshlast_status. -
Check for overseer alerts on each
trigger: "message"line where any entry'stypeisOVERSEER_ALERT— see Overseer Alert Detection below. -
Check consensus health on each line carrying
concurrent.consensus— see Consensus Monitoring below. The wait-status JSON-line shipsconcurrent.consensuswhenever 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 viaget_status(task_id)(the JSON-line does not carrypending_decisions) and move to Phase 4 (HITL). - On
status: "complete"orevent_type: "pipeline.completed"→ exit the monitor loop and move to Phase 5. - On
status: "failed"orevent_type: "pipeline.failed"→ apply the failed status grace period (see below) before exiting.
- On
-
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
statusisfailedbutrunning_agentsis non-empty → treat as "transitioning", not failed. Log:"Status shows failed but agents still running — treating as cycle transition."Continue polling. - If
statusisfailedandrunning_agentsis empty → callget_pipeline_snapshotMCP tool with thetask_idto confirm actual state before exiting. If the snapshot shows active containers or recent messages, continue polling. - Only exit to Phase 5 when
statusisfailed,running_agentsis 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
AskUserQuestionto 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 theget_container_logsMCP tool withtask_idandagent_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_taskwithtask_idandcleanup: 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_statussnapshot'spending_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-transitionOVERSEER_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 thefeedbackdecision_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_feedbackMCP 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 guardanswer_feedbackwrites 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-Nby 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 freshcq-Nby design. Within one phase you should not see twocq-Nfor 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_inputwith thecq-Nid 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_inputreturns HTTP 409 with a pointer to a mirror id (e.g.decision-M), the bridge already promotedcq-Ninto the queue. Resolve the queue id instead.For open-ended
feedback-N, useanswer_feedbackinstead — 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 separateNACKs:line. - Silent agent rows — for any role in
running_agentswhoseelapsed_secondsexceeds the silent threshold (10+ minutes by default) AND has zero messages inrecent_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 anOVERSEER_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
typefield (primary) — eachrecent_messagesentry includes atypefield with reliable enum values:CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_CONFIRMED. Use these for classification, not subject parsing. - Identify roles using the
from_rolefield — each message includesfrom_roleindicating which agent sent it. - Maintain an in-memory map of
{role: {last_message_type, last_message_time, message_count}}built fromrecent_messages - Infer consensus state: if all roles listed in
running_agentshave sentCONSENSUS_CONFIRMEDmessages, consensus is likely complete - For the per-role table (Path B above), approximate the fields when
concurrent.consensusis missing:Phasecell: render—for every row. Theproducer_phase/reviewer_phasesource is gone in fallback mode and message types do not give a reliable per-role phase mapping (e.g. aCONSENSUS_PROPOSEfrom a producer means the producer is inPROPOSED, but says nothing about reviewer phases on its own).—is the safe floor; do not invent a message-type-to-phase mapping.Confirmedcell:✓if the role has emitted aCONSENSUS_CONFIRMEDmessage, blank otherwise- Header
<N>/<total>confirmed: count of roles withCONSENSUS_CONFIRMEDmessages - Optional NACK rows:
CONSENSUS_NACKmessages not followed by aCONSENSUS_PROPOSEfrom the named producer (usesubjectto extract the reason)
- Use
subjectonly 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_logsMCP tool withtask_idandagent_roleset to the producer's role (lines: 50). Show the output and let the user decide next steps. - Check reviewer logs → Call the
get_container_logsMCP tool withtask_idandagent_roleset to the reviewer's role (lines: 50). Show the output and let the user decide next steps. - Nudge producer → Call the
send_messageMCP tool withtask_id,to_roleset to the producer role,message_type: "STATUS", andbody: "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_statusandlist_containersMCP tools with thetask_id, then show blocking agents and their recent logs (viaget_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_taskwithtask_idandcleanup: trueto clean up the pipeline. Ifcancel_taskfails, inform the user and offer to retry — the draft PR is already created so work is preserved.
- Verify branch has commits:
-
Cancel and retry → Confirm with the user, then call
cancel_taskwithtask_idandcleanup: true, followed bysubmit_taskwith the original parameters. Resume from Phase 3 with the newtask_id. Ifcancel_taskfails, inform the user and offer to retry. Ifcancel_tasksucceeds butsubmit_taskfails, 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 throughAskUserQuestionand let the human decide; only the human authorizescancel_task, phase restart, or any other irreversible step. TaskStopthe Monitor before re-arming it. Before starting a newwait-statusMonitor, callTaskStop(task_id=monitor_task_id)on the prior Monitor's cached id (see HITL-driven re-arms). Two live Monitors on the sametask_ideach advance their own cursor and double-emit every event. IfTaskStopitself 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_decisionscontains exactly one entry: thephase_gate. The agent-registered choice/feedback decisions are deferred behind the gate and are not yet inpending_decisions, even if the draft document enumerates them.The operator resolves the
phase_gateviaprovide_input(approve/request_changes/change_approach). -
Wave 2 — deferred decisions. On
approve, the pipeline stays inawaiting_humanand the orchestrator moves the deferred choice/feedback decisions intopending_decisions. They wake the nextwait-statusMonitor invocation viadecision.created. The next phase does not start until all of them are resolved. Onrequest_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 theresolved_questions_mapauto-resolution check (see below) to each one first; auto-resolved decisions are submitted immediately viaprovide_inputand omitted from the prompt. For the remaining decisions, group up to 4 into a single multi-questionAskUserQuestioncall (one question per decision, that decision'soptionsas the choices). After the user answers, callprovide_inputonce perdecision_idwith{"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 itsquestions[]array up to 4 perAskUserQuestioncall (existing behavior, see thefeedbacksubsection 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 persist
Truncated - read the full file at https://github.com/jwbron/egg/blob/b4f0c5c5fd37866821c663a73f118d290e92cd03/skills/sdlc/SKILL.md.