okstra 0.186.2 → 0.186.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -865,11 +865,11 @@ The manifest-provided `lead-events-*.jsonl` file is the canonical record for str
865
865
 
866
866
  `activityContractVersion: 1` is an interpretation version for new `implementation-planning` artifacts. A run without that field remains a historical run and does not require activity events or an `agentActivity[]` projection.
867
867
 
868
- The shared `okstra report-finalize` entrypoint reads canonical activity before translation source checking. Under contract v3 its in-process `project-activity` step assembles the report, filtering events by run identity and validating activity ID order before `agentActivity[]` is published. A historical v2 manifest retains the in-place projection path. For a non-English report, the lead runs `token-usage`, `project-activity`, and `check-source` before translator dispatch, then resumes at `render-views` after the translation sidecar exists. Conformance compares the resulting `agentActivity[]` IDs, order, and core fields with the canonical events for every lead host.
868
+ The shared `okstra report-finalize` entrypoint reads canonical activity before translation source checking. Under contract v3 its in-process `project-activity` step assembles the report, filtering events by run identity and validating activity ID order before `agentActivity[]` is published. A schema-invalid composed record is still published so `validate-run` can scan it; the step itself fails. A historical v2 manifest retains the in-place projection path. A failed Phase 7 step does not skip later checks: `validate-run` still runs, and only `teardown-stages` is skipped. For a non-English report, the lead runs `token-usage`, `project-activity`, and `check-source` before translator dispatch, then resumes at `render-views` after the translation sidecar exists. Conformance compares the resulting `agentActivity[]` IDs, order, and core fields with the canonical events for every lead host.
869
869
 
870
- Approval blockers use `open`, `answered`, `resolved`, and `obsolete`. Both `open` and `answered` block approval because `answered` means that a user response exists but has not passed application and checking. A response sidecar hides an answered row from the next user-input prompt, but it does not change the approval status of an existing report.
870
+ Approval blockers use `open`, `answered`, `resolved`, and `obsolete`. `open` blocks until the user judges. `answered` with `accept-risk` / `select` / `answer` does not block approval or the next phase; the DISAGREE votes stay on the plan item as evidence. A response sidecar with a proceeding disposition unblocks the same way. `request-revision` and `reject` still withhold the next phase.
871
871
 
872
- An approval row classifies its cause as `user-decision`, `noncritical-dissent`, or `correctness-critical`. A `noncritical-dissent` row can become `passed-with-dissent` only after the user explicitly selects `accept-risk` and the report records the related activity evidence. A `correctness-critical` row cannot use `accept-risk`; it becomes resolved only after the plan is revised and the affected target passes re-verification.
872
+ An approval row classifies its cause as `user-decision`, `noncritical-dissent`, or `correctness-critical`. `accept-risk` is available to all three: it ends the gate and keeps the dissent on the record for later stages. `correctness-critical` can still take `request-revision` when the user wants the plan corrected and re-verified.
873
873
 
874
874
  ## Final report views (HTML)
875
875
 
package/docs/cli.md CHANGED
@@ -862,7 +862,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
862
862
  | `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. |
863
863
  | `okstra design-snapshot --narrative <report-narrative.md> --output <design-preparation.json>` | Detect implementation-planning design surfaces and write the detector-owned snapshot consumed by final report assembly. |
864
864
  | `okstra plan-verify --narrative <report-narrative.md> --state <plan-body-verification.json>` | Recompute the plan-body gate from the convergence-owned state before `data.json` publication. `--report <historical-data.json>` remains the v2 reader. |
865
- | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. Stops at the first non-zero exit except deferred token collection, reports each step, and prints the ordered `--only` recovery tail. This is the shared path for every lead adapter. |
865
+ | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; only `teardown-stages` is skipped so a failed run does not reclaim worktrees. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. This is the shared path for every lead adapter. |
866
866
  | `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly, and schema v3 data uses the same always-generated, task-specific human HTML path. The full reading copy uses `templates/reports/final-report-v2.template.md` and is rendered on demand with `okstra render-final-report`. Passing the Markdown sibling locates the same data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, and Response ID parity. |
867
867
  | `okstra design-prep <list\|show\|write>` | Review AI-prepared implementation design requests, inspect their effective confirmed response, or append a confirmed user/wizard response without editing the planning report |
868
868
  | `okstra wizard <init\|step\|render-args\|confirmation\|outcome> --state-file <path>` | Interactive input state machine for okstra-run, implemented by `okstra_ctl.wizard`. Seed a state file with `init`, then repeatedly call `step --answer <val>` to receive the next `Prompt` JSON. `--answer` is **required**; use `--no-submit` to peek at the next prompt without submitting a response. A `pick` with more choices than the host picker can display keeps `kind: "pick"` but adds `presentation: "numbered-text"`; render every option as a numbered Markdown list and submit the user's 1-based number, exact value, or exact label. Invalid, out-of-range, and ambiguous answers re-prompt without dropping choices. `render-args` returns the final `render-bundle` argument map, and `confirmation` returns the user echo block. On a completed wizard, `outcome` returns `renderArgs`, `persistActions`, and `confirmationText` together; project/global release-handoff PR-template persistence appears as `persistActions[].command == "config.set"`. For an `implementation` task type, `stage_pick` follows `approved_plan_pick` and selects the stage before `executor_pick`. The brief step appears only for entry task types—requirements-discovery, error-analysis, improvement-discovery, project-analysis, feature-analysis, and change-impact-analysis. Analysis inputs use `feature_evidence_pick` / `feature_evidence`, `project_evidence_pick` / `project_evidence`, and `analysis_target_pick` / `analysis_target`; a revision-requested report prioritizes its same-task, same-type rerun. Downstream lifecycle phases automatically carry the manifest brief, with a three-option `brief_carry` fallback when none is registered; `release-handoff` has no brief and enters multi-select `handoff_stage_pick` for eligible stage groups or the whole task |
@@ -221,7 +221,7 @@ Inform the user on one line.
221
221
  Took over as Okstra lead (`<host-runtime>`) for `<taskKey>` (`<task-type>`). Run dir: `<RUN_DIR_RELATIVE_PATH>`. Beginning Phase 1 (context loading).
