okstra 0.186.2 → 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 +2 -2
- package/docs/for-ai/skills/okstra-run.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +6 -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/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/skills/okstra-run/SKILL.md +1 -1
- package/runtime/validators/validate-run.py +51 -43
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
|
|
|
@@ -221,7 +221,7 @@ Inform the user on one line.
|
|
|
221
221
|
Took over as Okstra lead (`<host-runtime>`) for `<taskKey>` (`<task-type>`). Run dir: `<RUN_DIR_RELATIVE_PATH>`. Beginning Phase 1 (context loading).
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. When the run is over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`: open
|
|
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
|
|
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
|
|
|
@@ -429,7 +430,7 @@ After persistence, reply briefly in the resolved Report Language. **Lead this re
|
|
|
429
430
|
|
|
430
431
|
Pick the next action from this table; the first matching row wins:
|
|
431
432
|
|
|
432
|
-
- Open
|
|
433
|
+
- Open `blocks: approval` rows → `/okstra-user-response` (name the `C-NNN` ids). Do not start implementation until those answers exist. An `accept-risk` / `select` / `answer` already recorded is not an open blocker.
|
|
433
434
|
- `workflow.awaitingApproval` is true → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run.
|
|
434
435
|
- Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase with the recorded sidecar, or `/okstra-inspect recap` if the resume flags are unknown.
|
|
435
436
|
- Pointer `status: ready` → `/okstra-run` for that `phase`.
|
|
@@ -430,18 +430,18 @@ round before any host or provider process starts.
|
|
|
430
430
|
- `Blocks=approval`
|
|
431
431
|
- the report record's `planItems[].clarificationRefs[]` reaching that `C-<N>`. Under contract v3 the report record carries the **plural** field and the v3.0 schema forbids `clarificationId` on a plan item; report assembly derives the refs from the activity ledger's `clarificationRefs[]` + `planItemIds[]`, so record the decision through `okstra approval-decision` rather than writing the link by hand. The lead-owned state file keeps the singular `clarificationId`. `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item reaches no clarification, or reaches one that is missing or not `blocks: approval`.
|
|
432
432
|
- set `approvalContext.classification` to `user-decision` for a majority `needs-user-input` item, `correctness-critical` for `DISAGREE(a)`, `DISAGREE(f)` on `P-Req-*`, or an independent Requirement Coverage blocker, and `noncritical-dissent` for another surviving majority disagreement.
|
|
433
|
-
- record the decision through `okstra approval-decision open`. Each option carries `disposition`, exactly one `reach`, and optional `scopeEffects`. The activity ledger carries affected `planItemIds` and `clarificationRefs`; the approval row never copies those backtrace IDs. `select` is allowed only for `user-decision
|
|
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
|
섹션 본문. 사이드카 부재 시 빈 문자열.
|
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any, Mapping
|
|
15
15
|
|
|
16
|
+
from .clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
|
|
16
17
|
from .final_report_paths import final_report_data_path
|
|
17
18
|
from .exact_coverage import ExactCoverageError, calculate_plan_exact_coverage
|
|
18
19
|
from .final_report_schema import (
|
|
@@ -309,15 +310,13 @@ def _selected_option(
|
|
|
309
310
|
def _validate_no_blockers(data: Mapping[str, Any], option: Mapping[str, Any]) -> None:
|
|
310
311
|
if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
|
|
311
312
|
raise DirectionSelectionError("selected candidate has a safety blocker")
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
"selection report has an unresolved next-phase blocker"
|
|
320
|
-
)
|
|
313
|
+
blockers = progress_blocking_ids(
|
|
314
|
+
data.get("clarificationItems"), USER_INPUT_BLOCKS
|
|
315
|
+
)
|
|
316
|
+
if blockers:
|
|
317
|
+
raise DirectionSelectionError(
|
|
318
|
+
"selection report has an unresolved next-phase blocker"
|
|
319
|
+
)
|
|
321
320
|
|
|
322
321
|
|
|
323
322
|
def _direction_payload(option: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -9,7 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Mapping
|
|
11
11
|
|
|
12
|
-
from okstra_ctl.clarification_items import
|
|
12
|
+
from okstra_ctl.clarification_items import (
|
|
13
|
+
APPROVAL_BLOCKS,
|
|
14
|
+
progress_blocking_ids,
|
|
15
|
+
)
|
|
13
16
|
|
|
14
17
|
STATUS_READY = "ready"
|
|
15
18
|
STATUS_PENDING = "pending"
|
|
@@ -219,24 +222,9 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
ids: list[str] = []
|
|
226
|
-
for row in rows:
|
|
227
|
-
if not isinstance(row, Mapping):
|
|
228
|
-
continue
|
|
229
|
-
blocks = str(row.get("blocks") or "").strip().lower()
|
|
230
|
-
status = str(row.get("status") or "").strip().lower()
|
|
231
|
-
row_id = row.get("id")
|
|
232
|
-
if (
|
|
233
|
-
blocks in APPROVAL_BLOCKS
|
|
234
|
-
and status in UNRESOLVED_STATUSES
|
|
235
|
-
and isinstance(row_id, str)
|
|
236
|
-
and row_id
|
|
237
|
-
):
|
|
238
|
-
ids.append(row_id)
|
|
239
|
-
return ids
|
|
225
|
+
return progress_blocking_ids(
|
|
226
|
+
report_data.get("clarificationItems"), APPROVAL_BLOCKS
|
|
227
|
+
)
|
|
240
228
|
|
|
241
229
|
|
|
242
230
|
def _planning_approval_block_reason(
|
|
@@ -245,8 +233,10 @@ def _planning_approval_block_reason(
|
|
|
245
233
|
"""plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
|
|
246
234
|
|
|
247
235
|
자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
|
|
248
|
-
들어온다. 차단은 `
|
|
249
|
-
|
|
236
|
+
들어온다. 차단은 `aborted-non-result` 와, 사용자가 아직 진행 처분을
|
|
237
|
+
고르지 않은 `Blocks=approval` 행이다. `blocked-by-disagreement` 는 그
|
|
238
|
+
행들이 전부 `accept-risk` / `select` / `answer` 이면 증거가 된 뒤라
|
|
239
|
+
포인터를 막지 않는다.
|
|
250
240
|
"""
|
|
251
241
|
ids = _unresolved_approval_ids(report_data)
|
|
252
242
|
if ids:
|
|
@@ -261,11 +251,23 @@ def _planning_approval_block_reason(
|
|
|
261
251
|
gate = ""
|
|
262
252
|
if isinstance(verification, Mapping):
|
|
263
253
|
gate = str(verification.get("gateResult") or "").strip().lower()
|
|
264
|
-
if gate
|
|
254
|
+
if gate == "aborted-non-result":
|
|
265
255
|
return (
|
|
266
256
|
f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
|
|
267
257
|
"구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
|
|
268
258
|
)
|
|
259
|
+
if gate == "blocked-by-disagreement":
|
|
260
|
+
approval_rows = [
|
|
261
|
+
row
|
|
262
|
+
for row in (report_data.get("clarificationItems") or [])
|
|
263
|
+
if isinstance(row, Mapping)
|
|
264
|
+
and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
|
|
265
|
+
]
|
|
266
|
+
if not approval_rows:
|
|
267
|
+
return (
|
|
268
|
+
f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
|
|
269
|
+
"구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
|
|
270
|
+
)
|
|
269
271
|
return ""
|
|
270
272
|
|
|
271
273
|
|
|
@@ -44,6 +44,7 @@ from typing import Any
|
|
|
44
44
|
import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
|
|
45
45
|
from jinja2 import ChainableUndefined, Environment, FileSystemLoader
|
|
46
46
|
|
|
47
|
+
from okstra_ctl.clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
|
|
47
48
|
from okstra_ctl.final_report_schema import (
|
|
48
49
|
SchemaError,
|
|
49
50
|
load_schema_for_data,
|
|
@@ -555,14 +556,9 @@ def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
|
|
|
555
556
|
)
|
|
556
557
|
context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
|
|
557
558
|
context["aiTaskTemplate"] = _markdown_task_template(task_type)
|
|
558
|
-
context["aiBlockingIds"] =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if isinstance(row, dict)
|
|
562
|
-
and row.get("status") in {"open", "answered"}
|
|
563
|
-
and row.get("blocks") in {"approval", "next-phase"}
|
|
564
|
-
and isinstance(row.get("id"), str)
|
|
565
|
-
]
|
|
559
|
+
context["aiBlockingIds"] = progress_blocking_ids(
|
|
560
|
+
data.get("clarificationItems", []), USER_INPUT_BLOCKS
|
|
561
|
+
)
|
|
566
562
|
sections = ReportSections(data, schema or {})
|
|
567
563
|
context["md"] = sections.section
|
|
568
564
|
context["md_rest"] = sections.rest
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Callable, Mapping, Sequence
|
|
10
10
|
|
|
11
11
|
from .agent_activity import agent_activity_rows
|
|
12
|
+
from .clarification_items import clarification_disposition, row_blocks_progress
|
|
12
13
|
from .final_report_schema import load_schema_version, validate
|
|
13
14
|
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
|
|
14
15
|
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
@@ -330,7 +331,10 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
330
331
|
data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
|
|
331
332
|
clarifications = data.get("clarificationItems") or []
|
|
332
333
|
blocked = any(
|
|
333
|
-
isinstance(row, Mapping)
|
|
334
|
+
isinstance(row, Mapping)
|
|
335
|
+
and row_blocks_progress(
|
|
336
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
337
|
+
)
|
|
334
338
|
for row in clarifications
|
|
335
339
|
)
|
|
336
340
|
frontmatter = {
|
|
@@ -49,8 +49,10 @@ from .analysis_inputs import (
|
|
|
49
49
|
)
|
|
50
50
|
from .stage_fix_carry import derive_stage_fix_carry
|
|
51
51
|
from .clarification_items import (
|
|
52
|
+
APPROVAL_BLOCKS,
|
|
52
53
|
attached_user_responses_section,
|
|
53
54
|
clarification_response_with_sidecars,
|
|
55
|
+
progress_blocking_ids,
|
|
54
56
|
scan_approval_gate,
|
|
55
57
|
)
|
|
56
58
|
from .error_report import prior_run_error_digest
|
|
@@ -283,6 +285,29 @@ def _data_json_gate_result(data: dict) -> str:
|
|
|
283
285
|
return str(verification.get("gateResult") or "").strip().lower()
|
|
284
286
|
|
|
285
287
|
|
|
288
|
+
def _blocking_gate_survives_user_decision(data: dict, gate: str) -> bool:
|
|
289
|
+
"""사용자가 진행 처분을 골라도 이 게이트 값이 승인을 막는가.
|
|
290
|
+
|
|
291
|
+
`aborted-non-result` 는 투표가 없어 사용자 판단의 대상이 아니다.
|
|
292
|
+
`blocked-by-disagreement` 는 승인 행이 있고 그 행이 전부 진행 처분이면
|
|
293
|
+
DISAGREE 를 증거로 남긴 채 막지 않는다. 승인 행이 없으면 판단 기록이
|
|
294
|
+
없으므로 막는다.
|
|
295
|
+
"""
|
|
296
|
+
if gate != "blocked-by-disagreement":
|
|
297
|
+
return True
|
|
298
|
+
rows = data.get("clarificationItems")
|
|
299
|
+
if not isinstance(rows, list):
|
|
300
|
+
return True
|
|
301
|
+
has_approval_row = any(
|
|
302
|
+
isinstance(row, dict)
|
|
303
|
+
and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
|
|
304
|
+
for row in rows
|
|
305
|
+
)
|
|
306
|
+
return (not has_approval_row) or bool(
|
|
307
|
+
progress_blocking_ids(rows, APPROVAL_BLOCKS)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
286
311
|
def _record_approved_flag(path: Path) -> bool | None:
|
|
287
312
|
"""정본 `frontmatter.approved`. 정본이 없으면(schema-v1) None."""
|
|
288
313
|
loaded = _load_final_report_data_if_present(path)
|
|
@@ -317,7 +342,10 @@ def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> No
|
|
|
317
342
|
if loaded is not None:
|
|
318
343
|
data_path, data = loaded
|
|
319
344
|
data_gate = _data_json_gate_result(data)
|
|
320
|
-
if
|
|
345
|
+
if (
|
|
346
|
+
data_gate in BLOCKING_PLAN_BODY_GATES
|
|
347
|
+
and _blocking_gate_survives_user_decision(data, data_gate)
|
|
348
|
+
):
|
|
321
349
|
raise PrepareError(
|
|
322
350
|
f"{action} rejected because approved plan data.json Gate result is "
|
|
323
351
|
f"`{data_gate}`: {data_path}\n"
|
|
@@ -421,7 +449,10 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
|
|
|
421
449
|
return False
|
|
422
450
|
data_path, data = loaded
|
|
423
451
|
data_gate = _data_json_gate_result(data)
|
|
424
|
-
if
|
|
452
|
+
if (
|
|
453
|
+
data_gate in BLOCKING_PLAN_BODY_GATES
|
|
454
|
+
and _blocking_gate_survives_user_decision(data, data_gate)
|
|
455
|
+
):
|
|
425
456
|
raise PrepareError(
|
|
426
457
|
f"--approve rejected because approved plan data.json Gate result is "
|
|
427
458
|
f"`{data_gate}`: {data_path}"
|
|
@@ -555,7 +586,7 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
555
586
|
_reject_blocking_plan_body_gate(p, "", action="approved plan validation")
|
|
556
587
|
_validate_approved_plan_conformance(p)
|
|
557
588
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
558
|
-
# Blocks=approval 행이 아직
|
|
589
|
+
# Blocks=approval 행이 아직 진행 처분 없이 열려 있으면 승인을 무효화한다.
|
|
559
590
|
scan = scan_approval_gate(p)
|
|
560
591
|
if scan.unreadable_reason:
|
|
561
592
|
raise PrepareError(
|
|
@@ -57,6 +57,7 @@ from okstra_ctl.paths import resolve_under_root
|
|
|
57
57
|
from okstra_ctl.run_context import dir_flock
|
|
58
58
|
from okstra_ctl.clarification_items import (
|
|
59
59
|
ClarificationItem,
|
|
60
|
+
UNRESOLVED_STATUSES,
|
|
60
61
|
read_clarification_rows,
|
|
61
62
|
sidecar_answers,
|
|
62
63
|
_section_1_slice,
|
|
@@ -885,7 +886,7 @@ def _open_blocker_rows(report_path: Path) -> list[dict[str, Any]]:
|
|
|
885
886
|
row
|
|
886
887
|
for row in rows
|
|
887
888
|
if row["item"].blocks in {"approval", "next-phase"}
|
|
888
|
-
and row["item"].status in
|
|
889
|
+
and row["item"].status in UNRESOLVED_STATUSES
|
|
889
890
|
and row["item"].row_id not in answered
|
|
890
891
|
]
|
|
891
892
|
|
|
@@ -390,4 +390,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
|
|
|
390
390
|
|
|
391
391
|
- Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
|
|
392
392
|
- Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
|
|
393
|
-
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open
|
|
393
|
+
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. Awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
|
|
@@ -88,6 +88,13 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
|
|
|
88
88
|
stages_for_clarification,
|
|
89
89
|
)
|
|
90
90
|
from okstra_ctl import next_phase # noqa: E402
|
|
91
|
+
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
92
|
+
APPROVAL_BLOCKS,
|
|
93
|
+
PROCEEDING_DISPOSITIONS,
|
|
94
|
+
clarification_disposition,
|
|
95
|
+
progress_blocking_ids,
|
|
96
|
+
row_blocks_progress,
|
|
97
|
+
)
|
|
91
98
|
from okstra_ctl.workflow import ( # noqa: E402
|
|
92
99
|
ERROR_ANALYSIS_ROUTING_DIRECTIONS,
|
|
93
100
|
PHASE_SEQUENCE,
|
|
@@ -4188,40 +4195,31 @@ def _plan_item_ids_for_clarification(
|
|
|
4188
4195
|
]
|
|
4189
4196
|
|
|
4190
4197
|
|
|
4191
|
-
def
|
|
4192
|
-
"""
|
|
4198
|
+
def _user_accepted_plan_item_ids(data: dict) -> set[str]:
|
|
4199
|
+
"""사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
|
|
4200
|
+
|
|
4201
|
+
DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
|
|
4202
|
+
"""
|
|
4193
4203
|
accepted: set[str] = set()
|
|
4194
4204
|
for row in data.get("clarificationItems") or []:
|
|
4195
4205
|
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
4196
4206
|
continue
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
continue
|
|
4200
|
-
resolution = _clarification_resolution(row, context)
|
|
4201
|
-
if (
|
|
4202
|
-
row.get("status") != "resolved"
|
|
4203
|
-
or context.get("classification") != "noncritical-dissent"
|
|
4204
|
-
or not isinstance(resolution, dict)
|
|
4205
|
-
or resolution.get("disposition") != "accept-risk"
|
|
4206
|
-
or not str(resolution.get("userText") or "").strip()
|
|
4207
|
+
if row_blocks_progress(
|
|
4208
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
4207
4209
|
):
|
|
4208
4210
|
continue
|
|
4209
|
-
|
|
4210
|
-
if context
|
|
4211
|
-
|
|
4212
|
-
data, row_id, context, resolution
|
|
4213
|
-
):
|
|
4214
|
-
continue
|
|
4215
|
-
accepted.update(
|
|
4216
|
-
item_id
|
|
4217
|
-
for item_id in context.get("planItemIds") or []
|
|
4218
|
-
if isinstance(item_id, str)
|
|
4219
|
-
)
|
|
4220
|
-
continue
|
|
4211
|
+
context = row.get("approvalContext")
|
|
4212
|
+
if not isinstance(context, dict):
|
|
4213
|
+
context = {}
|
|
4221
4214
|
accepted.update(_plan_item_ids_for_clarification(row, context, data))
|
|
4222
4215
|
return accepted
|
|
4223
4216
|
|
|
4224
4217
|
|
|
4218
|
+
def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
|
|
4219
|
+
"""호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
|
|
4220
|
+
return _user_accepted_plan_item_ids(data)
|
|
4221
|
+
|
|
4222
|
+
|
|
4225
4223
|
def _is_dissent_downgraded(
|
|
4226
4224
|
item: dict,
|
|
4227
4225
|
pbv: dict,
|
|
@@ -4229,16 +4227,11 @@ def _is_dissent_downgraded(
|
|
|
4229
4227
|
) -> bool:
|
|
4230
4228
|
"""Whether a surviving `majority-disagree` item stops blocking approval.
|
|
4231
4229
|
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
row can lower the item to `has-dissent`. Correctness-critical defects remain
|
|
4235
|
-
blocking regardless of the user's selected disposition.
|
|
4230
|
+
사용자 진행 처분(`accept-risk` / `select` / `answer`)이 있으면 표는 남기고
|
|
4231
|
+
게이트만 `has-dissent` 로 내린다. 분류와 자가수정 소진 여부는 보지 않는다.
|
|
4236
4232
|
"""
|
|
4237
4233
|
return (
|
|
4238
4234
|
_classify_plan_item_gate(item) == "majority-disagree"
|
|
4239
|
-
and not _is_correctness_critical(item)
|
|
4240
|
-
and _has_planner_fixable_majority(item)
|
|
4241
|
-
and _self_fix_budget_exhausted(pbv)
|
|
4242
4235
|
and str(item.get("id") or "") in accepted_item_ids
|
|
4243
4236
|
)
|
|
4244
4237
|
|
|
@@ -4554,11 +4547,15 @@ def _gate_blocking_causes(
|
|
|
4554
4547
|
|
|
4555
4548
|
|
|
4556
4549
|
_APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
|
|
4557
|
-
"user-decision": frozenset(
|
|
4550
|
+
"user-decision": frozenset(
|
|
4551
|
+
{"select", "accept-risk", "request-revision", "reject"}
|
|
4552
|
+
),
|
|
4558
4553
|
"noncritical-dissent": frozenset(
|
|
4559
4554
|
{"accept-risk", "request-revision", "reject"}
|
|
4560
4555
|
),
|
|
4561
|
-
"correctness-critical": frozenset(
|
|
4556
|
+
"correctness-critical": frozenset(
|
|
4557
|
+
{"accept-risk", "request-revision", "reject"}
|
|
4558
|
+
),
|
|
4562
4559
|
}
|
|
4563
4560
|
|
|
4564
4561
|
|
|
@@ -5026,6 +5023,8 @@ def _validate_resolved_approval(
|
|
|
5026
5023
|
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
5027
5024
|
"requires non-empty resolution.userText."
|
|
5028
5025
|
)
|
|
5026
|
+
if str(resolution.get("disposition") or "") in PROCEEDING_DISPOSITIONS:
|
|
5027
|
+
return
|
|
5029
5028
|
check_refs = resolution.get("checkRefs")
|
|
5030
5029
|
if not isinstance(check_refs, list) or not any(
|
|
5031
5030
|
isinstance(value, str) and value for value in check_refs
|
|
@@ -5174,6 +5173,7 @@ def _validate_correctness_resolution(
|
|
|
5174
5173
|
if (
|
|
5175
5174
|
context.get("classification") != "correctness-critical"
|
|
5176
5175
|
or row.get("status") != "resolved"
|
|
5176
|
+
or clarification_disposition(row) in PROCEEDING_DISPOSITIONS
|
|
5177
5177
|
):
|
|
5178
5178
|
return
|
|
5179
5179
|
row_id = str(row.get("id") or "<unknown>")
|
|
@@ -5426,11 +5426,13 @@ def _validate_approval_context(
|
|
|
5426
5426
|
activity_timestamps,
|
|
5427
5427
|
failures,
|
|
5428
5428
|
)
|
|
5429
|
-
if report_approved and
|
|
5429
|
+
if report_approved and row_blocks_progress(
|
|
5430
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
5431
|
+
):
|
|
5430
5432
|
failures.append(
|
|
5431
5433
|
f"final-report data.json: approval is true while clarification `{row_id}` "
|
|
5432
|
-
f"has status `{row.get('status')}`; open and
|
|
5433
|
-
"rows remain blocking."
|
|
5434
|
+
f"has status `{row.get('status')}`; open and return-disposition "
|
|
5435
|
+
"approval rows remain blocking."
|
|
5434
5436
|
)
|
|
5435
5437
|
|
|
5436
5438
|
|
|
@@ -5502,7 +5504,9 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
|
5502
5504
|
row, context, failures, schema_version="3.0"
|
|
5503
5505
|
)
|
|
5504
5506
|
_validate_v3_resolution_links(row, activities, failures)
|
|
5505
|
-
if approved and
|
|
5507
|
+
if approved and row_blocks_progress(
|
|
5508
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
5509
|
+
):
|
|
5506
5510
|
failures.append(
|
|
5507
5511
|
f"final-report data.json: approval is true while clarification "
|
|
5508
5512
|
f"`{row.get('id')}` remains `{row.get('status')}`."
|
|
@@ -6261,11 +6265,8 @@ def _next_step_texts(steps: object) -> list[str]:
|
|
|
6261
6265
|
|
|
6262
6266
|
|
|
6263
6267
|
def _has_unresolved_approval_blocker(data: dict) -> bool:
|
|
6264
|
-
return
|
|
6265
|
-
|
|
6266
|
-
and row.get("blocks") == "approval"
|
|
6267
|
-
and str(row.get("status") or "") in {"open", "answered"}
|
|
6268
|
-
for row in data.get("clarificationItems") or []
|
|
6268
|
+
return bool(
|
|
6269
|
+
progress_blocking_ids(data.get("clarificationItems"), APPROVAL_BLOCKS)
|
|
6269
6270
|
)
|
|
6270
6271
|
|
|
6271
6272
|
|
|
@@ -6277,7 +6278,14 @@ def _planning_gate_blocks_approval(data: dict) -> bool:
|
|
|
6277
6278
|
if not isinstance(verification, dict):
|
|
6278
6279
|
return False
|
|
6279
6280
|
gate = str(verification.get("gateResult") or "").strip().lower()
|
|
6280
|
-
|
|
6281
|
+
if gate == "aborted-non-result":
|
|
6282
|
+
return True
|
|
6283
|
+
if gate != "blocked-by-disagreement":
|
|
6284
|
+
return False
|
|
6285
|
+
return _has_unresolved_approval_blocker(data) or not any(
|
|
6286
|
+
isinstance(row, dict) and row.get("blocks") == "approval"
|
|
6287
|
+
for row in data.get("clarificationItems") or []
|
|
6288
|
+
)
|
|
6281
6289
|
|
|
6282
6290
|
|
|
6283
6291
|
def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
|