okstra 0.199.2 → 0.199.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cli.md CHANGED
@@ -815,9 +815,9 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
815
815
  | `okstra plan-items derivations --data <data.json> --response <user-response sidecar> [--clarification C-NNN]` | List the plan statements an answered clarification may have falsified. Extracts the symbols, paths, and ids the answer names (backticked spans plus `R-001` / `DEV-10174`-style ids) and reports every string in the plan body that mentions one, as a JSON pointer plus excerpt. Advisory: it says where a decision's subject is mentioned, never which mentions are now wrong — the supersession rule (`prompts/profiles/_common-contract.md`) requires the author to enumerate before editing, and this supplies the enumeration |
816
816
  | `okstra plan-items <prepare\|prompt\|validate-prepared> --run-manifest <path> …` | Bind the implementation-planning verification queue to the run manifest. `prepare` extracts the exact queue from `--narrative` and, when `designPreparation.mode` is `no-design-inputs` and the Stage Map has one row, flips `convergence.planBodyVerification.gating` to `false` (stdout `Gating`). `prompt` emits its fixed lossless view, ending with the parser-facing `## Response format` block so the block reaches every verifier with the queue; `validate-prepared` proves the prepared queue still matches the narrative. With `--state <plan-body-verification.json>` the round is a re-verification: `prepare` also carries each queued item's recorded votes and `selfFixNote` into the envelope as `priorRounds`, `prompt` renders them as that item's `**Prior round dissent**` block behind a re-verification preamble, and `validate-prepared` re-derives the carry and rejects an envelope that dropped it. Python resolves the convergence-owned state path, so model callers never choose it. |
817
817
  | `okstra plan-items seed --narrative <report-narrative.md> --state <plan-body-verification.json> [--prior-state <previous plan-body-verification.json>]` | Create the convergence-owned `planBodyVerification.planItems[]` rows every verdict lands in, from the same deterministic extraction `extract` uses. The historical v2 form is `--data <data.json>`. Idempotent by id: an existing row keeps its verdicts and carried fields. Reports `seeded` / `existing` counts. `--prior-state` carries the previous **run**'s verdicts into this one: a newly seeded item whose `contentHash` equals that run's `verifiedContentHash` for the same id inherits its `verdicts[]` and is tagged `carriedForwardFromSeq` with the seq read off the prior filename, so round 1 does not re-judge text nobody changed. A matching id alone never carries — `P-*` ids are positional and shift. It requires `--state`, refuses a prior state whose task root differs from the one `--state` lives under (the state file carries no task identity, so its path is the only identity there is), and when it carries anything it rewrites the sibling `plan-items-*.json` `dispatchQueue` the way `incremental-carry` does. Adds `carried` / `carriedForwardFromSeq` to the reported counts. |
818
- | `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md>… --round <N>` | Read each worker's Markdown verdict directly, validate every current `P-*` id, reject duplicate worker submissions, and overwrite that round's stored verdicts. `--items <plan-items artifact>` narrows the assignment to what a partial round actually dispatched — pass the `--tie-vote` artifact on a critic tie round, or every item the critic was never given is refused as unanswered. The unanswered check still applies inside the narrowed set. The historical `--verdicts <file>` form remains automation compatibility only. Without `--append` every recorded verdict row of the queued items is replaced; when a row belongs to a round `complete-round` never closed, the command refuses before writing and names the `complete-round --round <M>` to run first. `--discard-open-rounds` replaces anyway — the recovery path when those rounds are being re-applied from their result files in order (the discarded rows are printed); it also restores `dispatchQueue` to the items those result files answer, since the persisted queue belongs to the latest round and an earlier round's verdicts would otherwise be refused as outside it. |
819
- | `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N> [--self-fix-note <item-id>=<markdown-file>]… [--self-fix-group <cause-file>=<item-id>[,<item-id>...]]… [--self-fix-stop-reason <all-resolved\|no-progress\|max-rounds-reached>]` | After `plan-verify` succeeds, atomically derive and record the round's per-item votes, gate result, participant counts from the actual assigned roster, immutable completion time, convergence history, and optional self-fix notes/groups read from Markdown files. `--self-fix-group` requires `--self-fix-stop-reason` — there is no default. `--self-fix-stop-reason` alone records a stop for a round that rewrote nothing and leaves `selfFixGroups` / `selfFixRoundsApplied` untouched. Models do not write the state JSON. Stdout also carries `nextDispatch`. A second automatic rewrite is rejected; the one rewrite may contain several cause groups. `selfFixRoundsApplied` remains its verification round number, not a rewrite count. |
820
- | `okstra plan-items next-dispatch --state <plan-body-verification.json> [--run-manifest <path>]` | After `apply-verdicts`, decide whether this round opens a worker batch. `kind: none` — missing-dependency `UNVERIFIABLE` only, no new batch. `kind: worker-correction` — re-prompt only those workers; peers stay idle. `kind: critic-tie` — unsettled analyser 1-1 on a run that rostered a critic, `critic-worker` on those item ids only. `kind: user-decision` — the same 1-1 on a run with no critic rostered: no in-band vote can break it, so open one `okstra approval-decision open` per item (classification `noncritical-dissent`) plus its `## 1. Clarification Items` row and dispatch no further verification for them. `--run-manifest` is what tells the two apart (`invocationAssignments` `critic/*`); without it the answer stays `critic-tie`. A missing path is never environment-unverifiable. After the single automatic self-fix, remaining items route to `lead-decision` first, then `user-decision`, with no further automatic worker batch. |
818
+ | `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md>… --round <N>` | Read each worker's Markdown verdict directly, validate every current `P-*` id, reject duplicate worker submissions, and overwrite that round's stored verdicts. `--items <plan-items artifact>` narrows and persists the assignment shared by verdict validation and round completion to what a partial round actually dispatched — pass the `--tie-vote` artifact on a critic tie round, or every item the critic was never given is refused as unanswered. The unanswered check still applies inside the narrowed set. Earlier verdicts and completed-round history outside that set remain unchanged. The historical `--verdicts <file>` form remains automation compatibility only. Without `--append` every recorded verdict row of the queued items is replaced; when a row belongs to a round `complete-round` never closed, the command refuses before writing and names the `complete-round --round <M>` to run first. `--discard-open-rounds` replaces anyway — the recovery path when those rounds are being re-applied from their result files in order (the discarded rows are printed); it also restores `dispatchQueue` to the items those result files answer, since the persisted queue belongs to the latest round and an earlier round's verdicts would otherwise be refused as outside it. |
819
+ | `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N> [--items <plan-items artifact>] [--self-fix-note <item-id>=<markdown-file>]… [--self-fix-group <cause-file>=<item-id>[,<item-id>...]]… [--self-fix-stop-reason <all-resolved\|no-progress\|max-rounds-reached>]` | After `plan-verify` succeeds, atomically derive and record the round's per-item votes, gate result, participant counts from the actual assigned roster, immutable completion time, convergence history, and optional self-fix notes/groups read from Markdown files. `--self-fix-group` requires `--self-fix-stop-reason` — there is no default. `--self-fix-stop-reason` alone records a stop for a round that rewrote nothing and leaves `selfFixGroups` / `selfFixRoundsApplied` untouched. When `--items` is omitted, completion automatically reads this run's canonical prepared queue and restores it when it includes all votes recorded for the requested round. If that file belongs to a narrower later batch, the persisted queue stays authoritative. This recovers saved critic results after an upgrade without requiring the lead to add a recovery flag. An explicit `--items` still selects a prepared artifact. Only the assigned items need verdicts for this round; earlier verdicts elsewhere remain unchanged. Missing-verdict errors name the round and exact dispatched item ids. Models do not write the state JSON. Stdout also carries `nextDispatch`. With `gating=false`, critic corrections are exempt from the analyser round limit and do not require an even split. A later `--append` updates the critic verdict while retaining analyser votes and completed round history. A second automatic rewrite is rejected; the one rewrite may contain several cause groups. `selfFixRoundsApplied` remains its verification round number, not a rewrite count. |
820
+ | `okstra plan-items next-dispatch --state <plan-body-verification.json> [--run-manifest <path>]` | After `apply-verdicts`, decide whether this round opens a worker batch. `kind: none` — missing-dependency `UNVERIFIABLE` only, no new batch. `kind: worker-correction` — re-prompt only those workers; peers stay idle. `kind: critic-tie` — unsettled analyser 1-1 on a run that rostered a critic, `critic-worker` on those item ids only. `kind: user-decision` — the same 1-1 on a run with no critic rostered: no in-band vote can break it, so open one `okstra approval-decision open` per item (classification `correctness-critical` for unresolved kind `a` outside `P-Var-*` or kind `f` on `P-Req-*`, otherwise `noncritical-dissent`) plus its `## 1. Clarification Items` row and dispatch no further verification for them. `--run-manifest` is what tells the two apart (`invocationAssignments` `critic/*`); without it the answer stays `critic-tie`. A missing path is never environment-unverifiable. After the single automatic self-fix, remaining items route to `lead-decision` first, then `user-decision`, with no further automatic worker batch. |
821
821
  | `okstra plan-items resolve-dissent --state <plan-body-verification.json> --item <P-id> --decision-file <markdown-file>` | Record the lead decision, its authority within agreed scope, and cited evidence after the one self-fix. Accepts only current, noncritical design judgements; facts, user-input requirements and non-results require user confirmation. Keeps original votes and appends the decision to the report dissent log. The decision expires when its content, scope or verdicts change. |
822
822
  | `okstra plan-items correction-prompt --state <plan-body-verification.json> --run-manifest <path> --worker <id>` | Emit the planning-time environment-gap paragraph, then the assigned queue. The environment exception is first. Exits 2 unless `next-dispatch` named that worker as a blanket-UNVERIFIABLE correction target. |
823
823
  | `okstra error-log append-observed --out <errors.jsonl> --task-key <key> --phase <task-type> --agent <assigned-worker-id> --agent-role worker --model <model> --error-type tool-failure --command-file <markdown-file> --command-kind <kind> --message-file <markdown-file> [--cause <cause> --evidence-file <kind=file>]…` | Worker-facing typed error recording surface. Python validates and serializes the JSONL record; a worker supplies scalar identity fields plus Markdown files for free-form command, message, and probe content, never a JSON sidecar or JSON argument. `sandbox-denied` requires both `targetProbe` and `controlProbe` evidence files. |