222
222
  ```
223
223
 
224
- For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. When the run is over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`: open or answered approval blockers → `/okstra-user-response`; awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run); `validate-run` failed → one-line cause then `/okstra-run` or `/okstra-inspect recap`; pointer `ready` → `/okstra-run` for that phase.
224
+ For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. When the run is over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`: open approval blockers → `/okstra-user-response`; a recorded `accept-risk` / `select` / `answer` is not an open blocker; awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run); `validate-run` failed → one-line cause then `/okstra-run` or `/okstra-inspect recap`; pointer `ready` → `/okstra-run` for that phase.
225
225
 
226
226
  ## implementation unattended chaining (chain-stages)
227
227
 
@@ -343,7 +343,7 @@ Important modules:
343
343
  | `plan_derivations.py` | the supersession sweep `_common-contract.md` requires an author to do by hand — extracts the symbols, paths, and ids an answered clarification names and reports every plan string that mentions one. Advisory: it locates candidates and never judges which are now false |
344
344
  | `scope_provenance.py` | single source of truth for the scope-provenance grammar every phase-emitted requirement must declare, shared by `validators/validate-run.py` and `validators/validate_fanout.py` so the planning report and fan-out packets cannot drift |
345
345
  | `worker_artifact_paths.py` | canonical worker artifact path derivation (e.g. `audit_sidecar_rel` inserts `-audit-` after the first `-worker-` token), so dispatch and validation agree on non-canonical-path rejection |
346
- | `report_finalize.py` | Phase 7 post-report sequence **SSOT** — runs `check-source` → `token-usage` → `render-views` → `spawn-followups` → `validate-run` in that load-bearing order, stops at the first non-zero exit and names the failing step except `token-usage`, whose failure defers so the later steps still produce their artifacts. Both lead paths converge here: the Codex adapter calls it in-process (`codex_dispatch`), a Claude-led run reaches it through `okstra report-finalize`. Neither reimplements the sequence |
346
+ | `report_finalize.py` | Phase 7 post-report sequence **SSOT** — runs `check-source` → `token-usage` → `render-views` → `spawn-followups` → `validate-run` in that load-bearing order. A non-zero exit still runs every later check through `validate-run` and names the earliest failure; `teardown-stages` is skipped when any earlier step failed. Both lead paths converge here: the Codex adapter calls it in-process (`codex_dispatch`), a Claude-led run reaches it through `okstra report-finalize`. Neither reimplements the sequence |
347
347
  | `wrapper_status.py` | worker wrapper status sidecar reader — the host-side reader of the sidecar `worker_runner.py` writes. `is_terminal` is the one question it answers for the dispatch record and the pane reclaim: does `stage` read `exited` |
348
348
  | `worker_runner.py` | runs one worker CLI and records what happened — shared by every provider entrypoint. Owns the `selectors` pump over the child's streams, the stream-arrival idle watchdog (`killpg` on breach), the run-wide progress cap on the log copy, and the status sidecar's whole life. A run that dies after launch still closes its sidecar, so `worker_liveness` never reads a dead worker as running |
349
349
  | `session_transcript.py` | worker session transcript — one line per event (time, speaker, body) with a run-wide progress-line cap (`LOG_LINE_CAP`, elision notice) so a single-file dispatch's tool echo cannot dominate the project's `.okstra/` bytes; the fixed shape lets a later lead write share the same file |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.186.2",
3
+ "version": "0.186.4",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.186.2",
3
- "builtAt": "2026-08-24T02:07:41.323Z",
2
+ "package": "0.186.4",
3
+ "builtAt": "2026-08-24T05:51:42.489Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -139,16 +139,17 @@ The sequence is fixed:
139
139
  4. On an answer — record the raw text in the row's `userInput`, set `status: answered` and `userConfirmation: asked-and-answered`, and apply the selected disposition in this run.
140
140
  5. Only when asking fails does the row stay open: `asked-awaiting` when the user has not answered, `deferred-no-interactive-session` when this run has no user to ask.
141
141
 
142
- For report contract v3 `implementation-planning`, record active approval decisions only through `okstra approval-decision`; report assembly derives each report row's status, resolution, and backtraces from that lead-owned ledger plus the activity ledger. Classify a user-owned selection as `user-decision`, a surviving non-correctness majority disagreement as `noncritical-dissent`, and a cited path/symbol mismatch, `P-Req-*` coverage mismatch, or independent Requirement Coverage blocker as `correctness-critical`. `select` is limited to `user-decision`, `accept-risk` is limited to `noncritical-dissent`, and `request-revision` / `reject` are available to all three classifications. `correctness-critical` never offers or records `accept-risk`. Contract v2 remains read-only compatible; do not create a new v2 report. **Enforced:** `scripts/okstra_ctl/approval_decisions.py` rejects invalid option/disposition combinations, and `validators/validate-run.py` `_validate_v3_approval_context` recomputes report backtraces.
142
+ For report contract v3 `implementation-planning`, record active approval decisions only through `okstra approval-decision`; report assembly derives each report row's status, resolution, and backtraces from that lead-owned ledger plus the activity ledger. Classify a user-owned selection as `user-decision`, a surviving non-correctness majority disagreement as `noncritical-dissent`, and a cited path/symbol mismatch, `P-Req-*` coverage mismatch, or independent Requirement Coverage blocker as `correctness-critical`. `select` is limited to `user-decision`. `accept-risk` is available to all three classifications: it ends the gate, keeps the DISAGREE votes and the clarification row as evidence, and later stages read that record. `request-revision` / `reject` are available to all three and still withhold the next phase. Contract v2 remains read-only compatible; do not create a new v2 report. **Enforced:** `scripts/okstra_ctl/approval_decisions.py` rejects invalid option/disposition combinations, and `validators/validate-run.py` `_validate_v3_approval_context` recomputes report backtraces.
143
143
 
144
144
  The approval state transitions are fixed:
145
145
 
146
146
  - `open → answered` when the raw user response is recorded
147
- - `answered → resolved` only after the selected disposition is applied and its checks pass
148
- - `answered → open` when application or checking fails
147
+ - `answered → resolved` after the selected disposition is applied, when that work actually completed
149
148
  - `open → obsolete` only when a plan change removes the question
150
149
 
151
- `open` and `answered` continue to block approval; only `resolved` and `obsolete` are non-blocking. `user-decision` resolves after the choice is applied and structure / extraction / Requirement Coverage checks pass. `noncritical-dissent` resolves only after an explicit `accept-risk` with non-empty user text and activity-backed checks. `correctness-critical` resolves only after the correction's targeted re-verification records `AGREE` or an acceptable `SUPPLEMENT` for every linked item and no independent coverage blocker remains. A user-directed correction does not consume the automatic self-fix limit, and a verification failure after that correction does not restart the automatic loop.
150
+ Do not move `answered` back to `open` because a check failed. The user's choice stands. Record the failed check on the row; later stages still see the DISAGREE votes.
151
+
152
+ `open` blocks until the user judges. `answered` with `select` / `accept-risk` / `answer`, `resolved`, and `obsolete` do not block approval or the next phase. `request-revision` and `reject` still withhold the next phase. `accept-risk` does not require re-verification `AGREE`. The worker votes stay on the plan item so a later stage can still see the dissent. **Enforced:** `scripts/okstra_ctl/clarification_items.py` `row_blocks_progress`, `validators/validate-run.py` `_user_accepted_plan_item_ids`.
152
153
 
153
154
  When a terminal row preserves a pre-correction dissent classification, keep superseded votes in `state/plan-body-verification-implementation-planning-<seq>.json`. Activities that implement or check the decision record the exact `C-NNN` in `clarificationRefs` and the affected `P-*` identifiers in `planItemIds`. Report assembly verifies that every resolution `checkRefs` value names an existing activity and derives each plan item's `clarificationRefs`; the lead never copies those references into `approvalContext`. A corrected coverage-only blocker keeps its `C-NNN` in the non-blocking Requirement Coverage row's `decisionRefs`. An `obsolete` row is invalid while its disagreement or coverage blocker remains active in the current plan. **Enforced:** `scripts/okstra_ctl/report_assembly.py` `_clarification_row` / `_attach_plan_backlinks` and `validators/validate-run.py` `_validate_v3_approval_context`.
154
155
 
@@ -429,7 +430,7 @@ After persistence, reply briefly in the resolved Report Language. **Lead this re
429
430
 
430
431
  Pick the next action from this table; the first matching row wins:
431
432
 
432
- - Open or answered `blocks: approval` rows → `/okstra-user-response` (name the `C-NNN` ids). Do not start implementation and do not re-run planning until those answers exist.
433
+ - Open `blocks: approval` rows → `/okstra-user-response` (name the `C-NNN` ids). Do not start implementation until those answers exist. An `accept-risk` / `select` / `answer` already recorded is not an open blocker.
433
434
  - `workflow.awaitingApproval` is true → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run.
434
435
  - Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase with the recorded sidecar, or `/okstra-inspect recap` if the resume flags are unknown.
435
436
  - Pointer `status: ready` → `/okstra-run` for that `phase`.
@@ -430,18 +430,18 @@ round before any host or provider process starts.
430
430
  - `Blocks=approval`
431
431
  - the report record's `planItems[].clarificationRefs[]` reaching that `C-<N>`. Under contract v3 the report record carries the **plural** field and the v3.0 schema forbids `clarificationId` on a plan item; report assembly derives the refs from the activity ledger's `clarificationRefs[]` + `planItemIds[]`, so record the decision through `okstra approval-decision` rather than writing the link by hand. The lead-owned state file keeps the singular `clarificationId`. `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item reaches no clarification, or reaches one that is missing or not `blocks: approval`.
432
432
  - set `approvalContext.classification` to `user-decision` for a majority `needs-user-input` item, `correctness-critical` for `DISAGREE(a)`, `DISAGREE(f)` on `P-Req-*`, or an independent Requirement Coverage blocker, and `noncritical-dissent` for another surviving majority disagreement.
433
- - record the decision through `okstra approval-decision open`. Each option carries `disposition`, exactly one `reach`, and optional `scopeEffects`. The activity ledger carries affected `planItemIds` and `clarificationRefs`; the approval row never copies those backtrace IDs. `select` is allowed only for `user-decision`, `accept-risk` only for `noncritical-dissent`, and `request-revision` / `reject` for any classification. `correctness-critical` never offers or records `accept-risk`. **Enforced:** `scripts/okstra_ctl/approval_decisions.py` and report assembly.
434
- - **Self-fix exhaustion is not risk acceptance.** A `noncritical-dissent` item remains blocking until the user explicitly selects `accept-risk`. Record the user's non-empty original text and existing activity check references through `okstra approval-decision resolve`; report assembly derives the report `resolution`.
435
- - **Correctness-critical defects cannot be waived.** After the user-directed correction, targeted re-verification of every linked item MUST record only `AGREE` or an acceptable `SUPPLEMENT`, and any independent Requirement Coverage blocker MUST be removed before the row becomes `resolved`. A `DISAGREE` or `verification-error` returns it to `open`. **Enforced:** `validators/validate-run.py` `_validate_correctness_resolution`.
433
+ - record the decision through `okstra approval-decision open`. Each option carries `disposition`, exactly one `reach`, and optional `scopeEffects`. The activity ledger carries affected `planItemIds` and `clarificationRefs`; the approval row never copies those backtrace IDs. `select` is allowed only for `user-decision`. `accept-risk` is allowed for every classification, including `correctness-critical`: it ends the gate and keeps the DISAGREE votes as evidence. `request-revision` / `reject` withhold the next phase. **Enforced:** `scripts/okstra_ctl/approval_decisions.py` and report assembly.
434
+ - **Self-fix exhaustion is not risk acceptance.** A remaining item stays blocking until the user selects `accept-risk`, `select`, or `answer`. Record the user's non-empty original text. Do not require a second verification round to honour `accept-risk`.
435
+ - **Correctness-critical `accept-risk` does not rewrite the votes.** The linked items keep their DISAGREE (or remaining dissent) so a later stage can still see them. `request-revision` is the path that corrects the plan and re-verifies. **Enforced:** `validators/validate-run.py` `_user_accepted_plan_item_ids` / `_validate_correctness_resolution`.
436
436
  - When a correctness-critical `planner-fixable` item is promoted, its `Statement` MUST state "planner self-fix attempted but unresolved" and name the stop reason. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when a planner-fixable majority item is promoted while the budget is not exhausted — it requires `selfFixRoundsApplied >= 1` **and** `selfFixStopReason` in `{no-progress, max-rounds-reached}`, so neither `all-resolved` nor `not-attempted` can excuse a promotion.
437
437
  - Approval state transitions are fixed:
438
438
  - `open → answered` when the raw user response is recorded
439
439
  - `answered → resolved` only after the selected disposition is applied and its checks pass
440
440
  - `answered → open` when application or checking fails
441
441
  - `open → obsolete` only when a plan change removes the question
