okstra 0.186.1 → 0.186.3

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.
Files changed (34) hide show
  1. package/docs/architecture.md +3 -3
  2. package/docs/for-ai/skills/okstra-run.md +1 -1
  3. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  4. package/docs/project-structure-overview.md +2 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/okstra-lead-contract.md +18 -5
  8. package/runtime/prompts/lead/plan-body-verification.md +5 -5
  9. package/runtime/prompts/lead/report-writer.md +1 -1
  10. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +4 -5
  12. package/runtime/python/okstra_ctl/approval_decisions.py +1 -1
  13. package/runtime/python/okstra_ctl/clarification_items.py +141 -28
  14. package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
  15. package/runtime/python/okstra_ctl/implementation_direction.py +8 -9
  16. package/runtime/python/okstra_ctl/next_phase.py +24 -22
  17. package/runtime/python/okstra_ctl/render_final_report.py +4 -8
  18. package/runtime/python/okstra_ctl/report_assembly.py +5 -1
  19. package/runtime/python/okstra_ctl/run.py +34 -3
  20. package/runtime/python/okstra_ctl/user_response.py +2 -1
  21. package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
  22. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
  23. package/runtime/skills/okstra-run/SKILL.md +1 -1
  24. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  25. package/runtime/templates/reports/html/assets/base.css +19 -9
  26. package/runtime/templates/reports/html/assets/base.js +21 -0
  27. package/runtime/templates/reports/html/base.template.html +5 -2
  28. package/runtime/templates/reports/html/i18n/en.json +1 -0
  29. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  30. package/runtime/templates/reports/html/macros/forms.html +8 -3
  31. package/runtime/templates/reports/report.js +33 -0
  32. package/runtime/validators/validate-run.py +138 -47
  33. package/runtime/validators/validate_analysis_report.py +7 -6
  34. package/runtime/validators/validate_session_conformance.py +24 -3
@@ -867,9 +867,9 @@ The manifest-provided `lead-events-*.jsonl` file is the canonical record for str
867
867
 
868
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.
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
 
@@ -887,7 +887,7 @@ Entry points:
887
887
  - Python routing references: `scripts/okstra_ctl/report_views.py` owns the version split; `scripts/okstra_ctl/report_html/router.py` maps all v2 task types to dedicated view-model builders and templates. The HTML JavaScript response serializer remains byte-identical to Python `serialize_user_response`.
888
888
  - CLI: `scripts/okstra-render-report-views.py <final-report.data.json|final-report.md>` or delegated Node wrapper `bin/okstra render-views <path>`.
889
889
  - Validation: `validators/validate-report-views.py` checks schema/source/template digests, required human fields, form controls, external assets, SVG/table ID parity, and Response ID parity. The v2 renderer rejects an unknown task type instead of falling back to a generic page.
890
- - User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
890
+ - User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). The picker shows those choices only — it does not append `Enter directly`; custom text uses the host free-text row. A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
891
891
  - In-run user confirmation before an approval blocker follows the same briefing order and the same host picker: the lead investigates cited plan items, worker findings, and files, then asks through the adapter `prompt_user` mapping (`prompts/lead/okstra-lead-contract.md`).
892
892
 
893
893
  Generating a view never modifies the original final-report MD.
@@ -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. After an `implementation-planning` run, if `workflow.awaitingApproval` is true, tell the user to approve the plan; do not start another planning run. If the pointer is `blocked`, send the user to `okstra-user-response` first.
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
 
@@ -27,8 +27,8 @@ The legacy `list` and `show` JSON commands remain for automation compatibility.
27
27
  ## Flow
28
28
 
29
29
  1. Run `okstra preflight --runtime <host-runtime>` for the current harness. On `Okstra preflight: failed`, show `Reason` and `Recovery`, then stop. On `Okstra preflight: ready`, carry `Project root`, `Project ID`, `Runtime`, and `Relay contract`, then run `okstra paths --field home`.
30
- 2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another.
31
- 3. Select a task from `list-view` through that host picker.
30
+ 2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another. Pass only the choices this step already owns. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row are `Enters an answer`; on a numbered list, so is a next message that is not a listed label or its 1-based number. Do not ask a second question for the custom value.
31
+ 3. Select a task from `list-view` through that host picker. A host free-text row or unmatched next message is the report path or task key.
32
32
  4. Read only `show-view --report <reportPath> --project-root <projectRoot>` for report facts. The view also prints `Why asked`, `Linked plan items`, and `Cited artifacts`.
33
33
  5. Read every cited `path:line` under the project root and every linked plan-item definition before asking. Do not search beyond that list. Investigation explains; it never changes `options[]`.
34
34
  6. Ask one open clarification at a time in the user's language through the host picker. The question body is why, what is already decided, the fork, and what stays blocked. Do not lead with `Kind`, `Blocks`, `Expected form`, or `C-NNN`. Keep the row id in parentheses at the end.
@@ -277,6 +277,7 @@ Important modules:
277
277
  | `report_assembly.py` | Validates the role-owned report inputs and publishes the contract 3.0 record once (assembled in a temp file, then atomically promoted); a bad input aborts with owner / artifact path / field path / reason and preserves the existing `data.json` |
278
278
  | `report_projections.py` | Pure projections turning role-owned execution inputs (agent activity, execution status, convergence, design, token usage) into canonical final-report fragments |
279
279
  | `report_narrative.py` | Lossless read/write contract for the report-writer-owned narrative Markdown (`report-narrative-<task-type>-<seq>.md`) — the only artifact the report-writer authors under contract 3.0 |