@@ -864,7 +864,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
864
864
  | `okstra option-votes gaps --task-manifest <task-manifest.json> (--report <final-report .data.json> \| --narrative <report-writer narrative.md>) [--json]` | List the `implementation-option-selection` candidates that fail the ranking rule on the every-analyser clause alone, and name the analyser owing each vote. Round 1 runs the designers in parallel, so each votes only on the candidates it proposed and the merged set keeps a different hole per analyser; a run whose comparison had converged can end `blocked` with an empty `rankedOptions` for that reason alone. The lead reads this before concluding `blocked` and dispatches one vote-completion assignment per named analyser — a feasibility verdict on the named candidate and nothing else, so the run stays in `candidate-comparison` mode. A candidate carrying `safetyBlockers` or `unresolvedFeasibilityFacts`, or one that could not reach two `feasible` votes even with every missing vote, is excluded: another round would not change it. `--narrative` reads the writer's markdown before assembly and tolerates its value defects; `--report` reads a published record. |
865
865
  | `okstra design-snapshot --narrative <report-narrative.md> --output <design-preparation.json>` | Detect implementation-planning design surfaces and write the detector-owned snapshot consumed by final report assembly. |
866
866
  | `okstra plan-verify --narrative <report-narrative.md> --state <plan-body-verification.json>` | Recompute the plan-body gate from the convergence-owned state before `data.json` publication. `--report <historical-data.json>` remains the v2 reader. |
867
- | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, translates (`translate`: for a non-English `reportLanguage` it materializes and dispatches the translator worker unless the `*.i18n.<lang>.json` sidecar already exists, and fails when the worker leaves none), then renders with that sidecar overlaid, spawns follow-ups, validates, records the run's conclusion and the group's start order into the task-group's `group-context.md` (`record-group-memory`, creating the file when absent), and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; `record-group-memory` and `teardown-stages` are skipped so a failed run neither hands an unvalidated conclusion to sibling tasks nor reclaims worktrees. The result carries `nextInGroup` (the first task in start order not yet started) and, for a terminal pointer, `nextCommand` closes on starting it from its brief. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. This is the shared path for every lead adapter. |
867
+ | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, translates (`translate`: for a non-English `reportLanguage` it materializes and dispatches the translator worker unless the `*.i18n.<lang>.json` sidecar already exists, and fails when the worker leaves none), then renders with that sidecar overlaid, spawns follow-ups, validates, records the run's conclusion and the group's start order into the task-group's `group-context.md` (`record-group-memory`, creating the file when absent), and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; `record-group-memory` and `teardown-stages` are skipped so a failed run neither hands an unvalidated conclusion to sibling tasks nor reclaims worktrees. The result carries `nextInGroup` (the first task in start order not yet started) and, for a terminal pointer, `nextCommand` closes on starting it from its brief. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. Repairable failures return `recovery.mode=same-run`, complete assembly owner issues, and `resumeCommand` using this manifest; `nextCommand` does not ask for a new run. A validator-only failure targeting an earlier phase retains that recovery target. Finalization step failures and run-bound contract exceptions from `plan-items` and `agent-prompt` write bounded runtime error records, with any logging failure reported separately. This is the shared path for every lead adapter. |
868
868
  | `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly, and schema v3 data uses the same always-generated, task-specific human HTML path. The full reading copy uses `templates/reports/final-report-v2.template.md` and is rendered on demand with `okstra render-final-report`. Passing the Markdown sibling locates the same data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, Response ID parity, and that every in-page `href="#…"` lands on an element of the page. For a non-English report the command prints two counts: `translated N string(s) into <lang> (M left in English, K unresolved)` from the sidecar overlay, and `rendered R line(s) still in English on the <lang> page` from the written page itself — the second sees fields the extractor does not offer, so `M = 0` with `R > 0` means a reader-facing key is missing from `PROSE_KEYS`. |
869
869
  | `okstra design-prep <list\|show\|write>` | Review AI-prepared implementation design requests, inspect their effective confirmed response, or append a confirmed user/wizard response without editing the planning report |
870
870
  | `okstra wizard <init\|step\|render-args\|confirmation\|outcome> --state-file <path>` | Interactive input state machine for okstra-run, implemented by `okstra_ctl.wizard`. Seed a state file with `init`, then repeatedly call `step --answer <val>` to receive the next `Prompt` JSON. `--answer` is **required**; use `--no-submit` to peek at the next prompt without submitting a response. A `pick` with more choices than the host picker can display keeps `kind: "pick"` but adds `presentation: "numbered-text"`; render every option as a numbered Markdown list and submit the user's 1-based number, exact value, or exact label. Invalid, out-of-range, and ambiguous answers re-prompt without dropping choices. `render-args` returns the final `render-bundle` argument map, and `confirmation` returns the user echo block. On a completed wizard, `outcome` returns `renderArgs`, `persistActions`, and `confirmationText` together; project/global release-handoff PR-template persistence appears as `persistActions[].command == "config.set"`. For an `implementation` task type, `stage_pick` follows `approved_plan_pick` and selects the stage before `executor_pick`. The brief step appears only for entry task types—requirements-discovery, error-analysis, improvement-discovery, project-analysis, feature-analysis, and change-impact-analysis. Analysis inputs use `feature_evidence_pick` / `feature_evidence`, `project_evidence_pick` / `project_evidence`, and `analysis_target_pick` / `analysis_target`; a revision-requested report prioritizes its same-task, same-type rerun. Downstream lifecycle phases automatically carry the manifest brief, with a three-option `brief_carry` fallback when none is registered; `release-handoff` has no brief and enters multi-select `handoff_stage_pick` for eligible stage groups or the whole task |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.199.2",
3
+ "version": "0.199.4",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.199.2",
3
- "builtAt": "2026-09-11T23:34:24.321Z",
2
+ "package": "0.199.4",
3
+ "builtAt": "2026-09-12T08:39:25.893Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -47,9 +47,11 @@ After Phase 7 persistence, the last user-facing message of this run is the next
47
47
 
48
48
  Close with one command the user can run now. The first matching row wins:
49
49
 
50
+ - Phase 7 failure with `recovery.mode: same-run` → continue correcting the owner inputs and execute `recovery.resumeCommand` in this run. Preserve user approvals and model choices. Read the report-writer recovery procedure before correcting its narrative. Do not close by asking the user to restart the phase for repairable artifact errors. If the only failure is `validate-run` and a blocked pointer targets an earlier phase, `recovery.mode: phase-reentry` preserves that target in `nextCommand`. Other validator findings are advisories, printed as `validate-run: advisory — <finding>`; follow the matching pointer row when the run passed.
51
+
50
52
  - Open `blocks: approval` rows, or a `blocked-by-disagreement` gate whose approval rows carry no progress disposition → `/okstra-user-response` (name the `C-NNN` ids). An `accept-risk` / `select` / `answer` already recorded is not an open blocker. A report published with those rows open is not a failed run — the answers are the missing input, and a planning re-run without them reproduces the same block.
51
53
 
52
- After `implementation-planning`, stop on the rows above. No open approval blocker → `/okstra-run` → `implementation` or `--approve`. Do not propose another planning run. Do not say `/okstra-inspect`. A blocking `validate-run` failure is named in one line, then this same command; a `validate-run` advisory is named in one line and nothing else — it did not fail the run.
54
+ After a successful `implementation-planning` finalization, stop on the approval row above. No open approval blocker → `/okstra-run` → `implementation` or `--approve`. Do not propose another planning run. Do not say `/okstra-inspect`. A blocking `validate-run` failure follows the recovery row first; a `validate-run` advisory is named in one line and nothing else — it did not fail the run.
53
55
 
54
56
  For every other task type:
55
57
 
@@ -57,7 +59,6 @@ For every other task type:
57
59
  - Pointer `status: terminal` → the lifecycle ends here. Say the task is finished and quote the pointer's `rationale`. Do not propose a run and do not send the user to `/okstra-inspect`: the decision is already made, so there is nothing to inspect. If this run registered follow-up tasks, name them and the command that starts one.
58
60
  - Pointer `status: blocked` → the pointer's `rationale` names what is in the way and what to run. Quote it and issue that command — `/okstra-user-response` for the `C-NNN` ids it lists, or `/okstra-run` for the phase it names.
59
61
  - Pointer `status: pending` with a `rationale` → the user's own input is what comes next. Quote the `rationale` and issue the command it names. Do not re-run the phase that just completed, and do not send the user to `/okstra-inspect`: a finished phase has nothing to inspect, and re-running it discards the result the user is being asked to act on.
60
- - Phase 7 `validate-run` failed → one line naming the blocking cause, then use the blocked pointer's `phase` as the recovery target with `/okstra-run`; re-run the current phase only when that target is empty. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
61
62
  - Otherwise → `/okstra-inspect status` for this task.
62
63
 
63
64
  Name every file in this reply as a markdown link — `[<what it is>](<path>)`, path inside the parentheses — so the user can open it. `reportPaths.markdown` in the `report-finalize` result already carries this run's report, report record, and team state that way. Commands stay in backticks.
@@ -501,9 +501,11 @@ After persistence, reply briefly in the resolved Report Language. **Lead this re
501
501
 
502
502
  Pick the next action from this table; the first matching row wins:
503
503
 
504
+ - Phase 7 failure with `recovery.mode: same-run` → continue correcting the owner inputs and execute `recovery.resumeCommand` in this run. Preserve user approvals and model choices. Read the report-writer recovery procedure before correcting its narrative. Do not close by asking the user to restart the phase for repairable artifact errors. If the only failure is `validate-run` and a blocked pointer targets an earlier phase, `recovery.mode: phase-reentry` preserves that target in `nextCommand`. Other validator findings are advisories, printed as `validate-run: advisory — <finding>`; follow the matching pointer row when the run passed.
505
+
504
506
  - Open `blocks: approval` rows, or a `blocked-by-disagreement` gate whose approval rows carry no progress disposition (`okstra_ctl.run._blocking_gate_survives_user_decision`) → `/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. A report published with those rows open is not a failed run — the answers are the missing input, and a planning re-run without them reproduces the same block.
505
507
 
506
- After `implementation-planning`, stop on the rows above. No open approval blocker → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run. Do not say `/okstra-inspect`. A blocking `validate-run` failure is named in one line, then this same command; a `validate-run` advisory is named in one line and nothing else — it did not fail the run.
508
+ After `implementation-planning`, once finalization succeeds, stop on the approval row above. No open approval blocker → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run. Do not say `/okstra-inspect`. A blocking `validate-run` failure follows the recovery row first; a `validate-run` advisory is named in one line and nothing else — it did not fail the run.
507
509
 
508
510
  For every other task type:
509
511
 
@@ -512,7 +514,6 @@ For every other task type:
512
514
  - Pointer `status: terminal` and the result carries `nextInGroup` → the task is finished and the task-group has a task not yet started: say this task is finished, then close on `/okstra-run` for `nextInGroup.briefId` from its `brief` path — the group's start order is the brief ordinal, and `nextCommand.note` already names the task and its brief.
513
515
  - Pointer `status: blocked` → the pointer's `rationale` names what is in the way and what to run. Quote it and issue that command — `/okstra-user-response` for the `C-NNN` ids it lists, or `/okstra-run` for the phase it names.
