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.
- package/docs/architecture.md +3 -3
- package/docs/for-ai/skills/okstra-run.md +1 -1
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +2 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +18 -5
- package/runtime/prompts/lead/plan-body-verification.md +5 -5
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +4 -5
- package/runtime/python/okstra_ctl/approval_decisions.py +1 -1
- package/runtime/python/okstra_ctl/clarification_items.py +141 -28
- package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
- package/runtime/python/okstra_ctl/implementation_direction.py +8 -9
- package/runtime/python/okstra_ctl/next_phase.py +24 -22
- package/runtime/python/okstra_ctl/render_final_report.py +4 -8
- package/runtime/python/okstra_ctl/report_assembly.py +5 -1
- package/runtime/python/okstra_ctl/run.py +34 -3
- package/runtime/python/okstra_ctl/user_response.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +4 -2
- package/runtime/templates/reports/html/assets/base.css +19 -9
- package/runtime/templates/reports/html/assets/base.js +21 -0
- package/runtime/templates/reports/html/base.template.html +5 -2
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/html/macros/forms.html +8 -3
- package/runtime/templates/reports/report.js +33 -0
- package/runtime/validators/validate-run.py +138 -47
- package/runtime/validators/validate_analysis_report.py +7 -6
- package/runtime/validators/validate_session_conformance.py +24 -3
package/docs/architecture.md
CHANGED
|
@@ -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`.
|
|
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`.
|
|
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
|
|
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-
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
434
|
-
- **Self-fix exhaustion is not risk acceptance.** A
|
|
435
|
-
- **Correctness-critical
|
|
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`
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
199
|
-
- **Asking for approval (BLOCKING).** When `outcome` is `plan-ready
|
|
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`
|
|
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`
|
|
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"
|
|
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
|
-
|
|
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
|
|
414
|
-
|
|
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,
|
|
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``
|
|
437
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
466
|
-
if not
|
|
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
|
|
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
|
|
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
|
|
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
|
|
659
|
-
"""
|
|
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` 은
|
|
666
|
-
|
|
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
|
섹션 본문. 사이드카 부재 시 빈 문자열.
|