280
+ | `report_synthesis_packet.py` | Builds the frozen, read-only synthesis input packet handed to the report-writer — collects each source's label / owner / path / content digest into `report-writer-synthesis-packet-<task-type>-<seq>.{json,md}`, materialized at dispatch by `initial_prompt_materialization.py` so the writer synthesizes from a byte-stable snapshot. `report_assembly.py` calls `verify_report_synthesis_packet_sources` at publish time so a drifted source aborts the record; schema is `schemas/report-synthesis-packet-v1.0.schema.json` |
280
281
  | `approval_decisions.py` | Lead-owned approval-decision input ledger — `disposition` (`select`/`accept-risk`/`request-revision`/`reject`), reach, scope effect, and classification invariants (`correctness-critical`/`noncritical-dissent`/`user-decision`) |
281
282
  | `design_snapshot.py` | Builds the design-surface-detector-owned snapshot from the report narrative — reproducible design surfaces plus conservative `PREP-NNN` preparation items (delegates surface detection to `design_surfaces.py`) |
282
283
  | `report_markdown.py` | Schema-ordered Markdown serialisation of a data.json subtree for the full reading copy — headings, tables for uniform row sets, prose for narrative fields; field order read from the schema, not from the mapping |
@@ -696,4 +697,4 @@ Clarifications now live in the unified `## 1. Clarification Items` table. Deprec
696
697
 
697
698
  ---
698
699
 
699
- *Updated: 2026-08-23 · Source of truth checked against `package.json`, `bin/okstra`, `src/cli-registry.mts`, `src/lib/skill-catalog.mts`, `tools/build.mjs`, `scripts/`, `skills/`, `agents/`, `templates/`, `schemas/`, `validators/`, and tests.*
700
+ *Updated: 2026-08-24 · Source of truth checked against `package.json`, `bin/okstra`, `src/cli-registry.mts`, `src/lib/skill-catalog.mts`, `tools/build.mjs`, `scripts/`, `skills/`, `agents/`, `templates/`, `schemas/`, `validators/`, and tests.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.186.1",
3
+ "version": "0.186.3",
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.1",
3
- "builtAt": "2026-08-23T17:23:36.757Z",
2
+ "package": "0.186.3",
3
+ "builtAt": "2026-08-24T04:42:58.072Z",
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
 
@@ -425,7 +426,19 @@ jq -s 'group_by(.errorType) | map({type: .[0].errorType, count: length})' <runDi
425
426
 
426
427
  The errors log is informational. Its presence/absence does not affect the final verdict. Do not block report writing on it.
427
428
 
428
- After persistence, reply briefly in the resolved Report Language with: completion status, the human report path, the report record path, team-state path, validator result, resume command path, any remaining blocker. For `implementation-planning` that left `workflow.awaitingApproval` true, the first sentence after the task identity tells the user to approve this plan (`okstra-run` → `implementation`, which asks `approve_plan_confirm`, or `--approve`) and does not propose another planning run. For a `blocked` pointer after planning, the first sentence names the open `C-NNN` ids and sends the user to `okstra-user-response`; do not start implementation and do not re-run planning until those answers exist. **Lead this reply with the run's task identity** — state `<task-group>/<task-id>` (or the full `taskKey`) first, so the reader knows which task the reply is about. **Every run-artifact path in this reply MUST be task-qualified** — report the human report as `.okstra/tasks/<task-group>/<task-id>/runs/<task-type>/reports/final-report-<task-type>-<seq>.html` rooted at the task bundle, NOT the bare `runs/<task-type>/reports/...` form (byte-for-byte identical across every task of the same task-type, so it cannot identify the task). Under that, cite the report record (`.data.json`) and one line to render the full reading copy: `okstra render-final-report <task-qualified data.json>`. The same task-qualified rule applies to the team-state path, resume command path, and any other run-artifact path this reply cites.
429
+ After persistence, reply briefly in the resolved Report Language. **Lead this reply with the run's task identity** — state `<task-group>/<task-id>` (or the full `taskKey`) first. Then: completion status, the task-qualified human report path, the report record path, validator result, any remaining blocker. **Close with the user's next action** one command they can run now. A prohibition (`do not start implementation`) is not a next action. A status dump is not a close.
430
+
431
+ Pick the next action from this table; the first matching row wins:
432
+
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.
434
+ - `workflow.awaitingApproval` is true → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run.
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.
436
+ - Pointer `status: ready` → `/okstra-run` for that `phase`.
437
+ - Otherwise → `/okstra-inspect status` for this task.
438
+
439
+ When the host native picker is available and two of those rows could apply, ask with that picker (recommended first). Do not end the turn after the status dump.
440
+
441
+ **Every run-artifact path in this reply MUST be task-qualified** — report the human report as `.okstra/tasks/<task-group>/<task-id>/runs/<task-type>/reports/final-report-<task-type>-<seq>.html` rooted at the task bundle, NOT the bare `runs/<task-type>/reports/...` form (byte-for-byte identical across every task of the same task-type, so it cannot identify the task). Under that, cite the report record (`.data.json`) and one line to render the full reading copy: `okstra render-final-report <task-qualified data.json>`. The same task-qualified rule applies to the team-state path, resume command path, and any other run-artifact path this reply cites.
429
442
 
430
443
  ## Run-scoped worker-resource lifecycle
431
444
 
@@ -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 any `blocks: approval` clarification, one `recommendedNextSteps` entry MUST name the `--answered-clarifications` re-run, in its `text` or in one of its `commands`. The reader of the report is the person who has to act, and until now the mechanics of resuming lived only in the lead prompt which is read after the next run has already started. 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. The first human action is answering those rows (`okstra-user-response`); 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
  섹션 본문. 사이드카 부재 시 빈 문자열.