514
516
  - Pointer `status: pending` with a `rationale` → the user's own input is what comes next. Quote the `rationale` and issue the command it names. Do not re-run the phase that just completed, and do not send the user to `/okstra-inspect`: a finished phase has nothing to inspect, and re-running it discards the result the user is being asked to act on.
515
- - Phase 7 `validate-run` failed → one line naming the blocking cause, then use the blocked pointer's `phase` as the recovery target with `/okstra-run`; re-run the current phase only when that target is empty. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
516
517
  - Otherwise → `/okstra-inspect status` for this task.
517
518
 
518
519
  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.
@@ -49,7 +49,7 @@ Plan-body verification is configured under `convergence.planBodyVerification` in
49
49
  | `enabled` | `true` | If `false`, the round is skipped and the approval gate is not blocked by this round (legacy behaviour). |
50
50
  | `maxRounds` | `1` | Upper bound. Plan-body verification is consistency / completeness checking, not fact checking — additional rounds rarely help. Range 1–3. |
51
51
  | `selfFixMaxRounds` | `1` | Fixed limit, not configurable: one automatic report-writer rewrite at most. Skip when nothing is fixable. `plan_items_cli._record_self_fixes`, `validate-run._validate_self_fix_grouping`, and session activity validation enforce the limit. |
52
- | `gating` | `true` | If `true` (default), `majority-disagree` blocks approval. If `false`, the round is advisory-only and never blocks approval. Prepare emits `true` because the plan does not exist yet. After the report-writer draft, `okstra plan-items prepare` (and `seed`) flip it to `false` when `designPreparation.mode` is `no-design-inputs` and the Stage Map has exactly one row. That path keeps extraction and one verification round and does not run the self-fix loop or a sweep batch. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating=true`. `--no-plan-verification` is the separate manual opt-out (`enabled=false`). **Enforced:** `okstra_ctl.plan_items.advisory_plan_body_gating`, `validators/validate-run.py` `_validate_advisory_plan_body_gating`. "never blocks approval" is enforced by the `gating is False` early return in `_recompute_plan_body_gate`, `_gate_blocking_causes`, `_validate_plan_body_clarification_matching`, and `_validate_self_fix_before_clarification` — an advisory round neither demands a `blocks=approval` row nor an exhausted self-fix budget. |
52
+ | `gating` | `true` | If `true` (default), `majority-disagree` blocks approval. If `false`, the round is advisory-only and never blocks approval. Prepare emits `true` because the plan does not exist yet. After the report-writer draft, `okstra plan-items prepare` (and `seed`) flip it to `false` when `designPreparation.mode` is `no-design-inputs` and the Stage Map has exactly one row. That path keeps extraction and one analyser verification round and does not run the self-fix loop or a sweep batch. Critic corrections are exempt from the analyser round limit and need not concern an even split. Record their actual round numbers and preserve completed history; they are not planner rewrites. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating=true`. `--no-plan-verification` is the separate manual opt-out (`enabled=false`). **Enforced:** `okstra_ctl.plan_items.advisory_plan_body_gating`, `validators/validate-run.py` `_validate_advisory_plan_body_gating`. "never blocks approval" is enforced by the `gating is False` early return in `_recompute_plan_body_gate`, `_gate_blocking_causes`, `_validate_plan_body_clarification_matching`, and `_validate_self_fix_before_clarification` — an advisory round neither demands a `blocks=approval` row nor an exhausted self-fix budget. |
53
53
 
54
54
  Default values are emitted into the manifest by `scripts/okstra_ctl/render.py` (`_build_convergence_block`). The ctx knob `OKSTRA_PLAN_VERIFICATION=false` flips `planBodyVerification.enabled` to false. `gating=false` is not that opt-out: extraction and one round still run.
55
55
 
@@ -325,9 +325,9 @@ When `config.adversarial == true` (the default for `implementation-planning`; se
325
325
  - The burden of proof sits on the plan: an item earns `AGREE` only if the verifier actively tried to break it and could not.
326
326
  - The verifier MUST open the file paths / symbols / commands the item cites and confirm they exist and are **defined** as written. This is the one allowed widening of the lightweight "judge from internal consistency and stated commands / paths" rule — confirming the existence of cited paths is not "re-analyzing the original requirements". The widening stops at *definition*: a build/test command's **execution success** is out of scope here, because the planning worktree has no dependencies installed (§"Planning-time environment gap"). Confirm the script is declared; do not treat its failure to run as evidence against the plan.
327
327
  - If a cited path / command / validation signal cannot be confirmed, the verifier responds `DISAGREE(<kind>)` with the applicable breakage kind (a–f); uncertainty resolves toward DISAGREE, not AGREE.
328
- - **Single-vote-blocking kinds.** A reproduced `DISAGREE(a)` (cited path/symbol mismatch) on any item other than a `P-Var-*` one, or a reproduced `DISAGREE(f)` on a `P-Req-*` item, blocks on that one vote even if the rest AGREE. **Enforced:** `validators/validate-run.py` `_single_vote_block_survives`. Kinds `b` / `c` / `e` do not auto-block on one unreproduced vote, but a blocking-kind minority with ≥2 participating votes is still `majority-disagree` and goes to the user — the majority does not silently pass it. **Rollback ordering (`d`) never blocks.** Because `a` is reserved for a concrete contradiction between two spelled-out references, an abbreviated path is raised as `b`, never `a`. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate`.
328
+ - **Single-vote-blocking kinds.** A reproduced `DISAGREE(a)` (cited path/symbol mismatch) on any item other than a `P-Var-*` one, or a reproduced `DISAGREE(f)` on a `P-Req-*` item, blocks on that one vote unless the assigned critic settles the analyser split with `AGREE` / `SUPPLEMENT`. The critic can correct the original factual objection; the original vote remains in the audit history. **Enforced:** `validators/validate-run.py` `_single_vote_block_survives`. Kinds `b` / `c` / `e` do not auto-block on one unreproduced vote, but a blocking-kind minority with ≥2 participating votes is still `majority-disagree` and goes to the user — the majority does not silently pass it. **Rollback ordering (`d`) never blocks.** Because `a` is reserved for a concrete contradiction between two spelled-out references, an abbreviated path is raised as `b`, never `a`. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate`.
329
329
 
330
- Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in [convergence](./convergence.md) §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden). A reproduced fact (`a`, or `f` on P-Req) still blocks on one confirmed vote. A blocking-kind minority (`b`/`c`/`e`) with ≥2 participating votes goes to the user rather than passing as `has-dissent`. Rollback ordering (`d`) is advisory and never blocks. A lone surviving `DISAGREE` whose peer returned a non-result does NOT block — a worker failure must not make the gate stricter than a healthy roster would.
330
+ Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in [convergence](./convergence.md) §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden). A reproduced fact (`a`, or `f` on P-Req) still blocks on one confirmed vote until the critic corrects that judgement. A blocking-kind minority (`b`/`c`/`e`) with ≥2 participating votes goes to the user rather than passing as `has-dissent`. Rollback ordering (`d`) is advisory and never blocks. A lone surviving `DISAGREE` whose peer returned a non-result does NOT block — a worker failure must not make the gate stricter than a healthy roster would.
331
331
 
332
332
  ## Round protocol (single round at default `maxRounds=1`)
333
333
 
@@ -369,6 +369,8 @@ CLI-wrapper calls follow the planned execution surface after
369
369
  consume only `modelExecutionValue`. A missing or invalid invocation contract blocks the
370
370
  round before any host or provider process starts.
371
371
 
372
+ For contract 3.0, `prepare` checks the selected-direction draft with the same semantic validator as publication before writing the queue. Correct all reported references, coverage rows and planned commands through the writer correction procedure, then retry in this run. **Enforced:** `report_assembly.validate_plan_draft` and `plan_items_cli._prepare`; `tests/report/test_report_assembly.py` checks that invalid coverage creates no prepared queue.
373
+
372
374
  1. Lead runs `okstra plan-items prepare --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`, places the fixed output of `okstra plan-items prompt --run-manifest <run-manifest>` verbatim in every verifier prompt, then runs `okstra plan-items validate-prepared --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`. After a self-fix rewrite, pass `--state <plan-body-verification.json>` on prepare and validate-prepared so the dispatch queue is the changed items plus their stage closure, not the full extract, and so each re-dispatched item carries the previous round's dissent (§"Re-verification rounds (round 2+)"). Python resolves the one convergence-owned state path from that run identity. Dispatch only after that exact-match validation succeeds. The prompt is the dispatch queue: `observed` / `deferred` stages are omitted; plan-wide items (`P-Dir-1`, `P-Var-*`, `P-Dep-*`, `P-Opt-*`) stay. Unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stay on the first round, then only when their own `contentHash` changed — a neighbour rewrite must not resweep them. **Enforced:** `okstra_ctl.plan_items.dispatch_item_ids` / `reverify_item_ids`. After `okstra incremental-carry`, the carry copies unchanged checklist verdicts, rewrites `dispatchQueue`, and updates the sibling `plan-items-*.json` so the next `okstra plan-items prompt` does not re-score a carried row.
373
375
 
374
376
  **Then seed the landing table (BLOCKING):** `okstra plan-items seed --narrative <report-writer-narrative.md> --state <plan-body-verification.json> --run-manifest <run-manifest.json>`. `apply-verdicts` in step 8 refuses a verdict whose item has no `planBodyVerification.planItems[]` row. The report writer never owns that state, so the deterministic seed is the only creator of its rows. The seed is idempotent by id and never touches existing verdicts, so it is safe to re-run between rounds and after a self-fix re-extraction. It does refresh `contentHash` from the current extract. Skipping it makes step 8 fail with `plan-body state has no row for [...]`.