442
- `open` and `answered` continue to block approval; only `resolved` and `obsolete` are non-blocking. A user-directed correction does not consume the automatic self-fix limit, and a failed check does not restart the automatic loop.
442
+ `open` blocks until the user judges. `answered` with a proceeding disposition (`accept-risk` / `select` / `answer`) does not block. `request-revision` / `reject` still withhold the next phase. A user-directed correction does not consume the automatic self-fix limit, and a failed check does not restart the automatic loop or reopen the row.
443
443
  - A terminal row preserves its original dissent classification only from the convergence-owned state history. Every `user-decision-required` / `user-decision-evaluated` activity cites the row's `C-NNN` in `clarificationRefs` and affected plan items in `planItemIds`. A resolved decision names only existing `A-NNN` checks. Report assembly validates those links and derives the report backtraces; it does not accept copied IDs from the approval ledger. When an independent coverage-only blocker is corrected, keep the `C-NNN` in the non-blocking Requirement Coverage row's `decisionRefs`. `obsolete` is valid only after current evidence shows that the question or blocker disappeared.
444
- 9. Approval lives in the report record `frontmatter.approved` field — there is no in-body marker line. The user may set it to `true` (via `--approve` or the in-session wizard) only when the Gate result is `passed` or `passed-with-dissent`. **Enforced:** run-prep (`scripts/okstra_ctl/run.py` `_validate_approved_plan`) fail-closes an `approved: true` plan whose record carries a blocking `gateResult` or an open/answered `Blocks: approval` clarification row, and `validators/validate-run.py` `_validate_plan_body_gate_recompute` rejects a declared `gateResult` healthier than the recorded votes.
444
+ 9. Approval lives in the report record `frontmatter.approved` field — there is no in-body marker line. The user may set it to `true` (via `--approve` or the in-session wizard) when remaining `Blocks=approval` rows are user-proceeded (`accept-risk` / `select` / `answer`) even if the recorded `gateResult` is still `blocked-by-disagreement`. `aborted-non-result` still withholds approval. **Enforced:** run-prep (`scripts/okstra_ctl/run.py` `_validate_approved_plan` / `_blocking_gate_survives_user_decision`) and `validators/validate-run.py` `_validate_plan_body_gate_recompute`.
445
445
 
446
446
  ## `plan-body-verification-<task-type>-<seq>.json` schema
447
447
 
@@ -112,7 +112,7 @@ Never dispatch the translator before report assembly and `check-source`. For a n
112
112
 
113
113
  `workflow.nextRecommendedPhase` has `phase`, `status`, and `rationale`. The status vocabulary is `ready`, `pending`, `blocked`, and `terminal`.
114
114
 
115
- `phase` is non-empty only for an authored `ready` pointer. `prepare` may lower `ready` to `pending` while retaining `phase` in `scripts/okstra_ctl/render.py::_derive_next_recommended_phase`; readers must use `status` for launchability. When `finalVerification.routingRecommendation.target` is `release-handoff(stage-group)`, write `phase` as `release-handoff`. When `implementationPlanning.outcome` is `plan-ready` and the plan-body gate is `blocked-by-disagreement` or `aborted-non-result`, or a `Blocks=approval` row is still `open`/`answered`, write `status` `blocked` (empty `phase`) — do not write `ready` at `implementation`. When that outcome is `plan-ready` and the plan is approvable, write `ready` at `implementation`; the user still has to approve it separately.
115
+ `phase` is non-empty only for an authored `ready` pointer. `prepare` may lower `ready` to `pending` while retaining `phase` in `scripts/okstra_ctl/render.py::_derive_next_recommended_phase`; readers must use `status` for launchability. When `finalVerification.routingRecommendation.target` is `release-handoff(stage-group)`, write `phase` as `release-handoff`. When `implementationPlanning.outcome` is `plan-ready` and a `Blocks=approval` row still blocks progress (`open`, or `request-revision` / `reject`), or the plan-body gate is `aborted-non-result`, write `status` `blocked` (empty `phase`). `blocked-by-disagreement` with every approval row user-proceeded (`accept-risk` / `select` / `answer`) is approvable write `ready` at `implementation`. The user still has to approve it separately.
116
116
 
117
117
  ## Compatibility
118
118
 
@@ -6,7 +6,7 @@
6
6
  - `scopeEffects` — optional tokens drawn from `{new-schema, deferrable}`.
7
7
  - `addedWork` — one sentence naming the work this choice creates that the other choices do not. Name the files, stages, or commands; do not substitute a cost adjective.
8
8
  - `directionChange` — one sentence naming what this choice reverses: an approved plan item, a recorded decision, an earlier answer. Name that item. When it reverses nothing, say so.
9
- - `disposition` — the effect of selecting the option. Use `select` for `user-decision`, `accept-risk` for `noncritical-dissent`, and `request-revision` or `reject` when the option sends the plan back. `correctness-critical` never offers `accept-risk`.
9
+ - `disposition` — the effect of selecting the option. Use `select` for `user-decision`. Use `accept-risk` on any classification, including `correctness-critical`, when the user ends the gate and leaves the DISAGREE on the record. Use `request-revision` or `reject` when the option sends the plan back.
10
10
  - report assembly derives `approvalContext`, status, and resolution. `approvalContext` contains only `classification`, `unblockCondition`, and `recommendedDisposition`; it never copies plan or activity identifiers.
11
11
  - the three impact fields answer three different questions — how far the change reaches, what new work it creates, and what it overturns. Someone choosing between options needs all three, so never fold them into one sentence: whichever axis is easiest to write would silently stand in for the other two.
12
12
  - a row that omits `options[]`, offers fewer than two, or marks zero or two options as `recommended` is incomplete and must be completed before the report is finalised.
@@ -195,8 +195,8 @@ roles:
195
195
  - dependency / migration risk assessment (ordering constraints, data backfills, feature-flag prerequisites, repo-internal sequencing)
196
196
  - **Cross-Project Dependencies (conditionally required):** when the plan depends on work in another project / repo / published package, add (a) a `kind: cross-project` DM row to `dependencyMigrationRisk`, and (b) a matching `XP-NNN` row to `crossProjectDependencies`. An upstream-precondition row must have concrete `requiredWork` / `verificationSignal` / `howToStart` — `validators/validate-run.py` enforces that a DM `cross-project` ⇒ at least one `direction: upstream-precondition` XP row, and the schema enforces non-empty row fields. A cross-project dependency is recorded as this structured precondition, not as a soft Recommended Next Step. A single-project plan uses an empty array.
197
197
  - **recommendedNextSteps policy:** keep the substance of cross-project preconditions/carries in `crossProjectDependencies`, and put in `§3 Recommended Next Steps` only a pointer to that section (`§5.4 Cross-Project Dependencies`) — no double recording.
198
- - **Resuming from an approval blocker (BLOCKING).** When this report carries an unresolved (`open` or `answered`) `blocks: approval` clarification, `recommendedNextSteps[0]` MUST be a command the reader can run now: `/okstra-user-response`, and the `--answered-clarifications` re-run in that step's `text` or `commands`. Do not write "The Okstra lead will …" as the first step. Point them at `okstra recap assemble`, which prints the answered ids, the exact flag value, the sidecar paths, and whether the re-verification would fall back to full. Do not restate those values here: they are unknown while you write, because the user has not answered yet. Do not tell the reader to start another planning run before the answers exist. **Enforced:** `validators/validate-run.py` `_validate_rerun_guidance`.
199
- - **Asking for approval (BLOCKING).** When `outcome` is `plan-ready`, the gate is `passed` or `passed-with-dissent`, and no `blocks: approval` row is open, one `recommendedNextSteps` entry MUST tell the reader to approve (`--approve` or the in-session wizard). Do not recommend another `implementation-planning` run. **Enforced:** `validators/validate-run.py` `_validate_approval_guidance`.
198
+ - **Resuming from an approval blocker (BLOCKING).** When this report carries a progress-blocking `blocks: approval` clarification (`open`, or `request-revision` / `reject`), `recommendedNextSteps[0]` MUST be a command the reader can run now: `/okstra-user-response`, and the `--answered-clarifications` re-run in that step's `text` or `commands`. Do not write "The Okstra lead will …" as the first step. Point them at `okstra recap assemble`, which prints the answered ids, the exact flag value, the sidecar paths, and whether the re-verification would fall back to full. Do not restate those values here: they are unknown while you write, because the user has not answered yet. Do not tell the reader to start another planning run before the answers exist. An `accept-risk` / `select` / `answer` already recorded is not this case. **Enforced:** `validators/validate-run.py` `_validate_rerun_guidance`.
199
+ - **Asking for approval (BLOCKING).** When `outcome` is `plan-ready` and no `blocks: approval` row still blocks progress, one `recommendedNextSteps` entry MUST tell the reader to approve (`--approve` or the in-session wizard). A remaining `blocked-by-disagreement` whose rows the user already accepted does not send them back to planning. Do not recommend another `implementation-planning` run. **Enforced:** `validators/validate-run.py` `_validate_approval_guidance`.
200
200
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
201
201
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
202
202
  - **Requirement admissibility (scope boundary):** a brief line becomes a Requirement Coverage row only when **a stage can satisfy it by changing files in this repository** — source, tests, config, or deployment *manifest files*. A line whose satisfaction needs a person's approval, a ticket status change, or an action against live infrastructure (applying a manifest, a cutover, creating a dashboard or alert, validating in staging/production) is NOT a requirement for this phase: it belongs to the brief's `## External Gates`, and this plan neither creates a stage for it nor cites it in coverage. Briefs generated by `okstra-brief-gen` pre-split these into the end-state sections `## Expected Behavior` / `## Preserved Behavior` / `## Expected Outcome` (admissible) and `## External Gates` (not); when reading an older brief that carries a raw Definition-of-Done checklist, apply the same test line by line. The boundary is the *action*, not the topic — "add the flag to `values-prod.yaml`" is admissible, "apply that manifest to prod" is not. Planning an operational stage this phase cannot execute (see the run-scope rule above forbidding deployments) produces steps whose commands never resolve, which the §5.5.9 gate then correctly blocks — the plan must not create that deadlock in the first place.
