Help us improve
Share bugs, ideas, or general feedback.
From plan-tango
Runs a Claude↔Codex convergence loop: Codex reviews a plan, Claude applies fixes, repeats until ALLOW or max-iter. For auto-reviewed plan refinement without manual copypaste.
npx claudepluginhub egsok/plan-tango --plugin plan-tangoHow this skill is triggered — by the user, by Claude, or both
Slash command
/plan-tango:tango [plan-path-or-slug] [--max-iter N (default 6, cap 12)] [--effort none|minimal|low|medium|high|xhigh] [--model <m>] [--lenient] [--final-check] [--resume] [--takeover] [--quiet] [--verbose-report][plan-path-or-slug] [--max-iter N (default 6, cap 12)] [--effort none|minimal|low|medium|high|xhigh] [--model <m>] [--lenient] [--final-check] [--resume] [--takeover] [--quiet] [--verbose-report]This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
<objective>
references/advanced-config.mdreferences/codex-thread-investigation.mdreferences/final-check.mdreferences/invariants.mdreferences/report-format.mdreferences/review-prompt-template.mdreferences/schemas.mdreferences/verdict-contract.mdscripts/apply-fixes.mjsscripts/doctor.mjsscripts/init.mjsscripts/load-config.mjsscripts/lock.mjsscripts/parse-codex-jsonl.mjsscripts/parse-codex-verdict.mjsscripts/plan-paths.mjsscripts/prepare-iter.mjsscripts/run-codex-review.mjsscripts/snapshot.mjsscripts/update-check.mjsReviews implementation Plan files in parallel using Codex, Gemini, and Claude to analyze validity, gaps, risks, and improvements. Invoke via /plan-review after plan creation.
Orchestrates parallel architecture and experience reviews of implementation plans, scores findings across dimensions like data flow and UX, consolidates ranked fixes for user approval and auto-application. Use after planning, before non-trivial coding.
Orchestrates an adversarial plan-implement-review pipeline by spawning agents with separate context windows. Use after intake skills produce a starting document.
Share bugs, ideas, or general feedback.
Works inside plan mode (Read/Edit of plan-file allowed; Bash/Task via permission prompts).
<execution_context>
codex exec --json --sandbox read-only -o <file> from run-codex-review.mjs. Required: codex on PATH (codex --version), auth via codex login or /codex:setup.~/.claude/plan-tango/config.json. CLI overrides. Schema: ${CLAUDE_PLUGIN_ROOT}/skills/tango/user-config.example.json.${CLAUDE_PLUGIN_ROOT}/skills/tango/scripts/:
plan-paths.mjs (validate/newest/list-recent/resolve-repo/hash), workspace.mjs (ensure/cleanup), snapshot.mjs, lock.mjs (acquire/refresh/release/inspect), apply-fixes.mjs (dry-run classifier → edit_plan + ledger_template + advisory_plan), parse-codex-jsonl.mjs, parse-codex-verdict.mjs.load-config.mjs — merges CLI + config + defaults; emits {merged, sources, warnings}.init.mjs — orchestrates Phase A in one Bash call (validate plan + codex CLI + repo + load-config + lock + state init/resume + workspace ensure). Returns full context bundle for the orchestrator to bind, with internal lock-cleanup on partial failure.prepare-iter.mjs — single deterministic builder for ALL iter{N} artifacts: iter{N}.prompt.md, iter{N}.params.json, empty stub iter{N}.last-message.txt. Settings come inline via --state-settings '<json>' — no per-iter iter{N}.settings.json Write needed (step 13).run-codex-review.mjs — codex exec wrapper (called directly via Bash from step 15). Filters cosmetic rollout-recording stderr (see references/codex-thread-investigation.md). Retries codex_empty_output once internally before reporting.${CLAUDE_PLUGIN_ROOT}/agents/: plan-tango:plan-final-checker (opus, sanity check on converged statuses — Phase D only).references/review-prompt-template.md, references/verdict-contract.md. Schemas (state, params, ledger, verdict): references/schemas.md.
</execution_context>Default thread mode is continue (reuses one Codex thread; injects <reset_iteration> block at iter ≥ 2). Advanced flags — --continue-thread / --fresh-each (override thread mode), --service-tier <fast|flex>, --fast, --codex-profile <name>, extra_codex_config (config field) — and the legacy deprecated-alias flags / config values are documented in references/advanced-config.md. The loader (load-config.mjs) accepts deprecated aliases, migrates them to the canonical setting, and prints a one-line warning per run (orchestrator surfaces these via step 8.5).
init.mjs consolidates Phase A+B (formerly 11 stepped operations) into one Bash call. Internally it composes existing helpers (plan-paths, load-config, lock, workspace) — no new logic, just chained orchestration with internal cleanup on failure.
Build CLI JSON from $ARGUMENTS into a flat object with these keys (using _ for - per loader contract): max_iter, effort, model, lenient, quiet, verbose_report_flag, final_check_flag (canonical, set by --final-check), no_final_check, force_final_check, continue_thread, fresh_each, fast, service_tier, codex_profile. See references/advanced-config.md for alias semantics.
Run init:
node ${CLAUDE_PLUGIN_ROOT}/skills/tango/scripts/init.mjs \
--cli '<json>' \
[--plan-arg <positional-or-empty>] \
[--active-plan <path-from-system-prompt-or-empty>] \
[--resume] [--takeover]
Init resolves plan-path (priority: positional > active-plan > newest under ~/.claude/plans/; --resume disables the newest fallback per Resume-safety invariant), validates the plan (size ≥ 200 bytes, realpath under ~/.claude/plans/), verifies codex --version, resolves repo-root (repo_evidence_available is always true in v0.2 — old git-required gate retired), loads + validates merged settings, acquires the lock first, writes (or loads + hash-checks for resume) state.json, and ensures the workspace dir.
On stdout one JSON object: success — {ok:true, slug, plan_path, repo_root, repo_evidence_available, codex_version, settings, settings_sources, warnings, lock_acquired:true, lock_session_id, lock_took_over_stale, state_path, state, is_resume, workspace_path}. Failure — {ok:false, abort_reason, error, lock_acquired, lock_session_id?, slug?}.
On ok:false:
lock_acquired === true (race fallback — internal cleanup in init failed): release via lock.mjs release --slug <slug> --session <lock_session_id>. On session_mismatch log a warning, do NOT delete.error to user. ABORT with abort_reason (which is one of: missing_cli, unknown_flag, no_plan_resolved, resume_no_plan, plan_invalid, codex_cli_missing, repo_resolve_failed, config_invalid, max_iter_cap, lock_held, lock_corrupt, cannot_takeover_fresh_lock, lock_failed, resume_no_state, state_unreadable, state_invalid_json, resume_hash_mismatch, state_write_failed, workspace_failed).On ok:true:
state (full object), slug, plan_path, repo_root, repo_evidence_available, state_path, lock_session_id (used in step 22 lock.refresh and step 30 lock.release), lock_acquired = true, is_resume, settings (top-level from init output — this is the fresh loader output for this invocation; distinct from state.settings which is persisted at first-run and stays stable across --resume). Verify defensively: state.settings.max_iter ≤ 12 (step 21h re-checks at continue-prompt time).settings vs state.settings: most runtime decisions read state.settings (max_iter, effort, thread_mode, final_check, lenient, severity_aware, verbose_report) — these were settled at first-run for resume-stability. Step 29.5 update-check reads settings.update_check (top-level, fresh) so a user who edited ~/.claude/plan-tango/config.json between runs can flip the opt-out without re-starting from scratch.warnings entry to the user (deprecation notices). Always print, even with --quiet.lock_took_over_stale === true, log: "Took over stale lock from prior session."/fewer-permission-prompts if you'll use this often."State shape, params shape, ledger shape: see references/schemas.md.
while N <= max_iter, where N = state.iter + 1)For each iteration N (state.iter is the count of completed iterations, starts at 0):
10b. Integrity check (BEFORE snapshot, BEFORE Codex call): compute current_hash = sha256(plan_path) via plan-paths.mjs --hash. If !== state.last_known_plan_hash → BREAK with status=external-modification. Print: "Plan modified outside skill since last completed apply (expected {short(last_known)}, got {short(current)}). Skill aborts to avoid clobbering manual edits or competing automation. Inspect snapshots in {plan}.iter*-*.bak and decide whether to re-run from scratch." Skips remaining steps (no Codex call wasted; lock released in Phase E). Protects against IDE edits between iterations, second instances, or any external write.
11. Snapshot: snapshot.mjs --plan <plan_path> --iter <N>. If quiet=false: Print [N/max] Snapshot: <result.snapshot>.
12. If quiet=false: Print [N/max] Sending to Codex (effort=<effort>, mode=<thread_mode>, tier=<service_tier|standard>, cwd=<repo_root>)....
13. Prepare iter artifacts via prepare-iter.mjs (single Bash call replaces legacy build-prompt.mjs + build-params.mjs + orchestrator-Write of iter{N}.settings.json). Build the codex-relevant settings JSON inline from state.settings (subset: effort, model, service_tier, codex_profile, extra_codex_config — orchestrator-only keys excluded). Then call:
bash node ${CLAUDE_PLUGIN_ROOT}/skills/tango/scripts/prepare-iter.mjs \ --slug <slug> --iter <N> \ --plan <plan_path> \ --repo-root <repo_root> --repo-evidence <repo_evidence_available> \ --thread-mode <thread_mode> --resume-thread-id <state.codex_thread_id|null> \ --state-settings '<json>' \ --workspace ~/.claude/plans/{slug}-tango.workspace \ --template ${CLAUDE_PLUGIN_ROOT}/skills/tango/references/review-prompt-template.md
The script writes ALL three artifacts: iter{N}.prompt.md (template-substituted), iter{N}.params.json (with resume_thread_id rule enforced — only set when thread_mode=continue AND iter>=2 AND uuid non-null; reset_block in prompt gated by the same predicate), and iter{N}.last-message.txt (empty stub — wrapper also clears it before spawn). Returns {ok, prompt_file, params_file, last_message_file, prompt_lines, prompt_bytes, params_bytes} or {ok:false, error, detail}.
13b. Build-script failure handling: if prepare-iter.mjs exits non-zero OR returns stdout JSON with ok:false:
- Always print (regardless of quiet): [N/max] ERROR — prepare-iter.mjs failed: <error>: <detail>.
- Append ledger entry with iteration_kind="normal", action="build_script_failed", note=<error>.
- Skip Codex spawn. Set status=build-failed, BREAK out of the loop.
- Phase D pre-gate skips Opus on build-failed (status not in converged-* set). Phase E renders normally. Lock release in step 30 fires.
15. Run Codex review via Bash on run-codex-review.mjs:
bash node ${CLAUDE_PLUGIN_ROOT}/skills/tango/scripts/run-codex-review.mjs <abs-path-to-iter{N}.params.json>
Returns one JSON object on stdout (full verdict shape per references/schemas.md). Wrapper output is lean by default for ALLOW/BLOCK (no raw_final_message/raw_output_excerpt — full text on disk at last_message_path); pass --verbose-output (or set PLAN_TANGO_WRAPPER_VERBOSE=1) when verbose-report path needs raw fields.
16. Parse verdict JSON from the response. The wrapper returns the full shape — orchestrator does NOT re-parse the verdict text. Print (per-bullet quiet gating):
- verdict ∈ {ALLOW, BLOCK} — if quiet=false: [N/max] {verdict} — {C} critical, {M} major, {m} minor, {n} nit ({Xs}, evidence={true|false}).
- verdict=ERROR — always print: [N/max] ERROR — reason={reason}, exit_code={ec}.
- verdict=MALFORMED — always print: [N/max] MALFORMED — reason={reason}.
16.5. Save thread_id to state (when wrapper produced a session_id): apply this rule to state.codex_thread_id:
- response.fallback_to_fresh === true → always overwrite state.codex_thread_id = response.session_id. Log: "Thread lost, switched to ."
- Else if thread_mode === "continue" AND state.codex_thread_id === null AND session_id !== null → save (first iter in continue mode opens the persistent thread).
- Else → leave unchanged.
Write state immediately so Ctrl-C between iters preserves the thread for --resume.
If verdict == ERROR (handle BEFORE classification):
reason=codex_nonzero_exit AND stderr contains ENOENT|auth|401|not logged in → ABORT, suggest: "Run codex login (or /codex:setup) and re-run."reason=codex_empty_output → ABORT (wrapper already retried once internally; attempts=2, retried_empty=true in the response).reason=prompt_unreadable → ABORT (workspace bug; show path).reason=params_missing|params_unreadable|params_invalid_json|wrapper_exception → ABORT (skill-internal bug; show JSON).If verdict == MALFORMED: one retry (re-spawn same params; fresh thread, Codex may format better). If retry MALFORMED → ABORT, show raw_final_message. If retry succeeded → re-handle through 17.
Update state (any non-ABORT path): append current findings hashes to findings_history, drop oldest if length > 3.
Dry-run classification (only when verdict=BLOCK with non-empty findings): pipe {plan_path, findings} to apply-fixes.mjs. Read classified[], edit_plan[], ledger_template[], advisory_plan[], invariant_summary.
Stop conditions (priority order):
a) verdict == ALLOW and findings empty → BREAK status=converged.
a2) Severity-aware polish-only stop (default, see severity_aware setting): severity_aware=true AND verdict=BLOCK AND findings.length>0 AND count(critical) + count(major) === 0 → BREAK. Status branches on lenient: lenient ? "converged-lenient" : "converged-with-polish".
state.polish_advisory = [...advisory_plan] (covers ALL deduped findings including manual-classified, unlike edit_plan[]). Set state.polish_only_terminal = true.iteration_kind="normal" entries, one per polish_advisory record: {hash, severity, action: "advisory", note: "polish_only_terminal"}.classification=manual → BREAK status=manual-required. v0.2: print the manual-flagged findings (severity, title, location, problem, suggested_fix) so the user can decide outside the skill — edit the plan manually and re-run, or re-run with different --effort. The legacy AskUserQuestion apply-A/apply-B/skip/abort UI is removed; MANUAL_PATTERNS regex in apply-fixes.mjs still flags findings (so they don't auto-apply).
c) Any classified finding with severity ∈ {critical, major} AND classification=deferred → BREAK status=manual-required (same branch).
d) --lenient set AND BLOCK with findings AND zero critical/major → BREAK status=converged-lenient. Checked AFTER b/c so lenient cannot bypass manual. Unreachable when severity_aware=true (a2 fires first).
e) Oscillation: any finding hash in findings_history[N-2] but NOT findings_history[N-1] → BREAK status=oscillating.
f) Stuck: findings_history[N-1] set equals current findings set → BREAK status=stuck.
g) Regression: count(critical) in current > count(critical) in N-1 → BREAK status=regressed. Offer rollback to iter{N-1}.bak.
h) N === state.settings.max_iter (last permitted iter in current cap) → interactive continue-prompt (do NOT break immediately). AskUserQuestion: "Reached max-iter limit ({max_iter}). Current findings: {C} critical, {M} major, {m} minor, {n} nit. Continue?" Options: "Continue +4", "Continue +N (custom)", "Stop here (status=max-iter-reached)", "Abort run".max-iter-reached. On abort → BREAK status=aborted-by-user.new_max = max_iter + extra. Hard cap: if new_max > 12 → refuse with "Hard cap is 12. For larger budgets re-run with explicit --max-iter <N> (still capped at 12) or split the plan." Re-prompt with Stop/Abort only. Otherwise update state.settings.max_iter, write state, log "Continuing to iter {next} (new cap: {new_max})", fall through to step 22.
(ALLOW + findings and BLOCK + zero findings are caught upstream by the parser as MALFORMED.)Apply phase (only when classification produced edit_plan with at least one auto entry).
apply-fixes.mjs is a CLASSIFIER ONLY: it returns metadata ({hash, severity, file_path, location_hint, title, problem, suggested_fix, requested_file_path?} + classification auto/deferred/manual). The orchestrator converts each classified finding into a real Edit call by interpreting Codex's natural-language suggested_fix against the plan text — there is no automatic translation from finding to old_string/new_string.
Off-plan invariant check: live apply-fixes.mjs always sets edit_plan[i].file_path = plan_path for non-manual entries (target is always the plan file). Off-plan findings are signaled via edit_plan[i].requested_file_path !== null AND/OR invariant_summary.off_plan_count > 0 / off_plan_blocking === true. Detect off-plan via requested_file_path, NOT by comparing file_path to plan_path (always equal by construction).
edit_plan[i] with requested_file_path !== null:
off-plan-target. Append ledger entries iteration_kind="normal", action="off_plan_blocked", fields requested_file_path and suggested_fix. Show user the list and stop.action=deferred, note="off-plan-file target" and requested_file_path, but continue applying in-plan entries.invariant_summary.off_plan_blocking: if true and we did not break above, that's a logic bug — abort status=off-plan-target and dump the full classified array.Apply (per in-plan auto entry; process severity-first across the batch — critical → major → minor → nit):
location_hint or a quoted snippet inside problem/suggested_fix. Not found → action=deferred, note="anchor_not_found". Ambiguous (>1 match without line-number disambiguation) → action=deferred, note="anchor_ambiguous". Anchor clobbered by an earlier Edit this iter → action=deferred, note="anchor_clobbered_by_earlier_edit". Unique → proceed.suggested_fix against the matched section. Minimal old_string/new_string, tight scope (do not rewrite surrounding paragraphs). For non-mechanical intent ("add error handling" etc.), best interpretation that satisfies the intent; prefer additive over restructuring.action=deferred, note="edit_tool_rejected: <error_short>". On success → action=applied, record edit_summary (e.g. "+5/-2 lines in §Phase B").Append ledger entries to ~/.claude/plans/{slug}-tango.ledger.json (create with skeleton on first write). Per-finding shape see references/schemas.md.
Update last_known_plan_hash: sha256(updated_plan_file) → state.
Refresh lock: lock.mjs refresh --slug <slug> --session <session_id> --plan-hash <new_hash>. On session_mismatch → ABORT (someone took over).
If quiet=false: Print [N/max] Applied {k} fixes (+{added}/-{removed} lines), deferred {d}. Starting iter {N+1}.
Increment iter, loop.
Pre-gate (v0.2 — single rule): run Opus full-mode if both clauses hold; otherwise skip:
state.settings.final_check === "always" (single normalized output of load-config.mjs; CLI > config > default).Phase D does NOT re-inspect raw CLI flags or raw config values; the decision is settled in state.settings.final_check.
| status | Opus runs when final_check === "always"? |
|---|---|
converged, converged-lenient, converged-with-polish | YES (full mode) |
manual-required, stuck, regressed, max-iter-reached, oscillating, off-plan-target, external-modification, build-failed, aborted-by-user, final-check-malformed, final-recheck-error, final-recheck-malformed | NO |
(Auto-gate keyword triggers — removed in v0.2; see references/final-check.md for historical detail.)
Run final check: spawn plan-tango:plan-final-checker with {plan_path, repo_root, repo_evidence_available, mode}. Receive raw text output. Pipe through parse-codex-verdict.mjs --from-text via Bash. If parser returns verdict=MALFORMED → ONE retry of the subagent with reminder "Your last response did not start with ALLOW: or BLOCK:. Repeat with correct format". If retry MALFORMED → BREAK status=final-check-malformed, show raw output.
(Diagnostic mode — removed in v0.2.) Pre-gate (step 24) makes non-converged statuses ineligible regardless of settings.
Full mode (converged-*):
verdict == ALLOW AND findings empty → BREAK status=converged-final.verdict == BLOCK AND findings.length > 0 AND count(critical) + count(major) === 0 → BREAK status=converged-final. No corrective iter.
apply-fixes.mjs. Cross-check invariant_summary.off_plan_blocking — if true, BREAK status=off-plan-target per step 22 protocol; do NOT write polish_advisory.opus_advisory = [...advisory_plan].state.polish_only_terminal = true. Merge opus_advisory into state.polish_advisory (append + dedupe by hash).iteration_kind="final-check-advisory", one row per opus_advisory entry: {hash, severity, action: "advisory", note: "opus_polish_only"}.snapshot.mjs --iter final-fix.manual or critical/major deferred → BREAK status=manual-required-after-final.requested_file_path !== null). Failures → BREAK status=off-plan-target (ledger iteration_kind="final-fix", action="off_plan_blocked").iteration_kind="final-fix". Update last_known_plan_hash.run-codex-review.mjs again with fresh params.
converged-final.final-check-divergence (Opus and Codex disagree). Show both finding sets. Ask user to resolve.final-recheck-error.final-recheck-malformed.Print convergence report. Source data: state.findings_history, ~/.claude/plans/{slug}-tango.ledger.json, original-vs-current plan hash + size.
Templates for §2/§3/§5/§6 live in references/report-format.md. Inline summary:
state.settings.verbose_report === true.action ∈ {applied, deferred, manual, off_plan_blocked}. By severity (critical → major → minor → nit), one line: N. **{severity}** — {short title}. Cap at ~12; "…and {K} more (see ledger)" suffix when over.verbose_report === true.state.polish_only_terminal === true AND state.polish_advisory.length > 0.Skip rules: §3+§4+§5 skipped only when N=0 (Phase A abort). §1+§2 always render when N≥1. §3+§5 also skipped when verbose_report=false (default).
29.5. Update notice check. Independent of §3/§5 verbose-report gating; runs after step 29 report rendering, before step 30 lock release. Skip entirely when settings.update_check === false (top-level settings, not state.settings — see Phase A step 4 binding rationale).
```bash
# Read current plan-tango version once
CURR_VER=$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.env.CLAUDE_PLUGIN_ROOT + '/.claude-plugin/plugin.json', 'utf8')).version)")
node ${CLAUDE_PLUGIN_ROOT}/skills/tango/scripts/update-check.mjs --current-version "$CURR_VER"
```
Parse the JSON response. The script always exits 0 and always emits JSON:
- `status === "newer-available"` → print exactly one line to user: `\n<response.message>` (already formatted with the `/plan-tango:update` hint).
- `status ∈ {ok, skipped, error}` → print nothing. Silent.
The script is fail-silent on its own (network timeout, missing git, invalid cache) — orchestrator does NOT branch on stderr or exit codes. If the Bash call itself crashes (unlikely but possible), swallow the error and continue to step 30. Update-check is never blocking.
30. Release lock — ONLY if it was actually acquired. The orchestrator tracks lock_acquired based on what init.mjs returned (Phase A step 4): true on ok:true, true only when init reported the race-fallback case ok:false + lock_acquired:true, otherwise false. The orchestrator never attempts release on early init failures (init.mjs either internally cleaned up or never acquired the lock — placeholder values would crash with invalid_slug / missing_session_id, masking the real abort reason).
- lock_acquired === true → lock.mjs release --slug <slug> --session <lock_session_id>. On session_mismatch log warning, do NOT delete (someone took over). On lock_missing no-op, fine.
- lock_acquired === false: skip release entirely.
- This is the ONLY thing letting future runs start. Crash between iter and release with lock_acquired === true not having released → next run sees a 30-min stale window before allowed to acquire (or use --takeover sooner).
31. Optionally cleanup workspace: workspace.mjs cleanup --slug <slug> if status is terminal-success (converged-final, converged, converged-lenient, converged-with-polish). Keep workspace for failed runs so user can inspect.
<critical_invariants> The orchestrator must enforce these during the run. Script-enforced and informational invariants (sandbox, subagent-no-Edit, style rules) live in references/invariants.md.
edit_plan[i].requested_file_path !== null check. The file_path field itself is always plan_path by classifier construction — do NOT confuse the two.thread_mode=continue (default), iter 1 opens a Codex thread (saved as state.codex_thread_id); iters 2..N call codex exec resume <id> AND inject the <reset_iteration> block to limit anchor bias. In thread_mode=fresh every iteration opens a new thread. On lost-session error the wrapper auto-fallbacks to fresh and reports fallback_to_fresh:true; orchestrator unconditionally overwrites state.codex_thread_id (step 16.5).--resume re-acquires (state remembers slug; session_id is regenerated each invocation). Release is gated on lock_acquired === true. init.mjs releases internally if a step AFTER lock-acquire fails; race-fallback (cleanup itself fails) returns lock_acquired:true for orchestrator to retry release in Phase E.sha256(plan) MUST equal state.last_known_plan_hash. Mismatch = external modification = abort the cycle.init.mjs): --resume MUST NOT use the --newest fallback. Resume requires explicit slug/path or active plan in system prompt — init returns abort_reason: resume_no_plan otherwise.state.settings.max_iter MUST NOT exceed 12 — neither via initial --max-iter nor via the continue-prompt at step 21h.severity_aware=true (default), a BLOCK with zero critical+major is TERMINAL, NOT a corrective trigger. Polish findings persist to state.polish_advisory (sourced from apply-fixes.mjs advisory_plan[], deduped, includes manual-classified) and render in Phase E §6 — never auto-applied. Status branches on lenient: true → converged-lenient, false → converged-with-polish. Step 21 (a2) is the single termination point under this mode; legacy step 21 (d) is unreachable.
</critical_invariants>