@@ -387,9 +389,9 @@ round before any host or provider process starts.
387
389
  - `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
388
390
  - `partial-consensus` — majority `AGREE` with two or more blocking `DISAGREE`s. On kinds `b` / `c` / `e` this is scored `majority-disagree` and **blocks approval** so the user decides; it is not folded into a passing gate.
389
391
  - `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE`. On a blocking kind (`b` / `c` / `e`, and kind `a` on `P-Var-*`) this is scored `majority-disagree` and **blocks approval**. Advisory-only `DISAGREE(d)` and `P-Rb-*` stay recorded dissent and do not block. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs.)
390
- - `majority-disagree` — a *majority* of analysers `DISAGREE` (majority needs ≥2 participating non-error votes; rollback-ordering `DISAGREE(d)` votes are advisory and excluded from the tally), OR any blocking-kind dissent with ≥2 participating votes (a minority `DISAGREE` is not outvoted), OR any single-vote-blocking kind fires: one reproduced `DISAGREE(a)` on any item other than a `P-Var-*` one, or one reproduced `DISAGREE(f)` on a `P-Req-*` item (see §"Single-vote-blocking kinds"). This classification **blocks approval**.
392
+ - `majority-disagree` — a *majority* of analysers `DISAGREE` (majority needs ≥2 participating non-error votes; rollback-ordering `DISAGREE(d)` votes are advisory and excluded from the tally), OR any blocking-kind dissent with ≥2 participating votes (a minority `DISAGREE` is not outvoted), OR an unresolved single-vote-blocking kind fires: one reproduced `DISAGREE(a)` on any item other than a `P-Var-*` one, or one reproduced `DISAGREE(f)` on a `P-Req-*` item (see §"Single-vote-blocking kinds"). This classification **blocks approval**. A valid critic correction is scored before these blocking rules, whether or not the analysers split evenly.
391
393
  - `needs-reverify` — one of two shapes the round could not settle.
392
- - **An even split on a blocking kind.** The majority test is strict, so a panel splitting evenly (1-AGREE / 1-DISAGREE, 2-2, …) reaches neither `full-consensus` nor `majority-disagree`. Until this shape existed it folded into `has-dissent` and the gate passed. Do **not** re-run the original two. Dispatch `critic-worker` immediately on those items only (`okstra plan-items prepare --tie-vote`, then `okstra plan-items prompt`). The prompt carries the analyser split and no other plan items. Read the answer with `okstra plan-items collect-verdicts --items <the `--tie-vote` plan-items artifact> --result critic-worker=<path> --output <envelope>` — `--items` takes that artifact, whose `dispatchQueue` is the tie items, so pointing the next step at the raw result is refused against this round's full queue. Record the critic vote as `verdicts[].worker = critic-worker` with `okstra plan-items apply-verdicts --append --items <the `--tie-vote` plan-items artifact> --result critic-worker=<path>` — `--items` is what says this round dispatched part of the queue. Without it the result is checked against the whole persisted round queue and refused for every item the critic was never given (measured 2026-09-10: a 7-item tie round refused against 44 items), and the only way through was `--verdicts`, which the CLI's own help calls a historical envelope. Critic `AGREE` / `SUPPLEMENT` settles the split to `has-dissent`. Critic `DISAGREE` on a blocking kind is `majority-disagree`. **Enforced:** `validators/validate-run.py` `_validate_unresolved_tie_was_reverified` fails an in-scope item that carries an even split on a blocking kind and has neither a `critic-worker` vote nor a `blocks: approval` clarification row, `_classify_plan_item_gate` scores the tie shape and fails a settled classification the votes do not support, and `okstra_ctl.plan_items.next_dispatch` returns kind `critic-tie` for exactly these items, so the tie round is the queue the CLI hands you rather than one you assemble. **With no critic on the roster the split is a user decision, not another round.** `okstra plan-items next-dispatch --run-manifest <current-run-manifest.json>` answers `user-decision` (not `critic-tie`) when the run's `invocationAssignments` carries no `critic/*` entry, and its `itemIds` are the tie items. For each of them do what step 8 does for a surviving `majority-disagree`: `okstra approval-decision open` with `approvalContext.classification` set to `noncritical-dissent`, plus the matching `## 1. Clarification Items` row at `Blocks=approval`. Every tie that reaches this shape is non-correctness by construction — `_is_correctness_critical` is false for kinds `b`/`c`/`e` and for a `P-Var-*` item, and the single-vote-blocking kinds (`a`, and `f` on `P-Req-*`) never reach the tie branch at all. Dispatch no further verification for those items. The `Blocks=approval` row is what withholds approval until the user disposes, exactly as for any other approval row; the gate itself still folds `needs-reverify` into `passed-with-dissent`, so the round closes on the gate it actually scored. A tie left with neither a critic vote nor a decision row surfaces as recorded dissent plus an `advisories[]` entry, not a round-blocking failure. **Enforced:** `okstra_ctl.plan_items.critic_is_rostered` reads the roster and `next_dispatch` returns the kind; `validators/validate-run.py` `_validate_unresolved_tie_was_reverified` reads a `blocks: approval` row linked to the item as the settlement and emits its advisory only when neither settlement is recorded.
394
+ - **An even split on a blocking kind.** A panel splitting evenly (1-AGREE / 1-DISAGREE, 2-2, …) needs a critic decision. An unresolved single-vote-blocking kind remains `majority-disagree`; other unresolved splits are `needs-reverify`. Do **not** re-run the original two. Dispatch `critic-worker` immediately on those items only (`okstra plan-items prepare --tie-vote`, then `okstra plan-items prompt`). The prompt carries the analyser split and no other plan items. Read the answer with `okstra plan-items collect-verdicts --items <the `--tie-vote` plan-items artifact> --result critic-worker=<path> --output <envelope>` — `--items` takes that artifact, whose `dispatchQueue` is the tie items, so pointing the next step at the raw result is refused against this round's full queue. Record the critic vote as `verdicts[].worker = critic-worker` with `okstra plan-items apply-verdicts --append --items <the `--tie-vote` plan-items artifact> --result critic-worker=<path>` — `--items` persists the exact partial `dispatchQueue` used by verdict validation and `complete-round`. Earlier verdicts and completed-round history outside that queue remain unchanged. If a previous version saved the critic votes but left the full queue in state, the ordinary `okstra plan-items complete-round --state <state> --run-manifest <manifest> --round <N>` automatically reads this run's canonical prepared queue. It uses that queue when all votes recorded for this round are included, preserving a wider recorded batch when a later prepared queue excludes its votes. An explicit `--items <prepared artifact>` remains available for selecting an artifact. Enforced by `plan_items_cli._round_inputs` and `tests/run/test_plan_items.py` completion coverage. Do not fabricate new-round votes for already agreed items. Without it the result is checked against the whole persisted round queue and refused for every item the critic was never given (measured 2026-09-10: a 7-item tie round refused against 44 items), and the only way through was `--verdicts`, which the CLI's own help calls a historical envelope. Critic `AGREE` / `SUPPLEMENT` settles the split to `has-dissent`, including an earlier `DISAGREE(a)` or `DISAGREE(f)` on `P-Req-*`. This decision corrects the disputed judgement before single-vote blocking is evaluated; it does not delete the original dissent. Critic `DISAGREE` on a blocking kind is `majority-disagree`. **Enforced:** `validators/validate-run.py` `_validate_unresolved_tie_was_reverified` fails an in-scope item that carries an even split on a blocking kind and has neither a `critic-worker` vote nor a `blocks: approval` clarification row, `_classify_plan_item_gate` scores the tie shape and fails a settled classification the votes do not support, and `okstra_ctl.plan_items.next_dispatch` returns kind `critic-tie` for exactly these items, so the tie round is the queue the CLI hands you rather than one you assemble. **With no critic on the roster the split is a user decision, not another round.** `okstra plan-items next-dispatch --run-manifest <current-run-manifest.json>` answers `user-decision` (not `critic-tie`) when the run's `invocationAssignments` carries no `critic/*` entry, and its `itemIds` are the tie items. For each of them do what step 8 does for a surviving `majority-disagree`: `okstra approval-decision open` with `approvalContext.classification` set to `correctness-critical` when `_is_correctness_critical` is true, or `noncritical-dissent` otherwise, plus the matching `## 1. Clarification Items` row at `Blocks=approval`. Dispatch no further verification for those items. The `Blocks=approval` row is what withholds approval until the user disposes, exactly as for any other approval row; the gate retains unresolved single-vote blockers as `majority-disagree` and folds other `needs-reverify` items into `passed-with-dissent`, so the round closes on the gate it actually scored. A tie left with neither a critic vote nor a decision row surfaces as recorded dissent plus an `advisories[]` entry, not a round-blocking failure. **Enforced:** `okstra_ctl.plan_items.critic_is_rostered` reads the roster and `next_dispatch` returns the kind; `validators/validate-run.py` `_validate_unresolved_tie_was_reverified` reads a `blocks: approval` row linked to the item as the settlement and emits its advisory only when neither settlement is recorded.
393
395
  - **A lone dissent nobody cross-verified** — a single-vote-blocking kind fired but the item has **fewer than 2 participating non-error votes**, i.e. the lone dissent was never cross-verified because its peer returned `verification-error`. A single-vote-blocking kind means "one *confirmed* DISAGREE is enough"; an unconfirmed one is not, and on a `P-Var-*` item none fires at all — its kind `a` never blocks on one vote and takes a majority like `b` / `e`. This does **not** block approval — blocking on it would make a worker failure produce a stricter gate than a healthy roster, the same paradox the ≥2-vote majority rule already rules out. The item is re-dispatched in the next round (step 7); if it survives the round budget it is promoted per step 8 with a Statement that says verification never completed. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` returns `needs-reverify` for this shape and `_recompute_plan_body_gate` folds it into `passed-with-dissent`.
394
396
  - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
395
397
  5. Gate result resolution:
@@ -839,3 +841,5 @@ An item with no recorded vote carrying a round number gets no block, and an enve
839
841
  **Enforced:** `okstra plan-items validate-prepared --state <same state>` re-derives the carry and exits 2 when the prepared envelope's `priorRounds` does not match, alongside the `items` / `dispatchQueue` comparison it already made. A prepared queue that dropped the dissent cannot pass the step-1 validation the dispatch is gated on.
840
842
 
841
843
  The two spellings are different anchors for different artifacts: `**Prior round dissent**` is the block `prompt` puts in the prompt, `**Prior dissent**` is the line the worker puts in its result. `scripts/okstra_ctl/verdict_blocks.py` parses the result line into the verdict block when it is present and leaves it empty when it is not, so an omitted answer line is still silent — the prompt is what is now guaranteed, not the response.
844
+
845
+ The analyser round limit is enforced by `plan_items_cli._validate_advisory_round` at verdict application and completion. It does not limit critic correction rounds or require an even split. `apply-verdicts --append` can update the critic’s current verdict in a later round while preserving analyser votes and completed round history. Normal invocation identity and result-provenance checks still apply. `validators/validate-run.py` `_critic_gate_class` applies valid critic corrections before analyser voting rules. `tests/run/test_plan_items.py` covers second and third critic rounds on tied and majority-dissent items; `tests/contract/test_plan_body_verification.py` covers critic correction of both agreement and disagreement.
@@ -135,7 +135,9 @@ Do not run the nine steps below manually. Invoke `okstra report-finalize`; contr
135
135
  8. **`record-group-memory`** — write this run's conclusion (headline, decisions, watch-outs, open follow-ups, record path, next phase) and the group's start order into the task-group's `group-context.md` okstra region, creating the file when the group has none; skipped, like teardown, when an earlier step failed. Sibling tasks read it as `## Task-Group Memory`.
136
136
  9. **`teardown-stages`** — remove eligible stage worktrees after successful validation.
137
137
 
138
- After `report-finalize` returns, the lead not the report writer — closes the run with the launch prompt's User closeout: one command the user can run now.
138
+ After `report-finalize` returns with `ok: true`, the lead closes the run with the launch prompt's User closeout. A generated HTML file alone does not establish successful validation.
139
+
140
+ When the result carries `recovery.mode: same-run`, continue the authorized work in this run. Read every `recovery.issues` entry and failed step output. Correct writer-owned fields through the correction ledger procedure above; correct orchestrator-owned inputs through their owning commands. If plan content changes, prepare and verify the affected items before finalizing. Then execute `recovery.resumeCommand`, which retains this run manifest and resumes from the earliest failure, including reassembly of contract 3.0 inputs. After a narrative correction, check an existing translation with `okstra report-translate check-data --run-manifest <manifest>` and regenerate it if its source no longer matches. Keep previous approvals, user decisions, model selections and completed evidence. Do not ask for another `/okstra-run` to repair references, coverage summaries, narrative syntax or command spelling. Ask only when an unresolved user decision or an actual external prerequisite prevents the correction. Do not edit an installed runtime or another repository while recovering a consuming project's task. **Enforced:** `report_finalize._finalize_recovery` returns the same-run command and complete owner issues, `closeout_command` suppresses the new-run closeout for that result, and `tests/report/test_report_finalize.py` checks recovery after a failed step. Execution of the correction remains the lead's responsibility.
139
141
 
140
142
  ### The translation sidecar: the `translate` step
141
143
 
@@ -12,6 +12,7 @@ import sys
12
12
 
13
13
  from ..invocation import AgentInvocationError
14
14
  from ...worker_prompt_headers import WorkerPromptHeaderError
15
+ from ...error_log_write import record_runtime_failure
15
16
  from ...dispatch_state import BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE, DispatchError
16
17
  from .emit import _emit, _prepared_payload
17
18
  from .inputs import AgentPromptCliError
@@ -295,4 +296,13 @@ def main(argv: list[str] | None = None) -> int:
295
296
  ValueError,
296
297
  ) as exc:
297
298
  print(f"error: {exc}", file=sys.stderr)
299
+ manifest = getattr(args, "run_manifest", None)
300
+ if manifest:
301
+ root = Path(args.project_root).resolve()
302
+ logged = record_runtime_failure(
303
+ root / manifest, project_root=root,
304
+ command=f"agent-prompt {args.command}", exit_code=2, detail=str(exc),
305
+ )
306
+ if not logged["ok"]:
307
+ print(f"error-log: {logged['reason']}", file=sys.stderr)
298
308
  return 2
@@ -25,6 +25,7 @@ from ..activity import (
25
25
  from ...final_report_schema import load_schema_version
26
26
  from ...implementation_options import validate_implementation_option_selection
27
27
  from ...report_contract import TASK_TYPE_DATA_PROPERTY
28
+ from ...report_assembly import validate_plan_draft
28
29
  from ...report_corrections import (
29
30
  CorrectionsCheck,
30
31
  check_corrections,
@@ -103,6 +104,10 @@ def run_corrections_check(
103
104
  return validate_implementation_option_selection(
104
105
  block if isinstance(block, Mapping) else {}, original_ids, analysers,
105
106
  )
107
+ elif task_type == "implementation-planning":
108
+
109
+ def semantic_validator(data: dict[str, Any]) -> list[str]:
110
+ return validate_plan_draft(data, project_root, manifest)
106
111
 
107
112
  return check_corrections(
108
113
  ledger=ledger,
@@ -139,7 +139,7 @@ _BLOCKING: tuple[tuple[str, str], ...] = (
139
139
  "(scripts/okstra_ctl/implementation_stage.py:113) 옛 요청으로 진행한다.",
140
140
  ),
141
141
  # selected-direction 계획의 두 조각. 주 집행은 조립 게시 게이트
142
- # (report_assembly `_selected_direction_publication_errors`)이고, 여기는
142
+ # (report_assembly `selected_direction_plan_errors`)이고, 여기는
143
143
  # 게시 후 변조·우회 경로의 백스톱이다. dev-10341 실측: 이 접두 17건이
144
144
  # advisory 로 통과 발행된 계획을 구현 진입이 그대로 하드 거부해 run 이
145
145
  # wedge 됐다.
@@ -276,6 +276,42 @@ def append_observed(
276
276
  return rec
277
277
 
278
278
 
279
+ def record_runtime_failure(
280
+ run_manifest_path: Path, *, command: str, exit_code: int, detail: str,
281
+ project_root: Path | None = None,
282
+ ) -> dict:
283
+ """실패를 감지한 실행 도구가 기록한다. 기록 실패는 원래 오류를 가리지 않는다."""
284
+ from .json_boundary import load_owned_object
285
+ from .worker_prompt_headers import WorkerPromptHeaderError, resolve_errors_log_path
286
+
287
+ result = {"ok": False, "path": "", "reason": ""}
288
+ try:
289
+ manifest = load_owned_object(run_manifest_path, artifact="run manifest")
290
+ root = project_root or Path(manifest["projectRoot"])
291
+ context_path = manifest.get("activeRunContextPath")
292
+ context = {}
293
+ if context_path:
294
+ path = root / context_path
295
+ context = load_owned_object(path, artifact="active run context")
296
+ out = resolve_errors_log_path(root, manifest, context).resolve()
297
+ out.relative_to((root / ".okstra").resolve())
298
+ result["path"] = str(out)
299
+ timestamp = _iso(_now_utc())
300
+ append_jsonl_line(out, {
301
+ "ts": timestamp, "recordedAt": timestamp,
302
+ "taskKey": manifest.get("taskKey"), "phase": manifest.get("taskType"),
303
+ "agent": "okstra", "agentRole": "runtime", "model": None,
304
+ "source": "runtime-observed", "errorType": "tool-failure",
305
+ "command": command, "commandKind": "runtime", "exitCode": exit_code,
306
+ "message": truncate_stderr(detail), "stderrExcerpt": None,
307
+ })
308
+ except (OSError, ValueError, TypeError, KeyError, WorkerPromptHeaderError) as exc:
309
+ result["reason"] = f"{type(exc).__name__}: {exc}"
310
+ return result
311
+ result["ok"] = True
312
+ return result
313
+
314
+
279
315
  def _worker_sidecar_record(
280
316
  entry, *, recorded_at, task_key, agent, agent_role, model, identity, out_path=None,
281
317
  ):
@@ -643,7 +643,7 @@ CRITIC_WORKER_ID = "critic-worker"
643
643
 
644
644
 
645
645
  def is_critic_worker(worker: str) -> bool:
646
- """본문 동수를 가르는 critic 표인지."""
646
+ """본문 판정을 교정하는 비판 검토자 표인지."""
647
647
  name = str(worker or "").strip().lower()
648
648
  return name == CRITIC_WORKER_ID or name.endswith("-critic-worker")
649
649
 
@@ -738,7 +738,10 @@ CRITIC_TIE_PREAMBLE = (
738
738
  "You are the critic tie-break. Only the items below are in dispute. "
739
739
  "Each item already has one AGREE and one DISAGREE from the two plan-body "
740
740
  "verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
741
- "settles the split. Do not re-open items that are not listed.\n"
741
+ "settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
742
+ "Explain whether the earlier dissent is supported or mistaken; a corrected "
743
+ "dissent remains in the audit history without overriding your decision. "
744
+ "Do not re-open items that are not listed.\n"
742
745
  )
743
746
 
744
747
  # 라운드 2+ 는 직전 라운드의 반대 의견을 해결하려고 존재한다. 그 의견을 프롬프트가
@@ -34,6 +34,7 @@ from .plan_items import (
34
34
  content_hash,
35
35
  correction_prompt_text,
36
36
  critic_is_rostered,
37
+ is_critic_worker,
37
38
  critic_tie_prompt_text,
38
39
  dispatch_item_ids,
39
40
  extract_plan_items,
@@ -53,6 +54,8 @@ from .stage_ledger import build_stage_ledger
53
54
  from .claim_reproduction import NOT_RUNNABLE, reproduce
54
55
  from .final_report_schema import load_schema_version
55
56
  from .report_narrative import parse_narrative
57
+ from .report_assembly import validate_plan_draft
58
+ from .error_log_write import record_runtime_failure
56
59
  from .user_response import parse_user_response_entries
57
60
  from .verdict_blocks import (
58
61
  PLAN_ITEM_VERDICTS,
@@ -266,7 +269,8 @@ def _parser() -> argparse.ArgumentParser:
266
269
  )
267
270
  apply_verdicts.add_argument(
268
271
  "--append", action="store_true",
269
- help="add these votes to the existing rows (the critic's tie vote); "
272
+ help="add critic corrections while preserving analyser votes; a later "
273
+ "critic verdict updates that critic's current row after its earlier round is complete; "
270
274
  "without it every recorded verdict row is replaced, so a round "
271
275
  "that was never closed with complete-round is refused first",
272
276
  )
@@ -285,6 +289,12 @@ def _parser() -> argparse.ArgumentParser:
285
289
  complete.add_argument("--state", type=Path, required=True)
286
290
  complete.add_argument("--run-manifest", type=Path, required=True)
287
291
  complete.add_argument("--round", type=int, required=True, dest="round_number")
292
+ complete.add_argument(
293
+ "--items", type=Path,
294
+ help="restore this round's dispatched queue from its prepared items artifact; "
295
+ "defaults to this run's canonical prepared queue when it covers the round's "
296
+ "recorded votes; earlier verdicts outside that queue remain unchanged",
297
+ )
288
298
  complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
289
299
  complete.add_argument(
290
300
  "--self-fix-group", action="append", default=[],
@@ -405,6 +415,15 @@ def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
405
415
  def _prepare(args: argparse.Namespace) -> dict[str, Any]:
406
416
  output = _prepared_items_path(args.run_manifest)
407
417
  source = _plan_source(args)
418
+ authority = validated_run_authority(args.run_manifest)
419
+ if authority.payload.get("reportContractVersion") == "3.0":
420
+ failures = validate_plan_draft(source, authority.project_root, authority.payload)
421
+ if failures:
422
+ raise PlanItemContractError(
423
+ "owner=report-writer: "
424
+ + "; ".join(failures)
425
+ + "; correct the narrative in this run, then retry plan-items prepare"
426
+ )
408
427
  envelope = _envelope(source)
409
428
  envelope["dispatchQueue"] = _queue_for(
410
429
  envelope["items"],
@@ -1405,12 +1424,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1405
1424
  discard_open_rounds = bool(getattr(args, "discard_open_rounds", False))
1406
1425
  if discard_open_rounds and args.state is not None and args.result:
1407
1426
  _restore_queue_from_results(verification, args.result, recorded)
1408
- queue = verification.get("dispatchQueue") if isinstance(verification, Mapping) else None
1409
- assigned = (
1410
- {item_id for item_id in queue if isinstance(item_id, str)}
1411
- if isinstance(queue, list) else known
1412
- )
1413
- assigned = _narrow_assignment(args, assigned)
1427
+ assigned = _narrow_dispatch_queue(args, verification, known)
1414
1428
  rows = _incoming_verdict_rows(args, assigned)
1415
1429
  missing = sorted(item_id for item_id in rows if item_id not in known)
1416
1430
  if missing:
@@ -1421,21 +1435,14 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1421
1435
  )
1422
1436
  if args.round_number < 1:
1423
1437
  raise PlanItemContractError("--round must be 1 or greater")
1424
- if (
1425
- isinstance(verification, Mapping)
1426
- and verification.get("gating") is False
1427
- and args.round_number > 1
1428
- ):
1429
- # `complete-round` 가 같은 조건으로 거절하지만 그때는 이미 라운드 2 판정이
1430
- # 상태에 쓰인 뒤다 — 판정은 있는데 `roundHistory` 에 그 라운드가 없는
1431
- # 불일치가 남고, 상태 파일은 수렴 엔진 소유라 복구 경로가 없었다
1432
- # (2026-09-05 실측, dev-10626 planning). 쓰기 전에 같은 문장으로 거절한다.
1433
- raise PlanItemContractError(
1434
- "advisory plan-body gating allows one verification round"
1435
- )
1436
1438
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1437
1439
  append = bool(getattr(args, "append", False))
1438
- if not append:
1440
+ replaces_critic = append and any(
1441
+ is_critic_worker(row.get("worker", ""))
1442
+ and any(v.get("worker") == row.get("worker") for v in rows.get(item.get("id"), []))
1443
+ for item in recorded for row in item.get("verdicts", [])
1444
+ )
1445
+ if not append or replaces_critic:
1439
1446
  _reject_uncompleted_round_loss(
1440
1447
  recorded, rows, data.get("roundHistory"), args.round_number, target,
1441
1448
  discard_open_rounds=discard_open_rounds,
@@ -1444,6 +1451,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1444
1451
  for item in recorded:
1445
1452
  if isinstance(item, Mapping) and item.get("id") in rows:
1446
1453
  writer(item, rows[item["id"]], args.round_number, project_root)
1454
+ _validate_advisory_round(verification, args.round_number)
1447
1455
  write_json_atomic(target, data)
1448
1456
  return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
1449
1457
 
@@ -1478,23 +1486,26 @@ def _append_item_verdicts(
1478
1486
  round_number: int,
1479
1487
  project_root: Path | None,
1480
1488
  ) -> None:
1481
- """동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
1489
+ """분석자 표를 보존하고, 비판 검토자의 후속 판정을 현재 표로 갱신한다."""
1482
1490
  stamped = _stamped_verdicts(incoming, round_number, project_root)
1483
1491
  existing = item.get("verdicts")
1484
1492
  current = existing if isinstance(existing, list) else []
1485
- seen = {
1486
- row.get("worker") for row in current if isinstance(row, Mapping)
1493
+ seen = {row.get("worker"): row.get("round", 1) for row in current if isinstance(row, Mapping)}
1494
+ refreshed = {
1495
+ row.get("worker") for row in stamped
1496
+ if is_critic_worker(row.get("worker", ""))
1497
+ and row.get("worker") in seen and round_number > seen[row["worker"]]
1487
1498
  }
1488
- clash = [row.get("worker") for row in stamped if row.get("worker") in seen]
1499
+ clash = [row.get("worker") for row in stamped if row.get("worker") in seen and row.get("worker") not in refreshed]
1489
1500
  if clash:
1490
1501
  raise PlanItemContractError(
1491
1502
  f"plan item {item.get('id')} already has a vote from {clash} — "
1492
1503
  "the extra vote must come from a worker who has not voted on it. "
1493
- "--append is the critic's tie vote; a worker re-voting in a later "
1504
+ "--append accepts critic corrections; an analyser re-voting in a later "
1494
1505
  "round is recorded without --append, after the earlier round is "
1495
1506
  "closed with complete-round"
1496
1507
  )
1497
- item["verdicts"] = [*current, *stamped]
1508
+ item["verdicts"] = [row for row in current if row.get("worker") not in refreshed] + stamped
1498
1509
  _remember_verified_hash(item)
1499
1510
 
1500
1511
 
@@ -1602,7 +1613,7 @@ def _reject_uncompleted_round_loss(
1602
1613
  )
1603
1614
  raise PlanItemContractError(
1604
1615
  f"round {round_number} would replace verdicts of a round that was never "
1605
- f"completed ({detail}) — apply-verdicts replaces every recorded row, and "
1616
+ f"completed ({detail}) — replacing these verdicts would lose their history; "
1606
1617
  "only complete-round keeps a round's votes in planItems[].rounds. Close "
1607
1618
  f"the earlier round first: run {commands}, then re-run this command. "
1608
1619
  "If those rows are themselves being re-applied from their result files "
@@ -1715,7 +1726,22 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
1715
1726
  audit, history = data.get("planItems"), data.get("roundHistory")
1716
1727
  if not isinstance(audit, list) or not isinstance(history, list):
1717
1728
  raise PlanItemContractError("state planItems and roundHistory must be arrays")
1718
- return data, _state_plan_body_items(data, args.state), audit, history
1729
+ current = _state_plan_body_items(data, args.state)
1730
+ if args.command == "complete-round" and args.items is None:
1731
+ manifest = _load_json_object(args.run_manifest)
1732
+ if manifest.get("planBodyVerificationPath"):
1733
+ prepared = _prepared_items_path(args.run_manifest)
1734
+ if prepared.is_file():
1735
+ voted = {item["id"] for item in current
1736
+ if _round_snapshot(item, args.round_number)["votes"]}
1737
+ # 이후 회차용 준비 파일이 이미 저장된 현재 회차의 표를 제외하면 쓰지 않는다.
1738
+ if voted <= set(_assigned_item_ids(prepared)):
1739
+ args = copy.copy(args)
1740
+ args.items = prepared
1741
+ _narrow_dispatch_queue(
1742
+ args, data["planBodyVerification"], {item.get("id") for item in current},
1743
+ )
1744
+ return data, current, audit, history
1719
1745
 
1720
1746
 
1721
1747
  def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
@@ -1728,8 +1754,15 @@ def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, A
1728
1754
  if isinstance(item, Mapping) and item.get("id") in allowed
1729
1755
  ]
1730
1756
  snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in scoped}
1731
- if not snapshots or any(not row["votes"] for row in snapshots.values()):
1732
- raise PlanItemContractError("every current plan item needs a verdict before completion")
1757
+ if not snapshots:
1758
+ raise PlanItemContractError(f"round {round_number} dispatch queue has no current plan items")
1759
+ missing = [item_id for item_id, row in snapshots.items() if not row["votes"]]
1760
+ if missing:
1761
+ raise PlanItemContractError(
1762
+ f"round {round_number} has no verdict for dispatched plan items: {', '.join(missing)}. "
1763
+ "Earlier-round verdicts remain recorded; check this round's prepared queue "
1764
+ "before requesting new verdicts."
1765
+ )
1733
1766
  summary = _plan_gate_summary(verification)
1734
1767
  classes = {row["id"]: row["stateClassification"] for row in summary["items"]}
1735
1768
  for item_id, snapshot in snapshots.items():
@@ -1798,16 +1831,27 @@ def _reject_round_gap(history: list[Any], round_number: int) -> None:
1798
1831
  )
1799
1832
 
1800
1833
 
1834
+ def _validate_advisory_round(verification: Mapping[str, Any], round_number: int) -> None:
1835
+ """분석자 검증 횟수 제한은 비판 검토자의 교정에 적용하지 않는다."""
1836
+ if verification.get("gating") is not False or round_number <= 1:
1837
+ return
1838
+ if any(
1839
+ row.get("round") == round_number and not is_critic_worker(row.get("worker", ""))
1840
+ for item in verification.get("planItems", []) for row in item.get("verdicts", [])
1841
+ ):
1842
+ raise PlanItemContractError(
1843
+ "advisory plan-body gating allows one verification round for analysers; "
1844
+ "critic corrections do not consume that limit"
1845
+ )
1846
+
1847
+
1801
1848
  def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
1802
1849
  if args.round_number < 1:
1803
1850
  raise PlanItemContractError("--round must be 1 or greater")
1804
1851
  data, current, audit, history = _round_inputs(args)
1805
1852
  verification = data["planBodyVerification"]
1853
+ _validate_advisory_round(verification, args.round_number)
1806
1854
  if verification.get("gating") is False:
1807
- if args.round_number > 1:
1808
- raise PlanItemContractError(
1809
- "advisory plan-body gating allows one verification round"
1810
- )
1811
1855
  if args.self_fix_group or args.self_fix_note or args.self_fix_stop_reason:
1812
1856
  raise PlanItemContractError(
1813
1857
  "advisory plan-body gating forbids the self-fix loop"
@@ -1952,8 +1996,8 @@ def _correction_prompt(args: argparse.Namespace) -> str:
1952
1996
  return correction_prompt_text(_prompt(args))
1953
1997
 
1954
1998
 
1955
- def _narrow_assignment(
1956
- args: argparse.Namespace, assigned: set[object],
1999
+ def _narrow_dispatch_queue(
2000
+ args: argparse.Namespace, verification: dict[str, Any], known: set[object],
1957
2001
  ) -> set[object]:
1958
2002
  """이 라운드가 실제로 배정한 항목들.
1959
2003
 
@@ -1962,10 +2006,15 @@ def _narrow_assignment(
1962
2006
  37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
1963
2007
  tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
1964
2008
  `--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
1965
- `--items` 라운드의 artifact 주면 `dispatchQueue` 배정이
1966
- 된다. 미응답 검사 자체는 그대로다 좁힌 배정 안에서 여전히 전건을
1967
- 요구하므로, 워커가 자기 몫을 조용히 빠뜨리는 것은 계속 잡힌다.
2009
+ 배정 범위는 판정 저장과 완료 기록이 함께 써야 한다. 지역 변수만 좁히면
2010
+ 저장은 성공해도 완료가 과거 전체에서 라운드의 표를 요구한다.
2011
+ 상태의 큐만 좁히고 기존 표와 라운드 이력은 보존한다.
1968
2012
  """
2013
+ queue = verification.get("dispatchQueue")
2014
+ assigned = (
2015
+ {item_id for item_id in queue if isinstance(item_id, str)}
2016
+ if isinstance(queue, list) else known
2017
+ )
1969
2018
  items_path = getattr(args, "items", None)
1970
2019
  if items_path is None:
1971
2020
  return assigned
@@ -1981,6 +2030,10 @@ def _narrow_assignment(
1981
2030
  f"queue does not contain — pass the artifact this round dispatched, "
1982
2031
  f"not another round's"
1983
2032
  )
2033
+ verification["dispatchQueue"] = [
2034
+ item_id for item_id in (queue if isinstance(queue, list) else sorted(assigned))
2035
+ if item_id in narrowed
2036
+ ]
1984
2037
  return narrowed
1985
2038
 
1986
2039
 
@@ -2027,6 +2080,13 @@ def main(argv: list[str] | None = None) -> int:
2027
2080
  result = _HANDLERS[args.command](args)
2028
2081
  except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
2029
2082
  print(f"plan-items: {exc}", file=sys.stderr)
2083
+ manifest = getattr(args, "run_manifest", None)
2084
+ if manifest:
2085
+ logged = record_runtime_failure(
2086
+ Path(manifest), command=f"plan-items {args.command}", exit_code=2, detail=str(exc)
2087
+ )
2088
+ if not logged["ok"]:
2089
+ print(f"error-log: {logged['reason']}", file=sys.stderr)
2030
2090
  return 2
2031
2091
  if isinstance(result, str):
2032
2092
  print(result, end="")
@@ -1,6 +1,7 @@
1
1
  """역할별 리포트 입력을 검증해 계약 3.0 정본을 한 번 게시한다."""
2
2
  from __future__ import annotations
3
3
 
4
+ import copy
4
5
  import json
5
6
  import os
6
7
  import tempfile
@@ -17,7 +18,7 @@ from .clarification_items import (
17
18
  from .final_report_schema import load_schema_version, validate
18
19
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
19
20
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
20
- from .report_narrative import parse_narrative
21
+ from .report_narrative import parse_narrative, task_narrative_errors
21
22
  from .report_synthesis_packet import (
22
23
  report_synthesis_packet_paths,
23
24
  verify_report_synthesis_packet_sources,
@@ -111,23 +112,12 @@ def _input_preflight_issues(
111
112
  try:
112
113
  if row.key == "narrative":
113
114
  narrative = parse_narrative(row.path.read_text(encoding="utf-8"), schema)
114
- # 서사에는 header 가 없으므로 실행 명세의 작업 유형으로 판정 규칙을 적용한다.
115
- for branch in schema.get("allOf", []):
116
- verdict = branch.get("then", {}).get("properties", {}).get("finalVerdict")
117
- if verdict is None:
118
- continue
119
- errors = validate(
120
- {**narrative, "header": {"taskType": task_type}},
121
- {
122
- "$defs": schema.get("$defs", {}),
123
- "if": branch["if"],
124
- "then": {"properties": {"finalVerdict": verdict}},
125
- },
126
- )
127
- issues.extend(
128
- AssemblyIssue(row.owner, str(row.path), "finalVerdict", error)
129
- for error in errors
115
+ issues.extend(
116
+ AssemblyIssue(
117
+ row.owner, str(row.path), error.split(":", 1)[0], error,
130
118
  )
119
+ for error in task_narrative_errors(narrative, schema, task_type)
120
+ )
131
121
  elif row.key == "agent-activity":
132
122
  row.path.read_text(encoding="utf-8")
133
123
  else:
@@ -573,7 +563,7 @@ def _apply_selected_direction_snapshot(
573
563
  )
574
564
 
575
565
 
576
- def _selected_direction_publication_errors(
566
+ def selected_direction_plan_errors(
577
567
  data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
578
568
  ) -> list[str]:
579
569
  """게시 직전에 구현 진입 검증을 그대로 돌린다.
@@ -593,6 +583,21 @@ def _selected_direction_publication_errors(
593
583
  return validate_selected_direction_plan(data, brief_path, snapshot_path)
594
584
 
595
585
 
586
+ def validate_plan_draft(
587
+ data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
588
+ ) -> list[str]:
589
+ """작성자 입력을 게시하지 않고 같은 기계 투영과 의미 검사로 검증한다."""
590
+ draft = copy.deepcopy(dict(data))
591
+ _apply_selected_direction_snapshot(draft, project_root, manifest)
592
+ _attach_metadata(draft, manifest)
593
+ return [
594
+ *task_narrative_errors(
595
+ draft, load_schema_version("3.0"), str(manifest.get("taskType", "")),
596
+ ),
597
+ *selected_direction_plan_errors(draft, project_root, manifest),
598
+ ]
599
+
600
+
596
601
  def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
597
602
  task_key = str(manifest.get("taskKey") or "")
598
603
  parts = task_key.split(":")
@@ -872,7 +877,7 @@ def assemble_report(
872
877
  raise ReportAssemblyError(tuple(input_issues))
873
878
  data = _compose(project_root, manifest_path, manifest, inputs, schema)
874
879
  errors = validate(data, schema)
875
- direction_errors = _selected_direction_publication_errors(
880
+ direction_errors = selected_direction_plan_errors(
876
881
  data, project_root, manifest
877
882
  )
878
883
  value = manifest.get("expectedReportRecordPath")
@@ -44,7 +44,7 @@ import json
44
44
  from datetime import date, datetime, timezone
45
45
  import subprocess
46
46
  import sys
47
- from dataclasses import dataclass
47
+ from dataclasses import asdict, dataclass
48
48
  from pathlib import Path
49
49
  from typing import Any, Callable, Mapping, Sequence
50
50
 
@@ -73,6 +73,7 @@ from .stage_targets import (
73
73
  integrate_and_teardown_whole_task,
74
74
  )
75
75
  from .session import observe_lead_session
76
+ from .error_log_write import record_runtime_failure
76
77
 
77
78
  # 포인터 값 타입만 쓴다. `okstra_project.phase_pointer` 는 okstra 안의
78
79
  # 어떤 것도 import 하지 않는 leaf 라 순환이 없다 — 그 모듈 도크스트링.
@@ -560,13 +561,17 @@ def step_payload(
560
561
  command: Sequence[str],
561
562
  result: subprocess.CompletedProcess[str],
562
563
  ) -> dict[str, Any]:
563
- return {
564
+ payload = {
564
565
  "name": name,
565
566
  "command": list(command),
566
567
  "exitCode": result.returncode,
567
568
  "stdoutTail": tail(result.stdout),
568
569
  "stderrTail": tail(result.stderr),
569
570
  }
571
+ if name == STEP_PROJECT_ACTIVITY and result.returncode != 0 and result.stdout:
572
+ # 소유자 오류는 요약 문자열 길이에 잘리지 않은 전체 목록으로 전달한다.
573
+ payload["issues"] = json.loads(result.stdout).get("issues", [])
574
+ return payload
570
575
 
571
576
 
572
577
  def run_finalize(
@@ -610,6 +615,33 @@ def run_finalize(
610
615
  ctx, commands, selected, all_commands
611
616
  )
612
617
 
618
+ steps, first_failure = _execute_finalize_steps(ctx, commands, before_step)
619
+ pointer, pointer_error = _recorded_next_phase(ctx)
620
+ payload: dict[str, Any] = {
621
+ "ok": not first_failure,
622
+ "reason": first_failure,
623
+ "steps": steps,
624
+ "nextRecommendedPhase": pointer,
625
+ }
626
+ if pointer_error:
627
+ payload["nextRecommendedPhaseError"] = pointer_error
628
+ following = _next_in_group(steps)
629
+ if following:
630
+ payload["nextInGroup"] = following
631
+ if first_failure:
632
+ payload["recovery"] = _finalize_recovery(ctx, payload)
633
+ # closeout 이 실제로 건넬 명령. 표를 리드의 기억에 맡기지 않는다.
634
+ payload["nextCommand"] = closeout_command(payload)
635
+ payload["reportPaths"] = _closeout_report_paths(ctx)
636
+ return payload
637
+
638
+
639
+ def _execute_finalize_steps(
640
+ ctx: FinalizeContext, commands: Sequence[tuple[str, list[str]]],
641
+ before_step: Callable[[str], None] | None,
642
+ ) -> tuple[list[dict[str, Any]], str]:
643
+ """처리 순서를 지키며 각 실패를 기록하고, 전체 성공일 때 실행을 닫는다."""
644
+ steps: list[dict[str, Any]] = []
613
645
  first_failure = ""
614
646
  validated = False
615
647
  preflight_failed = False
@@ -623,31 +655,60 @@ def run_finalize(
623
655
  if before_step is not None:
624
656
  before_step(name)
625
657
  result = _run_finalize_step(ctx, name, command)
626
- steps.append(step_payload(name, command, result))
658
+ step = step_payload(name, command, result)
659
+ if result.returncode != 0:
660
+ step["errorLogAppend"] = record_runtime_failure(
661
+ ctx.manifest_path, project_root=ctx.project_root,
662
+ command=f"report-finalize {name}", exit_code=result.returncode,
663
+ detail="\n".join(filter(None, [result.stderr, result.stdout])) or f"{name} exited {result.returncode}",
664
+ )
665
+ steps.append(step)
627
666
  if name == STEP_PREFLIGHT:
628
667
  preflight_failed = result.returncode != 0
629
668
  if result.returncode != 0 and not first_failure:
630
669
  first_failure = f"{name} failed with exit code {result.returncode}"
631
670
  if name == STEP_VALIDATE_RUN and result.returncode == 0:
632
671
  validated = True
633
- if validated:
672
+ if validated and not first_failure:
634
673
  _record_run_end(ctx.team_state_path)
635
- pointer, pointer_error = _recorded_next_phase(ctx)
636
- payload: dict[str, Any] = {
637
- "ok": not first_failure,
638
- "reason": first_failure,
639
- "steps": steps,
640
- "nextRecommendedPhase": pointer,
674
+ return steps, first_failure
675
+
676
+
677
+ def _finalize_recovery(ctx: FinalizeContext, result: Mapping[str, Any]) -> dict[str, Any]:
678
+ """새 실행 대신 실패한 처리부터 재개할 명령과 원본 소유자 오류를 전달한다."""
679
+ order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
680
+ failed = {step["name"] for step in result.get("steps") or [] if step.get("exitCode")}
681
+ pointer = promote_next_phase(result.get("nextRecommendedPhase"))
682
+ if (failed == {STEP_VALIDATE_RUN} and pointer["status"] == STATUS_BLOCKED
683
+ and pointer["phase"] and pointer["phase"] != ctx.task_type):
684
+ return {"mode": "phase-reentry", "phase": pointer["phase"], "instruction": pointer["rationale"]}
685
+ command = [
686
+ "okstra", "report-finalize", "--project-root", str(ctx.project_root),
687
+ "--run-manifest", str(ctx.manifest_path), "--report", str(ctx.data_path),
688
+ "--team-state", str(ctx.team_state_path),
689
+ ]
690
+ resume_steps = _recovery_step_names(result, order)
691
+ if ctx.report_contract_version == "3.0" and STEP_PROJECT_ACTIVITY not in resume_steps:
692
+ # 수정한 서사·판정 상태를 정본에 다시 조립해야 후속 검사가 새 입력을 읽는다.
693
+ resume_steps = list(order[order.index(STEP_PROJECT_ACTIVITY):])
694
+ for name in resume_steps:
695
+ command.extend(["--only", name])
696
+ issues = []
697
+ for step in result.get("steps") or []:
698
+ if step.get("name") != STEP_PROJECT_ACTIVITY or not step.get("exitCode"):
699
+ continue
700
+ issues.extend(step.get("issues") or [])
701
+ return {
702
+ "mode": "same-run", "resumeCommand": command, "issues": issues,
703
+ "instruction": (
704
+ "Repair the reported causes in this run using the owning input's correction command, "
705
+ "then execute resumeCommand. Preserve approvals, model choices and completed evidence. "
706
+ "If plan content changes, re-verify the affected items before finalizing. "
707
+ "After a narrative correction, check existing translations with report-translate check-data "
708
+ "and regenerate them if their source no longer matches. "
709
+ "Ask only for an unresolved user decision or an actual external prerequisite."
710
+ ),
641
711
  }
642
- if pointer_error:
643
- payload["nextRecommendedPhaseError"] = pointer_error
644
- following = _next_in_group(steps)
645
- if following:
646
- payload["nextInGroup"] = following
647
- # closeout 이 실제로 건넬 명령. 표를 리드의 기억에 맡기지 않는다.
648
- payload["nextCommand"] = closeout_command(payload)
649
- payload["reportPaths"] = _closeout_report_paths(ctx)
650
- return payload
651
712
 
652
713
 
653
714
  def _closeout_report_paths(ctx: FinalizeContext) -> dict[str, Any]:
@@ -852,7 +913,11 @@ def _run_project_activity(
852
913
  ctx.data_path,
853
914
  )
854
915
  count = len(rows)
855
- except (ActivityProjectionError, ReportAssemblyError) as exc:
916
+ except ReportAssemblyError as exc:
917
+ return subprocess.CompletedProcess(
918
+ command, 1, json.dumps({"issues": [asdict(issue) for issue in exc.issues]}), str(exc)
919
+ )
920
+ except ActivityProjectionError as exc:
856
921
  return subprocess.CompletedProcess(command, 1, "", str(exc))
857
922
  return subprocess.CompletedProcess(
858
923
  command, 0, json.dumps({"count": count}), ""
@@ -1090,6 +1155,9 @@ def closeout_command(result: Mapping[str, Any]) -> dict[str, str]:
1090
1155
  돌려주는 것은 `command` 와 `note` 두 칸이다. `command` 가 비면 근거
1091
1156
  문장이 그 자리를 대신한다.
1092
1157
  """
1158
+ recovery = result.get("recovery")
1159
+ if isinstance(recovery, Mapping) and recovery.get("mode") == "same-run":
1160
+ return {"command": "", "note": str(recovery["instruction"])}
1093
1161
  failed_validate = any(
1094
1162
  step.get("name") == STEP_VALIDATE_RUN and step.get("exitCode") != 0
1095
1163
  for step in (result.get("steps") or [])
@@ -179,6 +179,28 @@ def validate_writer_owned(data: Mapping[str, Any], schema: Mapping[str, Any]) ->
179
179
  return validate(dict(data), writer_owned_schema(schema))
180
180
 
181
181
 
182
+ def task_narrative_errors(
183
+ data: Mapping[str, Any], schema: Mapping[str, Any], task_type: str,
184
+ ) -> list[str]:
185
+ """실행 유형에 따른 판정·후속 작업 규칙을 정본 스키마에서 검사한다."""
186
+ context = {**data, "header": {"taskType": task_type}}
187
+ errors: list[str] = []
188
+ for branch in schema.get("allOf", []):
189
+ fields = {
190
+ key: value
191
+ for key, value in branch.get("then", {}).get("properties", {}).items()
192
+ if key in {"finalVerdict", "followUpTasks"}
193
+ }
194
+ if not fields:
195
+ continue
196
+ errors.extend(validate(context, {
197
+ "$defs": schema.get("$defs", {}),
198
+ "if": branch["if"],
199
+ "then": {"properties": fields},
200
+ }))
201
+ return list(dict.fromkeys(errors))
202
+
203
+
182
204
  def writer_owned_path_defect(path: str) -> str | None:
183
205
  """이 필드 경로에 작성자가 쓸 수 없는 이유, 쓸 수 있으면 None.
184
206
 
@@ -246,6 +246,13 @@ class ReportSynthesisPacket:
246
246
  "that exact order.",
247
247
  "`endStateCoverage` must contain exactly one row per original "
248
248
  "requirement id; an `addressed` row names its `coveredBy` anchor.",
249
+ "Coverage references use existing identifiers: `stageRefs` contains stage numbers, "
250
+ "`stepRefs` uses `<stage>.<step>` (for example `1.2`), `validationRefs` uses "
251
+ "validation checklist ids, and `fileRefs` uses exact changed file paths without annotations. "
252
+ "Map QA script changes to their requirements too. A future follow-up is not a stage or validation id.",
253
+ "Derive `coverageSummary` from the coverage rows; do not declare exact 100% "
254
+ "or `plan-ready` while requirements are uncovered or file changes are unmapped. "
255
+ "Preserve recorded user decisions when describing deferred work.",
249
256
  ]
250
257
 
251
258
  def to_dict(self) -> dict[str, Any]:
@@ -475,4 +475,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
475
475
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
476
476
  - Name every file you show the user as a markdown link — `[<what it is>](<path>)`, with the path inside the parentheses. That is the only form the host renders as clickable; a path in backticks is text the user has to copy out. The `report-finalize` result's `reportPaths.markdown` carries the run's report, report record, and team state already in that form. Commands stay in backticks — a link is for a file, not for something to run.
477
477
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
478
- - 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. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed one-line cause then `/okstra-run` for the blocked pointer's `phase` when present, otherwise the current phase; otherwise `/okstra-inspect status`.
478
+ - 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 `report-finalize` returns `recovery.mode: same-run`, continue the authorized corrections in this run and execute `recovery.resumeCommand` before closeout; preserve approvals and model choices without reopening the wizard. The command and owner issues are supplied by `report_finalize._finalize_recovery`. When the lead (or this skill, after the lead returns) reports a successfully finalized run over, close with the user's next action — one command they can run now. A prohibition is not a next action. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed with `recovery.mode: phase-reentry` name the cause and use `nextCommand` for the recorded earlier phase; otherwise `/okstra-inspect status`.
@@ -3705,31 +3705,30 @@ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
3705
3705
 
3706
3706
 
3707
3707
  def _critic_non_error_verdicts(item: dict) -> list[dict]:
3708
- return [
3709
- row
3710
- for row in (item.get("verdicts") or [])
3711
- if isinstance(row, dict)
3712
- and is_critic_worker(str(row.get("worker") or ""))
3713
- and str(row.get("verdict") or "").strip().upper()
3714
- not in ("", "VERIFICATION-ERROR")
3715
- ]
3708
+ """현재 기록된 비판 검토자의 최신 유효 판정."""
3709
+ rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
3710
+ critic = [row for row in rows
3711
+ if is_critic_worker(row.get("worker", ""))
3712
+ and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}]
3713
+ latest = max((row.get("round", 1) for row in critic), default=0)
3714
+ return [row for row in critic if row.get("round", 1) == latest]
3716
3715
 
3717
3716
 
3718
- def _tie_gate_class(item: dict, agree: list, disagree: list) -> str | None:
3719
- """분석자 동수면 critic 가르고, 없으면 재검증. 동수가 아니면 None."""
3720
- if not (len(disagree) == len(agree) and disagree):
3721
- return None
3717
+ def _critic_gate_class(item: dict) -> str | None:
3718
+ """비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
3722
3719
  critic = _critic_non_error_verdicts(item)
3723
3720
  if not critic:
3724
- return "needs-reverify"
3725
- if any(
3726
- str(row.get("verdict") or "").strip().upper() == "DISAGREE"
3727
- and str(row.get("breakageKind") or "").strip().lower()
3728
- not in _ADVISORY_ONLY_KINDS
3729
- for row in critic
3730
- ):
3731
- return "majority-disagree"
3732
- return "has-dissent"
3721
+ return None
3722
+ critic_dissent = [row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"]
3723
+ if critic_dissent:
3724
+ if str(item.get("id", "")).upper().startswith("P-RB"):
3725
+ return "has-dissent"
3726
+ return "majority-disagree" if any(
3727
+ str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
3728
+ for row in critic_dissent
3729
+ ) else "has-dissent"
3730
+ dissent = any(str(row.get("verdict", "")).upper() == "DISAGREE" for row in item.get("verdicts", []))
3731
+ return "has-dissent" if dissent else "full-consensus"
3733
3732
 
3734
3733
 
3735
3734
  def _classify_plan_item_gate(item: dict) -> str:
@@ -3743,6 +3742,9 @@ def _classify_plan_item_gate(item: dict) -> str:
3743
3742
  reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
3744
3743
  settles it.
3745
3744
  """
3745
+ corrected = _critic_gate_class(item)
3746
+ if corrected is not None:
3747
+ return corrected
3746
3748
  tokens = [
3747
3749
  (
3748
3750
  str(v.get("verdict") or "").strip().upper(),
@@ -3800,9 +3802,8 @@ def _classify_plan_item_gate(item: dict) -> str:
3800
3802
  # made the gate stricter than a healthy roster would.
3801
3803
  if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3802
3804
  return "majority-disagree"
3803
- settled = _tie_gate_class(item, agree, blocking_disagree)
3804
- if settled is not None and len(non_error) >= 2:
3805
- return settled
3805
+ if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
3806
+ return "needs-reverify"
3806
3807
  if (
3807
3808
  len(non_error) >= 2
3808
3809
  and blocking_disagree
@@ -9406,6 +9407,7 @@ def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
9406
9407
  ),
9407
9408
  )
9408
9409
  elif task_type == "implementation-planning":
9410
+ from okstra_ctl.report_assembly import selected_direction_plan_errors
9409
9411
  from okstra_ctl.implementation_direction import (
9410
9412
  stage_validation_executability_errors,
9411
9413
  )
@@ -9415,6 +9417,12 @@ def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
9415
9417
  data.get("implementationPlanning") or {}
9416
9418
  )
9417
9419
  )
9420
+ failures.extend(selected_direction_plan_errors(
9421
+ data, Path(str(manifest.get("projectRoot") or run_manifest_path.parent)), manifest
9422
+ ))
9423
+ _append_stage_data_failures(
9424
+ data, failures, _task_root_from_run_dir(report_path.parent.parent),
9425
+ )
9418
9426
  _validate_planning_conformance_declared(report_path, failures)
9419
9427
  print(
9420
9428
  json.dumps(