@@ -232,10 +232,9 @@ roles:
232
232
  - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead runs a worker peer-review round on the persisted queue. Selected-direction plans begin with `P-Dir-1`; legacy candidate-comparison plans begin with `P-Opt-*`; both continue with the shared execution items. The fixed order remains initial verification → one planner self-fix → targeted re-verification → user gate. Gate recomputation, extraction completeness, approval-context reconciliation, and self-fix limits remain enforced by `validators/validate-run.py`; verdict details and dissent format are owned by `prompts/lead/plan-body-verification.md`.
233
233
  - **Approval decision state.** The lead records active and carried decisions through `okstra approval-decision`. Every option carries `disposition`, one `reach`, and optional `scopeEffects`. A resolved decision names existing `A-NNN` checks; report assembly derives `approvalContext`, status, resolution, and reverse links.
234
234
  - `open → answered` when the raw user response is recorded
235
- - `answered → resolved` only after the selected disposition is applied and its checks pass
236
- - `answered → open` when application or checking fails
235
+ - `answered → resolved` after the selected disposition is applied, when that work completed
237
236
  - `open → obsolete` only when a plan change removes the question
238
- `open` and `answered` block approval; only `resolved` and `obsolete` are non-blocking. **Enforced:** `validators/validate-run.py` `_validate_approval_context` plus run-prep `scripts/okstra_ctl/run.py` `_validate_approved_plan`.
237
+ `open` blocks until the user judges. `answered` with `accept-risk` / `select` / `answer` does not block. Do not move `answered` back to `open` because a check failed. **Enforced:** `scripts/okstra_ctl/clarification_items.py` `row_blocks_progress`, `validators/validate-run.py` `_validate_approval_context`, run-prep `scripts/okstra_ctl/run.py` `_validate_approved_plan`.
239
238
  - **Terminal approval evidence.** A resolved correctness-critical decision names a later successful evaluation through `resolutionInput.checkRefs`. Each referenced activity carries the same `C-NNN` in `clarificationRefs[]`, the affected `planItemIds[]`, zero-exit commands, and the plan-body state result. Report assembly rejects a missing activity or reverse link before publication. **Enforced:** `scripts/okstra_ctl/report_assembly.py::_clarification_row` and `_attach_plan_backlinks`.
240
239
  - **Decision-record evaluation (sole owner)**: this phase is the **single owner** of decision-record evaluation in the okstra lifecycle. The brief never evaluates or drafts decision records — it only forwards `adr-candidate:*` signals. Every `adr-candidate:*` entry inherited from the brief's `Open Questions` is a mandatory evaluation target. In addition, evaluate every decision the chosen realization introduces against the three criteria:
241
240
  1. **Hard to reverse** — would changing the decision later cost meaningfully more than deciding now?
@@ -21,7 +21,7 @@ CLASSIFICATIONS = frozenset(
21
21
  {"user-decision", "noncritical-dissent", "correctness-critical"}
22
22
  )
23
23
  _FORBIDDEN_BY_CLASSIFICATION = {
24
- "correctness-critical": frozenset({"select", "accept-risk"}),
24
+ "correctness-critical": frozenset({"select"}),
25
25
  "noncritical-dissent": frozenset({"select"}),
26
26
  "user-decision": frozenset(),
27
27
  }
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
 
33
33
  import json
34
34
  import re
35
+ from collections.abc import Mapping
35
36
  from dataclasses import dataclass
36
37
  from pathlib import Path
37
38
  from typing import Optional
@@ -295,6 +296,7 @@ def _v2_row(entry: dict) -> Optional[dict]:
295
296
  if isinstance(options, list)
296
297
  else []
297
298
  ),
299
+ "disposition": clarification_disposition(entry),
298
300
  }
299
301
 
300
302
 
@@ -377,7 +379,8 @@ def parse_section_1_rows(report_text: str) -> list[dict]:
377
379
  return rows
378
380
 
379
381
 
380
- UNRESOLVED_STATUSES = {"open", "answered"}
382
+ # 아직 사용자 판단이 없는 상태. `answered` 는 판단 기록이며 다시 묻지 않는다.
383
+ UNRESOLVED_STATUSES = {"open"}
381
384
 
382
385
  # `Blocks` values that gate the user's `approved: true` flip.
383
386
  APPROVAL_BLOCKS = frozenset({"approval"})
@@ -393,6 +396,76 @@ ANSWER_DISPOSITIONS = frozenset({
393
396
  "request-revision",
394
397
  "reject",
395
398
  })
399
+ # 사용자가 진행을 고른 처분. DISAGREE 표와 행은 증거로 남고 게이트는 내리다.
400
+ PROCEEDING_DISPOSITIONS = frozenset({"answer", "select", "accept-risk"})
401
+ # 사용자가 이 계획으로 진행하지 않겠다고 고른 처분.
402
+ RETURN_DISPOSITIONS = frozenset({"request-revision", "reject"})
403
+
404
+
405
+ def clarification_disposition(row: Mapping[str, object]) -> str:
406
+ """행에 기록된 사용자 처분. 없으면 빈 문자열."""
407
+ resolution = row.get("resolution")
408
+ if not isinstance(resolution, Mapping):
409
+ context = row.get("approvalContext")
410
+ if isinstance(context, Mapping):
411
+ resolution = context.get("resolution")
412
+ if isinstance(resolution, Mapping):
413
+ disposition = str(resolution.get("disposition") or "").strip()
414
+ if disposition:
415
+ return disposition
416
+ user_input = str(row.get("userInput") or "").strip()
417
+ options = row.get("options")
418
+ if user_input and isinstance(options, list):
419
+ for option in options:
420
+ if (
421
+ isinstance(option, Mapping)
422
+ and str(option.get("answer") or "").strip() == user_input
423
+ ):
424
+ return str(option.get("disposition") or "").strip()
425
+ return ""
426
+
427
+
428
+ def row_blocks_progress(status: str, disposition: str = "") -> bool:
429
+ """이 행이 승인·다음 단계 진입을 막는가.
430
+
431
+ 진행 처분(`accept-risk` / `select` / `answer`)은 고치지 않은 DISAGREE 를
432
+ 행과 투표에 남긴 채로 게이트만 내린다. `request-revision` / `reject` 는
433
+ 사용자가 진행을 거절한 것이므로 막는다. 처분이 없는 `answered` 도 판단
434
+ 기록이므로 막지 않는다.
435
+ """
436
+ normalized_status = status.strip().lower()
437
+ normalized_disposition = disposition.strip().lower()
438
+ if normalized_status == "obsolete":
439
+ return False
440
+ if normalized_disposition in PROCEEDING_DISPOSITIONS:
441
+ return False
442
+ if normalized_disposition in RETURN_DISPOSITIONS:
443
+ return True
444
+ return normalized_status not in {"answered", "resolved"}
445
+
446
+
447
+ def progress_blocking_ids(
448
+ rows: object,
449
+ blocking_values: frozenset[str] = APPROVAL_BLOCKS,
450
+ ) -> list[str]:
451
+ """게이트를 아직 막는 행 id. 사용자 진행 처분이 있는 행은 빠진다."""
452
+ if not isinstance(rows, list):
453
+ return []
454
+ ids: list[str] = []
455
+ for row in rows:
456
+ if not isinstance(row, Mapping):
457
+ continue
458
+ blocks = str(row.get("blocks") or "").strip().lower()
459
+ status = str(row.get("status") or "").strip()
460
+ row_id = row.get("id")
461
+ if (
462
+ blocks in blocking_values
463
+ and row_blocks_progress(status, clarification_disposition(row))
464
+ and isinstance(row_id, str)
465
+ and row_id
466
+ ):
467
+ ids.append(row_id)
468
+ return ids
396
469
 
397
470
 
398
471
  @dataclass(frozen=True)
@@ -410,10 +483,18 @@ class ClarificationScan:
410
483
 
411
484
 
412
485
  def scan_approval_gate(report_path: Path) -> ClarificationScan:
413
- """Scan for unresolved ``Blocks=approval`` rows (``Status`` in
414
- ``{open, answered}``), refusing to guess whenever the schema drifted."""
486
+ """Scan for ``Blocks=approval`` rows that still block progress.
487
+
488
+ A recorded user proceeding disposition (`accept-risk` / `select` /
489
+ `answer`), including one that lives only in the sidecar, does not block.
490
+ ``request-revision`` / ``reject`` still block. The scan refuses to guess
491
+ whenever the schema drifted.
492
+ """
415
493
  return scan_clarification_blockers(
416
- report_path, APPROVAL_BLOCKS, honor_sidecar_answers=False
494
+ report_path,
495
+ APPROVAL_BLOCKS,
496
+ honor_sidecar_answers=True,
497
+ sidecar_unblocks_proceeding_only=True,
417
498
  )
418
499
 
419
500
 
@@ -430,43 +511,61 @@ def scan_clarification_blockers(
430
511
  blocking_values: frozenset[str],
431
512
  *,
432
513
  honor_sidecar_answers: bool,
514
+ sidecar_unblocks_proceeding_only: bool = False,
433
515
  ) -> ClarificationScan:
434
516
  """Shared fail-closed clarification walk for both gates above — schema-v2
435
517
  reads its rows from the data sibling and schema-v1 from the §1 table.
436
- ``honor_sidecar_answers`` controls whether a user's sidecar hides answered
437
- rows from this scan; approval deliberately passes ``False``."""
518
+ ``honor_sidecar_answers`` hides rows the sidecar already answered.
519
+ ``sidecar_unblocks_proceeding_only`` keeps ``request-revision`` / ``reject``
520
+ as blockers so a return choice cannot start the next phase.
521
+ """
438
522
  v2_scan = _scan_v2_blockers(report_path, blocking_values)
439
523
  scan = (
440
524
  v2_scan if v2_scan is not None
441
525
  else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
442
526
  )
443
527
  return (
444
- _resolve_blockers_answered_by_user(report_path, scan)
528
+ _resolve_blockers_answered_by_user(
529
+ report_path,
530
+ scan,
531
+ proceeding_only=sidecar_unblocks_proceeding_only,
532
+ )
445
533
  if honor_sidecar_answers
446
534
  else scan
447
535
  )
448
536
 
449
537
 
450
538
  def _resolve_blockers_answered_by_user(
451
- report_path: Path, scan: ClarificationScan
539
+ report_path: Path,
540
+ scan: ClarificationScan,
541
+ *,
542
+ proceeding_only: bool = False,
452
543
  ) -> ClarificationScan:
453
544
  """사용자가 사이드카로 답한 행을 blocker 에서 뺀 스캔.
454
545
 
455
546
  답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
456
547
  run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
457
548
  `Export user response`, `okstra user-response write`)에서는 갱신되지 않는다.
458
- 사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다.
549
+ 사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다. 승인
550
+ 게이트는 진행 처분만 차단에서 뺀다.
459
551
 
460
552
  fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
461
553
  어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
462
554
  """
463
555
  if scan.unreadable_reason is not None or not scan.blockers:
464
556
  return scan
465
- answers = sidecar_answers(report_path)
466
- if not answers:
557
+ dispositions = sidecar_dispositions(report_path)
558
+ if not dispositions:
559
+ return scan
560
+ answered_ids = {
561
+ row_id
562
+ for row_id, disposition in dispositions.items()
563
+ if (not proceeding_only) or disposition in PROCEEDING_DISPOSITIONS
564
+ }
565
+ if not answered_ids:
467
566
  return scan
468
567
  return ClarificationScan(
469
- [b for b in scan.blockers if b.row_id not in answers], None
568
+ [b for b in scan.blockers if b.row_id not in answered_ids], None
470
569
  )
471
570
 
472
571
 
@@ -507,7 +606,7 @@ def scan_section_1_blockers(
507
606
  ))
508
607
  blockers = [
509
608
  it for it in table.items
510
- if it.blocks in blocking_values and it.status in UNRESOLVED_STATUSES
609
+ if it.blocks in blocking_values and row_blocks_progress(it.status)
511
610
  ]
512
611
  return ClarificationScan(blockers, None)
513
612
 
@@ -538,7 +637,9 @@ def _scan_v2_blockers(
538
637
  blockers = [
539
638
  row["item"] for row in rows
540
639
  if row["item"].blocks in blocking_values
541
- and row["item"].status in UNRESOLVED_STATUSES
640
+ and row_blocks_progress(
641
+ row["item"].status, str(row.get("disposition") or "")
642
+ )
542
643
  ]
543
644
  return ClarificationScan(blockers, None)
544
645
 
@@ -655,35 +756,47 @@ def _sidecars_for_attachment(source: Path) -> list[Path]:
655
756
  return sorted([*ordinary, *selected])
656
757
 
657
758
 
658
- def sidecar_answers(source: Path) -> dict[str, str]:
659
- """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
660
-
661
- 사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
662
- 게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
759
+ def _sidecar_answer_records(source: Path) -> dict[str, tuple[str, str]]:
760
+ """사이드카 답을 `{id: (value, disposition)}` 로 모은다.
663
761
 
664
762
  `disposition` 이 `ANSWER_DISPOSITIONS` 에 속하는 항목만 답으로 센다.
665
- `reframe` 은 "다음 run 에서 다시 물어달라" 이지 답이 아니므로
666
- (`skills/okstra-user-response/SKILL.md` "A reframe is not an answer")
667
- 답 집합에서 빠져야 사용자 입력 목록에 그 항목이 계속 남는다.
668
-
669
- 같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
670
- reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
671
- 미해결로 돌아온다. `user_response` 를 지연 import 해 순환 참조를 피한다.
763
+ `reframe` 은 답이 아니므로 집합에서 빠진다. 같은 id 이름순 마지막이
764
+ 이긴다 최신이 reframe 이거나 값이 비면 앞선 답을 지운다.
672
765
  """
673
766
  from okstra_ctl.user_response import parse_user_response_entries
674
767
 
675
- answers: dict[str, str] = {}
768
+ answers: dict[str, tuple[str, str]] = {}
676
769
  for sidecar in user_response_sidecars(source):
677
770
  for entry in parse_user_response_entries(
678
771
  sidecar.read_text(encoding="utf-8")
679
772
  ):
680
773
  if entry.value and entry.disposition in ANSWER_DISPOSITIONS:
681
- answers[entry.response_id] = entry.value
774
+ answers[entry.response_id] = (entry.value, entry.disposition)
682
775
  else:
683
776
  answers.pop(entry.response_id, None)
684
777
  return answers
685
778
 
686
779
 
780
+ def sidecar_answers(source: Path) -> dict[str, str]:
781
+ """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
782
+
783
+ 사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
784
+ 게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
785
+ """
786
+ return {
787
+ row_id: value
788
+ for row_id, (value, _disposition) in _sidecar_answer_records(source).items()
789
+ }
790
+
791
+
792
+ def sidecar_dispositions(source: Path) -> dict[str, str]:
793
+ """사이드카 답의 처분을 `{clarification-id: disposition}` 로 모은다."""
794
+ return {
795
+ row_id: disposition
796
+ for row_id, (_value, disposition) in _sidecar_answer_records(source).items()
797
+ }
798
+
799
+
687
800
  def attached_user_responses_section(source: Path) -> str:
688
801
  """`source` 형제 `user-responses/` 사이드카만 모은 `# Attached User Responses`
689
802
  섹션 본문. 사이드카 부재 시 빈 문자열.
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Mapping
15
15
 
16
+ from .clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
16
17
  from .final_report_paths import final_report_data_path
17
18
  from .exact_coverage import ExactCoverageError, calculate_plan_exact_coverage
18
19
  from .final_report_schema import (
@@ -309,15 +310,13 @@ def _selected_option(
309
310
  def _validate_no_blockers(data: Mapping[str, Any], option: Mapping[str, Any]) -> None:
310
311
  if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
311
312
  raise DirectionSelectionError("selected candidate has a safety blocker")
312
- for row in data.get("clarificationItems") or ():
313
- if (
314
- isinstance(row, Mapping)
315
- and row.get("blocks") == "next-phase"
316
- and row.get("status") not in {"resolved", "obsolete"}
317
- ):
318
- raise DirectionSelectionError(
319
- "selection report has an unresolved next-phase blocker"
320
- )
313
+ blockers = progress_blocking_ids(
314
+ data.get("clarificationItems"), USER_INPUT_BLOCKS
315
+ )
316
+ if blockers:
317
+ raise DirectionSelectionError(
318
+ "selection report has an unresolved next-phase blocker"
319
+ )
321
320
 
322
321
 
323
322
  def _direction_payload(option: Mapping[str, Any]) -> dict[str, Any]:
@@ -9,7 +9,10 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Mapping
11
11
 
12
- from okstra_ctl.clarification_items import APPROVAL_BLOCKS, UNRESOLVED_STATUSES
12
+ from okstra_ctl.clarification_items import (
13
+ APPROVAL_BLOCKS,
14
+ progress_blocking_ids,
15
+ )
13
16
 
14
17
  STATUS_READY = "ready"
15
18
  STATUS_PENDING = "pending"
@@ -219,24 +222,9 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
219
222
 
220
223
 
221
224
  def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
222
- rows = report_data.get("clarificationItems")
223
- if not isinstance(rows, list):
224
- return []
225
- ids: list[str] = []
226
- for row in rows:
227
- if not isinstance(row, Mapping):
228
- continue
229
- blocks = str(row.get("blocks") or "").strip().lower()
230
- status = str(row.get("status") or "").strip().lower()
231
- row_id = row.get("id")
232
- if (
233
- blocks in APPROVAL_BLOCKS
234
- and status in UNRESOLVED_STATUSES
235
- and isinstance(row_id, str)
236
- and row_id
237
- ):
238
- ids.append(row_id)
239
- return ids
225
+ return progress_blocking_ids(
226
+ report_data.get("clarificationItems"), APPROVAL_BLOCKS
227
+ )
240
228
 
241
229
 
242
230
  def _planning_approval_block_reason(
@@ -245,8 +233,10 @@ def _planning_approval_block_reason(
245
233
  """plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
246
234
 
247
235
  자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
248
- 들어온다. 차단은 `blocked-by-disagreement` / `aborted-non-result`
249
- `Status` open/answered 인 `Blocks=approval` 행뿐이다.
236
+ 들어온다. 차단은 `aborted-non-result` 와, 사용자가 아직 진행 처분을
237
+ 고르지 않은 `Blocks=approval` 행이다. `blocked-by-disagreement` 는 그
238
+ 행들이 전부 `accept-risk` / `select` / `answer` 이면 증거가 된 뒤라
239
+ 포인터를 막지 않는다.
250
240
  """
251
241
  ids = _unresolved_approval_ids(report_data)
252
242
  if ids:
@@ -261,11 +251,23 @@ def _planning_approval_block_reason(
261
251
  gate = ""
262
252
  if isinstance(verification, Mapping):
263
253
  gate = str(verification.get("gateResult") or "").strip().lower()
264
- if gate in _BLOCKING_PLAN_GATES:
254
+ if gate == "aborted-non-result":
265
255
  return (
266
256
  f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
267
257
  "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
268
258
  )
259
+ if gate == "blocked-by-disagreement":
260
+ approval_rows = [
261
+ row
262
+ for row in (report_data.get("clarificationItems") or [])
263
+ if isinstance(row, Mapping)
264
+ and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
265
+ ]
266
+ if not approval_rows:
267
+ return (
268
+ f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
269
+ "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
270
+ )
269
271
  return ""
270
272
 
271
273
 
@@ -44,6 +44,7 @@ from typing import Any
44
44
  import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
45
45
  from jinja2 import ChainableUndefined, Environment, FileSystemLoader
46
46
 
47
+ from okstra_ctl.clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
47
48
  from okstra_ctl.final_report_schema import (
48
49
  SchemaError,
49
50
  load_schema_for_data,
@@ -555,14 +556,9 @@ def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
555
556
  )
556
557
  context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
557
558
  context["aiTaskTemplate"] = _markdown_task_template(task_type)
558
- context["aiBlockingIds"] = [
559
- row.get("id")
560
- for row in data.get("clarificationItems", [])
561
- if isinstance(row, dict)
562
- and row.get("status") in {"open", "answered"}
563
- and row.get("blocks") in {"approval", "next-phase"}
564
- and isinstance(row.get("id"), str)
565
- ]
559
+ context["aiBlockingIds"] = progress_blocking_ids(
560
+ data.get("clarificationItems", []), USER_INPUT_BLOCKS
561
+ )
566
562
  sections = ReportSections(data, schema or {})
567
563
  context["md"] = sections.section
568
564
  context["md_rest"] = sections.rest
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Any, Callable, Mapping, Sequence
10
10
 
11
11
  from .agent_activity import agent_activity_rows
12
+ from .clarification_items import clarification_disposition, row_blocks_progress
12
13
  from .final_report_schema import load_schema_version, validate
13
14
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
14
15
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
@@ -330,7 +331,10 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
330
331
  data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
331
332
  clarifications = data.get("clarificationItems") or []
332
333
  blocked = any(
333
- isinstance(row, Mapping) and row.get("status") in {"open", "answered"}
334
+ isinstance(row, Mapping)
335
+ and row_blocks_progress(
336
+ str(row.get("status") or ""), clarification_disposition(row)
337
+ )
334
338
  for row in clarifications
335
339
  )
336
340
  frontmatter = {
@@ -460,7 +464,12 @@ def assemble_report(
460
464
  project_root: Path, manifest_path: Path, *,
461
465
  replace: Callable[[str, str], None] = os.replace,
462
466
  ) -> dict[str, Any]:
463
- """모든 입력이 유효할 때만 계약 3.0 정본을 번 교체한다."""
467
+ """계약 3.0 정본을 조립해 게시한다.
468
+
469
+ 입력이 없어 조립 자체가 안 되면 기존 파일을 건드리지 않는다. 조립은
470
+ 됐는데 스키마만 깨진 기록은 게시한 뒤 거부한다. 게시를 건너뛰면
471
+ `validate-run` 이 이번 런 내용을 보지 못한다.
472
+ """
464
473
  manifest_row = ReportInputPath("run-manifest", "orchestrator", manifest_path)
465
474
  manifest = _read_json(manifest_row)
466
475
  if not uses_report_contract_v3(manifest):
@@ -486,9 +495,13 @@ def assemble_report(
486
495
  raise ReportAssemblyError(tuple(input_issues))
487
496
  data = _compose(project_root, manifest_path, manifest, inputs, schema)
488
497
  errors = validate(data, schema)
498
+ value = manifest.get("expectedReportRecordPath")
499
+ if not isinstance(value, str) or not value:
500
+ _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
501
+ target = Path(value)
502
+ target = target if target.is_absolute() else project_root / target
503
+ _publish(target, data, replace)
489
504
  if errors:
490
- target = Path(str(manifest.get("expectedReportRecordPath") or "data.json"))
491
- target = target if target.is_absolute() else project_root / target
492
505
  raise ReportAssemblyError(
493
506
  tuple(
494
507
  AssemblyIssue(
@@ -500,10 +513,4 @@ def assemble_report(
500
513
  for error in errors
501
514
  )
502
515
  )
503
- value = manifest.get("expectedReportRecordPath")
504
- if not isinstance(value, str) or not value:
505
- _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
506
- target = Path(value)
507
- target = target if target.is_absolute() else project_root / target
508
- _publish(target, data, replace)
509
516
  return data
@@ -7,17 +7,21 @@ read-only compatibility. The order is load-bearing: rendering before assembly
7
7
  or usage collection publishes stale derived views, and validating before
8
8
  rendering trips the report-views contract.
9
9
 
10
- `token-usage` is the one step whose failure does not stop the sequence, which
11
- deliberately accepts that first state: its input is the lead session log, so a
12
- refusal there would otherwise delete every artifact the later steps produce.
13
- The run does not pass in that state. `validators/validate-run.py` re-collects
14
- when the recorded usage is all zeros against an `unavailable` session source
15
- (`_needs_token_autofix`) and refuses the run rather than ship zeroed counts
16
- (`accuracy-failed`); a legacy v1 report is caught earlier still, by its
17
- unsubstituted `{{...}}` placeholders, which a v2 report never carries because
18
- its numeric cells are `null` until this step fills them. Substituting the
19
- tokens on a later retry then leaves the already-rendered html stale for
20
- `validators/validate-report-views.py`.
10
+ A non-zero exit no longer stops the sequence before `validate-run`. A schema
11
+ cap used to hide the contract scan: assembly refused, later steps never ran,
12
+ and the lead only saw those failures after a second finalize. Each step still
13
+ records its own exit; `ok` stays False when any step failed. `teardown-stages`
14
+ is the exception it reclaims worktrees, so a failed prefix skips it.
15
+
16
+ `token-usage` reads the lead session log (state outside this run). A refusal
17
+ there used to delete every later artifact; it no longer does, because the
18
+ sequence continues. `validators/validate-run.py` re-collects when the recorded
19
+ usage is all zeros against an `unavailable` session source (`_needs_token_autofix`)
20
+ and refuses the run rather than ship zeroed counts (`accuracy-failed`). A
21
+ legacy v1 report is caught earlier still, by its unsubstituted `{{...}}`
22
+ placeholders, which a v2 report never carries because its numeric cells are
23
+ `null` until this step fills them. Substituting the tokens on a later retry
24
+ then leaves the already-rendered html stale for `validators/validate-report-views.py`.
21
25
 
22
26
  The translation sidecar is NOT one of these steps. `render-views` overlays it,
23
27
  so a non-English run dispatches the translator before this sequence starts —
@@ -447,26 +451,14 @@ def run_finalize(
447
451
  before_step: Callable[[str], None] | None = None,
448
452
  only: Sequence[str] | None = None,
449
453
  ) -> dict[str, Any]:
450
- """Run the Phase 7 steps in contractual order.
451
-
452
- A non-zero exit stops the sequence, with one exception: ``token-usage``
453
- defers. Its input is the lead session log — state outside this run — so a
454
- refusal there is not evidence that the artifacts after it are unwritable.
455
- The deferred failure becomes the result's ``reason`` when nothing later
456
- fails, and yields to any later failure, which is the step that actually
457
- blocked the run.
458
-
459
- ``before_step`` fires immediately before each step is spawned, letting an
460
- adapter settle state the step will read (the Codex adapter marks the report
461
- writer `completed` before `validate-run` inspects team-state).
462
-
463
- ``only`` restricts the run to the named steps, preserving contractual
464
- order. The last step (`validate-run`) is the one that usually fails, and
465
- re-running the whole sequence to retry it repeats every preceding idempotent
466
- step at full token and wall-clock cost.
454
+ """계약 순서로 Phase 7 돌리고, 실패해도 `validate-run` 까지 모은다.
455
+
456
+ `before_step` 단계 프로세스 직전에 호출된다. Codex 어댑터가
457
+ `validate-run` 앞에 작성기 상태를 `completed` 표시하는 자리이다.
458
+
459
+ `only` 고른 단계만 계약 순서로 남긴다.
467
460
  """
468
461
  steps: list[dict[str, Any]] = []
469
- deferred = ""
470
462
  try:
471
463
  if ctx.report_contract_version != "3.0":
472
464
  write_execution_roles(ctx)
@@ -476,7 +468,9 @@ def run_finalize(
476
468
 
477
469
  if only:
478
470
  selected = set(only)
479
- contract_order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
471
+ contract_order = (
472
+ V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
473
+ )
480
474
  unknown = sorted(selected - set(contract_order))
481
475
  if unknown:
482
476
  return {
@@ -486,75 +480,70 @@ def run_finalize(
486
480
  }
487
481
  commands = [(name, cmd) for name, cmd in commands if name in selected]
488
482
 
483
+ first_failure = ""
489
484
  for name, command in commands:
485
+ # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라진다.
486
+ if name == STEP_TEARDOWN_STAGES and first_failure:
487
+ continue
490
488
  if before_step is not None:
491
489
  before_step(name)
492
- if name == STEP_PROJECT_ACTIVITY:
493
- try:
494
- if ctx.report_contract_version == "3.0":
495
- assembled = assemble_report(ctx.project_root, ctx.manifest_path)
496
- count = len(assembled.get("agentActivity") or [])
497
- else:
498
- rows = project_agent_activity(
499
- ctx.project_root,
500
- ctx.manifest_path,
501
- ctx.data_path,
502
- )
503
- count = len(rows)
504
- except (ActivityProjectionError, ReportAssemblyError) as exc:
505
- result = subprocess.CompletedProcess(
506
- command,
507
- 1,
508
- "",
509
- str(exc),
510
- )
511
- else:
512
- result = subprocess.CompletedProcess(
513
- command,
514
- 0,
515
- json.dumps({"count": count}),
516
- "",
517
- )
518
- else:
519
- result = None
520
- if name == STEP_TEARDOWN_STAGES:
521
- result = _teardown_stage_worktrees(ctx, command)
522
- if name == STEP_VALIDATE_RUN:
523
- try:
524
- _link_lead_result_for_validation(ctx)
525
- except (DispatchError, OSError, json.JSONDecodeError) as exc:
526
- return {
527
- "ok": False,
528
- "reason": f"lead result linkage failed: {exc}",
529
- "steps": steps,
530
- }
531
- if result is None:
532
- result = subprocess.run(
533
- command,
534
- cwd=ctx.project_root,
535
- text=True,
536
- capture_output=True,
537
- )
490
+ result = _run_finalize_step(ctx, name, command)
538
491
  steps.append(step_payload(name, command, result))
539
- if result.returncode != 0:
540
- failure = f"{name} failed with exit code {result.returncode}"
541
- # Token collection reads the lead session log, which lives outside
542
- # this run. One refusal there (`grandTotalTokens=0`) took the whole
543
- # run's output with it: html never rendered, follow-ups never
544
- # spawned, and `validate-run` then blocked on `report-views:
545
- # missing html artifact`. So this step alone defers instead of
546
- # stopping the sequence. It hides nothing — the non-zero exit stays
547
- # in `steps`, `ok` stays False, and the closing `validate-run`
548
- # still refuses the run.
549
- if name == STEP_TOKEN_USAGE:
550
- deferred = failure
551
- continue
552
- return {"ok": False, "reason": failure, "steps": steps}
553
- if deferred:
554
- return {"ok": False, "reason": deferred, "steps": steps}
492
+ if result.returncode != 0 and not first_failure:
493
+ first_failure = f"{name} failed with exit code {result.returncode}"
494
+ if first_failure:
495
+ return {"ok": False, "reason": first_failure, "steps": steps}
555
496
  return {"ok": True, "reason": "", "steps": steps}
556
497
 
557
498
 
499
+ def _run_finalize_step(
500
+ ctx: FinalizeContext,
501
+ name: str,
502
+ command: Sequence[str],
503
+ ) -> subprocess.CompletedProcess[str]:
504
+ """한 Phase 7 단계를 실행하고 그 단계의 종료 코드만 돌려준다."""
505
+ if name == STEP_PROJECT_ACTIVITY:
506
+ return _run_project_activity(ctx, command)
507
+ if name == STEP_TEARDOWN_STAGES:
508
+ return _teardown_stage_worktrees(ctx, command)
509
+ if name == STEP_VALIDATE_RUN:
510
+ try:
511
+ _link_lead_result_for_validation(ctx)
512
+ except (DispatchError, OSError, json.JSONDecodeError) as exc:
513
+ return subprocess.CompletedProcess(
514
+ command, 1, "", f"lead result linkage failed: {exc}"
515
+ )
516
+ return subprocess.run(
517
+ command,
518
+ cwd=ctx.project_root,
519
+ text=True,
520
+ capture_output=True,
521
+ )
522
+
523
+
524
+ def _run_project_activity(
525
+ ctx: FinalizeContext,
526
+ command: Sequence[str],
527
+ ) -> subprocess.CompletedProcess[str]:
528
+ """계약 3.0 은 조립, 그 외는 활동 투영만 한다."""
529
+ try:
530
+ if ctx.report_contract_version == "3.0":
531
+ assembled = assemble_report(ctx.project_root, ctx.manifest_path)
532
+ count = len(assembled.get("agentActivity") or [])
533
+ else:
534
+ rows = project_agent_activity(
535
+ ctx.project_root,
536
+ ctx.manifest_path,
537
+ ctx.data_path,
538
+ )
539
+ count = len(rows)
540
+ except (ActivityProjectionError, ReportAssemblyError) as exc:
541
+ return subprocess.CompletedProcess(command, 1, "", str(exc))
542
+ return subprocess.CompletedProcess(
543
+ command, 0, json.dumps({"count": count}), ""
544
+ )
545
+
546
+
558
547
  def write_execution_roles(ctx: FinalizeContext) -> None:
559
548
  """Write the exact manifest role set onto the final-report data.json."""
560
549
  if not ctx.data_path.is_file() or not ctx.manifest_path.is_file():
@@ -685,13 +674,11 @@ def _recovery_step_names(
685
674
  """The steps a retry has to re-run: every step from the earliest failure on.
686
675
 
687
676
  Naming only the failed steps would prescribe half a recovery. `token-usage`
688
- defers, so `render-views` already wrote an html view from unsubstituted
689
- data; substituting the tokens on a retry leaves that view stale
677
+ can fail while `render-views` still writes html from unsubstituted data;
678
+ substituting the tokens on a retry leaves that view stale
690
679
  (`validators/validate-report-views.py` checks `source-sha256` against the
691
- md body). And a sequence that stopped early never reached `validate-run`,
692
- which is the step that decides whether the run is shippable. Resuming from
693
- the earliest failure redoes both while still skipping the prefix that
694
- succeeded — the saving `--only` exists for.
680
+ md body). Resuming from the earliest failure redoes the tail while skipping
681
+ the prefix that succeeded the saving `--only` exists for.
695
682
  """
696
683
  failed = {
697
684
  string_value(step.get("name"))
@@ -49,8 +49,10 @@ from .analysis_inputs import (
49
49
  )
50
50
  from .stage_fix_carry import derive_stage_fix_carry
51
51
  from .clarification_items import (
52
+ APPROVAL_BLOCKS,
52
53
  attached_user_responses_section,
53
54
  clarification_response_with_sidecars,
55
+ progress_blocking_ids,
54
56
  scan_approval_gate,
55
57
  )
56
58
  from .error_report import prior_run_error_digest
@@ -283,6 +285,29 @@ def _data_json_gate_result(data: dict) -> str:
283
285
  return str(verification.get("gateResult") or "").strip().lower()
284
286
 
285
287
 
288
+ def _blocking_gate_survives_user_decision(data: dict, gate: str) -> bool:
289
+ """사용자가 진행 처분을 골라도 이 게이트 값이 승인을 막는가.
290
+
291
+ `aborted-non-result` 는 투표가 없어 사용자 판단의 대상이 아니다.
292
+ `blocked-by-disagreement` 는 승인 행이 있고 그 행이 전부 진행 처분이면
293
+ DISAGREE 를 증거로 남긴 채 막지 않는다. 승인 행이 없으면 판단 기록이
294
+ 없으므로 막는다.
295
+ """
296
+ if gate != "blocked-by-disagreement":
297
+ return True
298
+ rows = data.get("clarificationItems")
299
+ if not isinstance(rows, list):
300
+ return True
301
+ has_approval_row = any(
302
+ isinstance(row, dict)
303
+ and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
304
+ for row in rows
305
+ )
306
+ return (not has_approval_row) or bool(
307
+ progress_blocking_ids(rows, APPROVAL_BLOCKS)
308
+ )
309
+
310
+
286
311
  def _record_approved_flag(path: Path) -> bool | None:
287
312
  """정본 `frontmatter.approved`. 정본이 없으면(schema-v1) None."""
288
313
  loaded = _load_final_report_data_if_present(path)
@@ -317,7 +342,10 @@ def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> No
317
342
  if loaded is not None:
318
343
  data_path, data = loaded
319
344
  data_gate = _data_json_gate_result(data)
320
- if data_gate in BLOCKING_PLAN_BODY_GATES:
345
+ if (
346
+ data_gate in BLOCKING_PLAN_BODY_GATES
347
+ and _blocking_gate_survives_user_decision(data, data_gate)
348
+ ):
321
349
  raise PrepareError(
322
350
  f"{action} rejected because approved plan data.json Gate result is "
323
351
  f"`{data_gate}`: {data_path}\n"
@@ -421,7 +449,10 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
421
449
  return False
422
450
  data_path, data = loaded
423
451
  data_gate = _data_json_gate_result(data)
424
- if data_gate in BLOCKING_PLAN_BODY_GATES:
452
+ if (
453
+ data_gate in BLOCKING_PLAN_BODY_GATES
454
+ and _blocking_gate_survives_user_decision(data, data_gate)
455
+ ):
425
456
  raise PrepareError(
426
457
  f"--approve rejected because approved plan data.json Gate result is "
427
458
  f"`{data_gate}`: {data_path}"
@@ -555,7 +586,7 @@ def _validate_approved_plan(path: str) -> None:
555
586
  _reject_blocking_plan_body_gate(p, "", action="approved plan validation")
556
587
  _validate_approved_plan_conformance(p)
557
588
  # frontmatter approved == true 상태. §1 Clarification Items 의
558
- # Blocks=approval 행이 아직 open/answered 승인을 무효화한다.
589
+ # Blocks=approval 행이 아직 진행 처분 없이 열려 있으면 승인을 무효화한다.
559
590
  scan = scan_approval_gate(p)
560
591
  if scan.unreadable_reason:
561
592
  raise PrepareError(
@@ -57,6 +57,7 @@ from okstra_ctl.paths import resolve_under_root
57
57
  from okstra_ctl.run_context import dir_flock
58
58
  from okstra_ctl.clarification_items import (
59
59
  ClarificationItem,
60
+ UNRESOLVED_STATUSES,
60
61
  read_clarification_rows,
61
62
  sidecar_answers,
62
63
  _section_1_slice,
@@ -885,7 +886,7 @@ def _open_blocker_rows(report_path: Path) -> list[dict[str, Any]]:
885
886
  row
886
887
  for row in rows
887
888
  if row["item"].blocks in {"approval", "next-phase"}
888
- and row["item"].status in {"open", "answered"}
889
+ and row["item"].status in UNRESOLVED_STATUSES
889
890
  and row["item"].row_id not in answered
890
891
  ]
891
892
 
@@ -390,4 +390,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
390
390
 
391
391
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
392
392
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
393
- - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open or answered `blocks: approval` rows → `/okstra-user-response`. Awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
393
+ - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. Awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
@@ -88,6 +88,13 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
88
88
  stages_for_clarification,
89
89
  )
90
90
  from okstra_ctl import next_phase # noqa: E402
91
+ from okstra_ctl.clarification_items import ( # noqa: E402
92
+ APPROVAL_BLOCKS,
93
+ PROCEEDING_DISPOSITIONS,
94
+ clarification_disposition,
95
+ progress_blocking_ids,
96
+ row_blocks_progress,
97
+ )
91
98
  from okstra_ctl.workflow import ( # noqa: E402
92
99
  ERROR_ANALYSIS_ROUTING_DIRECTIONS,
93
100
  PHASE_SEQUENCE,
@@ -4188,40 +4195,31 @@ def _plan_item_ids_for_clarification(
4188
4195
  ]
4189
4196
 
4190
4197
 
4191
- def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4192
- """Plan items whose remaining dissent the user explicitly accepted."""
4198
+ def _user_accepted_plan_item_ids(data: dict) -> set[str]:
4199
+ """사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
4200
+
4201
+ DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
4202
+ """
4193
4203
  accepted: set[str] = set()
4194
4204
  for row in data.get("clarificationItems") or []:
4195
4205
  if not isinstance(row, dict) or row.get("blocks") != "approval":
4196
4206
  continue
4197
- context = row.get("approvalContext")
4198
- if not isinstance(context, dict):
4199
- continue
4200
- resolution = _clarification_resolution(row, context)
4201
- if (
4202
- row.get("status") != "resolved"
4203
- or context.get("classification") != "noncritical-dissent"
4204
- or not isinstance(resolution, dict)
4205
- or resolution.get("disposition") != "accept-risk"
4206
- or not str(resolution.get("userText") or "").strip()
4207
+ if row_blocks_progress(
4208
+ str(row.get("status") or ""), clarification_disposition(row)
4207
4209
  ):
4208
4210
  continue
4209
- row_id = str(row.get("id") or "")
4210
- if context.get("planItemIds") or context.get("activityIds"):
4211
- if not _approval_context_activity_refs_exist(
4212
- data, row_id, context, resolution
4213
- ):
4214
- continue
4215
- accepted.update(
4216
- item_id
4217
- for item_id in context.get("planItemIds") or []
4218
- if isinstance(item_id, str)
4219
- )
4220
- continue
4211
+ context = row.get("approvalContext")
4212
+ if not isinstance(context, dict):
4213
+ context = {}
4221
4214
  accepted.update(_plan_item_ids_for_clarification(row, context, data))
4222
4215
  return accepted
4223
4216
 
4224
4217
 
4218
+ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4219
+ """호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
4220
+ return _user_accepted_plan_item_ids(data)
4221
+
4222
+
4225
4223
  def _is_dissent_downgraded(
4226
4224
  item: dict,
4227
4225
  pbv: dict,
@@ -4229,16 +4227,11 @@ def _is_dissent_downgraded(
4229
4227
  ) -> bool:
4230
4228
  """Whether a surviving `majority-disagree` item stops blocking approval.
4231
4229
 
4232
- Exhausting the automatic self-fix budget records the unresolved dissent but
4233
- does not accept it. Only an explicit, resolved noncritical risk-acceptance
4234
- row can lower the item to `has-dissent`. Correctness-critical defects remain
4235
- blocking regardless of the user's selected disposition.
4230
+ 사용자 진행 처분(`accept-risk` / `select` / `answer`)이 있으면 표는 남기고
4231
+ 게이트만 `has-dissent` 내린다. 분류와 자가수정 소진 여부는 보지 않는다.
4236
4232
  """
4237
4233
  return (
4238
4234
  _classify_plan_item_gate(item) == "majority-disagree"
4239
- and not _is_correctness_critical(item)
4240
- and _has_planner_fixable_majority(item)
4241
- and _self_fix_budget_exhausted(pbv)
4242
4235
  and str(item.get("id") or "") in accepted_item_ids
4243
4236
  )
4244
4237
 
@@ -4554,11 +4547,15 @@ def _gate_blocking_causes(
4554
4547
 
4555
4548
 
4556
4549
  _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
4557
- "user-decision": frozenset({"select", "request-revision", "reject"}),
4550
+ "user-decision": frozenset(
4551
+ {"select", "accept-risk", "request-revision", "reject"}
4552
+ ),
4558
4553
  "noncritical-dissent": frozenset(
4559
4554
  {"accept-risk", "request-revision", "reject"}
4560
4555
  ),
4561
- "correctness-critical": frozenset({"request-revision", "reject"}),
4556
+ "correctness-critical": frozenset(
4557
+ {"accept-risk", "request-revision", "reject"}
4558
+ ),
4562
4559
  }
4563
4560
 
4564
4561
 
@@ -5026,6 +5023,8 @@ def _validate_resolved_approval(
5026
5023
  f"final-report data.json: resolved approval clarification `{row_id}` "
5027
5024
  "requires non-empty resolution.userText."
5028
5025
  )
5026
+ if str(resolution.get("disposition") or "") in PROCEEDING_DISPOSITIONS:
5027
+ return
5029
5028
  check_refs = resolution.get("checkRefs")
5030
5029
  if not isinstance(check_refs, list) or not any(
5031
5030
  isinstance(value, str) and value for value in check_refs
@@ -5174,6 +5173,7 @@ def _validate_correctness_resolution(
5174
5173
  if (
5175
5174
  context.get("classification") != "correctness-critical"
5176
5175
  or row.get("status") != "resolved"
5176
+ or clarification_disposition(row) in PROCEEDING_DISPOSITIONS
5177
5177
  ):
5178
5178
  return
5179
5179
  row_id = str(row.get("id") or "<unknown>")
@@ -5426,11 +5426,13 @@ def _validate_approval_context(
5426
5426
  activity_timestamps,
5427
5427
  failures,
5428
5428
  )
5429
- if report_approved and row.get("status") in {"open", "answered"}:
5429
+ if report_approved and row_blocks_progress(
5430
+ str(row.get("status") or ""), clarification_disposition(row)
5431
+ ):
5430
5432
  failures.append(
5431
5433
  f"final-report data.json: approval is true while clarification `{row_id}` "
5432
- f"has status `{row.get('status')}`; open and answered approval "
5433
- "rows remain blocking."
5434
+ f"has status `{row.get('status')}`; open and return-disposition "
5435
+ "approval rows remain blocking."
5434
5436
  )
5435
5437
 
5436
5438
 
@@ -5502,7 +5504,9 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5502
5504
  row, context, failures, schema_version="3.0"
5503
5505
  )
5504
5506
  _validate_v3_resolution_links(row, activities, failures)
5505
- if approved and row.get("status") in {"open", "answered"}:
5507
+ if approved and row_blocks_progress(
5508
+ str(row.get("status") or ""), clarification_disposition(row)
5509
+ ):
5506
5510
  failures.append(
5507
5511
  f"final-report data.json: approval is true while clarification "
5508
5512
  f"`{row.get('id')}` remains `{row.get('status')}`."
@@ -6261,11 +6265,8 @@ def _next_step_texts(steps: object) -> list[str]:
6261
6265
 
6262
6266
 
6263
6267
  def _has_unresolved_approval_blocker(data: dict) -> bool:
6264
- return any(
6265
- isinstance(row, dict)
6266
- and row.get("blocks") == "approval"
6267
- and str(row.get("status") or "") in {"open", "answered"}
6268
- for row in data.get("clarificationItems") or []
6268
+ return bool(
6269
+ progress_blocking_ids(data.get("clarificationItems"), APPROVAL_BLOCKS)
6269
6270
  )
6270
6271
 
6271
6272
 
@@ -6277,7 +6278,14 @@ def _planning_gate_blocks_approval(data: dict) -> bool:
6277
6278
  if not isinstance(verification, dict):
6278
6279
  return False
6279
6280
  gate = str(verification.get("gateResult") or "").strip().lower()
6280
- return gate in {"blocked-by-disagreement", "aborted-non-result"}
6281
+ if gate == "aborted-non-result":
6282
+ return True
6283
+ if gate != "blocked-by-disagreement":
6284
+ return False
6285
+ return _has_unresolved_approval_blocker(data) or not any(
6286
+ isinstance(row, dict) and row.get("blocks") == "approval"
6287
+ for row in data.get("clarificationItems") or []
6288
+ )
6281
6289
 
6282
6290
 
6283
6291
  def _validate_rerun_guidance(data: dict, failures: list[str]) -> None: