okstra 0.199.3 → 0.199.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/docs/cli.md +1 -1
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/launch.template.md +12 -0
  5. package/runtime/prompts/lead/okstra-lead-contract.md +2 -0
  6. package/runtime/prompts/lead/plan-body-verification.md +17 -5
  7. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  8. package/runtime/prompts/wizard/prompts.ko.json +1 -0
  9. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +18 -4
  10. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +11 -3
  11. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +23 -1
  12. package/runtime/python/okstra_ctl/consumers.py +0 -55
  13. package/runtime/python/okstra_ctl/domain/write_policy.py +7 -1
  14. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -17
  15. package/runtime/python/okstra_ctl/final_report_schema.py +16 -16
  16. package/runtime/python/okstra_ctl/plan_items_cli.py +29 -4
  17. package/runtime/python/okstra_ctl/report_assembly.py +15 -18
  18. package/runtime/python/okstra_ctl/report_corrections.py +24 -15
  19. package/runtime/python/okstra_ctl/report_finalize.py +1 -1
  20. package/runtime/python/okstra_ctl/report_narrative.py +33 -6
  21. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -0
  23. package/runtime/python/okstra_ctl/wizard/engine.py +4 -16
  24. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +7 -37
  25. package/runtime/python/okstra_ctl/worktree_status_cli.py +2 -3
  26. package/runtime/python/okstra_ctl/write_policy.py +51 -4
  27. package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
  28. package/runtime/schemas/final-report-v2.0.schema.json +1 -1
  29. package/runtime/schemas/final-report-v3.0.schema.json +1 -1
  30. package/runtime/skills/okstra-run/SKILL.md +9 -1
  31. package/runtime/validators/validate-run.py +3 -0
package/docs/cli.md CHANGED
@@ -816,7 +816,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
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
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. Use `--items` to restore the prepared partial queue when an older version saved critic verdicts but left the full queue in state; only the assigned items need verdicts for this round, and prior votes elsewhere remain unchanged. 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. |
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
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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.199.3",
3
+ "version": "0.199.5",
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.3",
3
- "builtAt": "2026-09-12T07:12:53.937Z",
2
+ "package": "0.199.5",
3
+ "builtAt": "2026-09-12T23:19:40.021Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -41,6 +41,18 @@ Relaying a wizard step does not change the option set — relay every `options[]
41
41
 
42
42
  ## User closeout (BLOCKING)
43
43
 
44
+ Progress, remaining work, and recommendation
45
+
46
+ At each phase or implementation-stage boundary, at task completion, and before a controlled session pause or handoff, give a concise update in the resolved Report Language. Identify the task and the phase or stage, then include these three items (translate the labels):
47
+
48
+ - Progress: what this boundary actually completed, the result of checks already run, and links to the relevant persisted evidence. Distinguish a completed phase from a completed task; a dispatched worker is still in progress.
49
+ - Remaining work: unfinished work in the current task, open approval or input blockers with their IDs, and any registered follow-up tasks. Say explicitly when none remains in the verified scope; when state is unavailable, say unknown rather than inventing completion or a backlog.
50
+ - Recommendation: the next concrete action and why it comes next. After finalization, use `nextCommand` and `nextRecommendedPhase.rationale` from the result under the routing rules below, including `nextInGroup` when present. Before finalization, use the active run's recorded stage queue and recovery or resume information; do not reuse an earlier run's pointer or invent a resume command.
51
+
52
+ During an authorized continuous run, put the update beside the existing progress checkpoint and continue to the next queued step without asking for confirmation merely to deliver this update. At a controlled session pause or handoff, distinguish persisted results from in-progress work and include the available resume reference. At task completion, distinguish remaining work in this task from follow-ups or the next task in its group. If there is no recorded next action, say none is recommended. These updates do not replace checkpoints or authorize another task.
53
+
54
+ Prompt delivery is checked by `tests/contract/test_next_phase_authoring_delivery.py::test_lead_closeout_names_a_command_the_user_can_run`; this is guidance for the lead's prose, not runtime validation of the emitted message.
55
+
44
56
  After Phase 7 persistence, the last user-facing message of this run is the next command. A status dump is not a close. A prohibition (`do not start implementation`) is not a next action. This applies to every task type.
45
57
 
46
58
  **Read the pointer from the `report-finalize` result — do not re-derive it.** Its top-level `nextRecommendedPhase` object (`phase`, `status`, `rationale`) is the value every row below branches on, and the same three values are repeated on stderr as `next phase status:` / `next phase:` / `next phase rationale:`. You did not write that object and you cannot recompute it: you author your task type's routing field, and Phase 7 projects the pointer from it plus the approval state — for `implementation-planning` there is no routing field at all. If the result carries `nextRecommendedPhaseError`, say in one line that the pointer could not be read, then close on the `validate-run` row below. 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.
@@ -104,6 +104,8 @@ User-utterance interpretation rule:
104
104
 
105
105
  ## Progress reporting (BLOCKING)
106
106
 
107
+ At each phase or implementation-stage boundary, at task completion, and before a controlled session pause or handoff, follow the launch prompt's "Progress, remaining work, and recommendation" guidance. Include the result and evidence, unfinished work and blockers, and the next action with its reason. During an authorized continuous run, deliver this update beside the checkpoint and continue; do not turn the update into an approval gate. The same guidance applies to the final reply after persistence below.
108
+
107
109
  A single okstra run frequently spans 30–120 minutes with multi-minute silent windows while workers run; without progress signals the user cannot distinguish "still working" from "hung". Lead MUST emit a single short progress line at each checkpoint below — plain user-facing text in a separate brief message (not buried inside a tool call), one line per checkpoint, format: `PROGRESS: <phase-id> <verb-phrase>`. Emit the line raw — the literal `PROGRESS:` token must begin the line. Do NOT wrap it in inline-code backticks (`` `PROGRESS: ...` ``) or a ```` ``` ```` code fence; markdown wrapping is what the post-hoc conformance validator scrapes around, and raw emit keeps the signal unambiguous.
108
110
 
109
111
  Record each checkpoint with `okstra lead-progress append --project-root <dir> --run-manifest <path> --phase <phase-id>`, then emit the `progressLine` it prints as the user-facing line. The command resolves `leadEventsPath` from the run manifest and writes the checkpoint there — on a host whose adapter declares `sessionAccounting: artifact-only` that ledger is the only place the post-hoc validator can read it, so a conversation line alone leaves no trace and the run is reported as missing the checkpoint. Pass `--worker <role>` on the per-worker checkpoints (it rewrites a phase-specific functional label into the roster role team-state records), `--field NAME=VALUE` for the remaining `key=value` tokens in the order the line carries them, and `--detail <text>` where the line ends in a verb phrase; the wording listed below is the default for the fixed-prose checkpoints.
@@ -335,10 +335,20 @@ Every plan-body verification and self-fix instruction passes [okstra-lead-contra
335
335
 
336
336
  Before each verifier call, write one task-instructions file under the current
337
337
  run's `state/` directory and run `okstra agent-prompt materialize` with
338
- `--audience reverification-worker`,
339
- `--assignment-ref reverify/<workerId>`, the exact `--worker-id`, and
338
+ `--audience reverification-worker`, the exact `--worker-id`, and
340
339
  `--dispatch-kind plan-verify-r<N>`, matching the `<N>` in the result filename of
341
- step 3. That kind, not `reverify-r<N>`, is what this round takes: the verification
340
+ step 3. For analyser verifiers, use `--assignment-ref reverify/<workerId>`.
341
+ For `critic-worker`, use `--assignment-ref critic/scope` from this run's
342
+ `invocationAssignments`, including a tie round or a later critic correction.
343
+ The verdict worker name `critic-worker` does not create a `reverify/critic-worker`
344
+ assignment. Keep the selected critic's existing model assignment.
345
+ **Enforced:** `okstra_ctl.agent.prompt_cli.run_identity._validate_run_identity`
346
+ accepts the planning critic's verification audience; materialization resolves
347
+ the declared assignment and reports the correct critic reference when the
348
+ invented `reverify/critic-worker` reference is used.
349
+ `tests/run/test_agent_prompt_cli.py` verifies the declared critic's reservation
350
+ and dispatch identity plus this recovery diagnostic.
351
+ That kind, not `reverify-r<N>`, is what this round takes: the verification
342
352
  prompt contract requires the renderer signature of the queue's canonical renderer,
343
353
  and for plan items that renderer is `okstra plan-items prompt`, not `okstra
344
354
  convergence reverify-prompt`. Pass that command's output verbatim as
@@ -355,7 +365,9 @@ source `RoleExecution` row from the run manifest's static role state. Use its
355
365
  `RoleExecution` row's `roleExecutionRef`, not that row's
356
366
  `sourceRoleExecutionRef` field. Pass `--source-role-execution-ref
357
367
  <sourceRoleExecutionRef>`. Do not derive that reference from `workerId`,
358
- provider, model, or execution-label text. A legacy v1 run omits this flag. Run
368
+ provider, model, or execution-label text. For `critic-worker`, select the static
369
+ `role: critic` row; the runtime reserves its verifier execution under the
370
+ existing critic participant and model. A legacy v1 run omits this flag. Run
359
371
  `okstra agent-prompt verify` against the
360
372
  returned `metadataPath` before dispatch and use the returned `promptPath`
361
373
  without modification. Native-session calls use only `hostModelValue`; before
@@ -391,7 +403,7 @@ For contract 3.0, `prepare` checks the selected-direction draft with the same se
391
403
  - `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.)
392
404
  - `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.
393
405
  - `needs-reverify` — one of two shapes the round could not settle.
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 already saved the critic votes but left the full queue in state, recover with `okstra plan-items complete-round --state <state> --run-manifest <manifest> --round <N> --items <the tie-vote plan-items artifact>`. 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.
406
+ - **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.
395
407
  - **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`.
396
408
  - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
397
409
  5. Gate result resolution:
@@ -175,7 +175,7 @@ roles:
175
175
  - `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
176
176
  - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | outcome | expected`. `outcome` is one word — `PASS` or `FAIL` — and `expected` is the sentence saying what that looks like here; a verdict written inside the sentence is not read as one. The `files` cell lists each touched path in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...`), which does not resolve and is rejected by plan-body verification as a kind-b path mismatch. **The narrative row additionally carries `plannedPaths`: the same paths as an array, one repository-relative path per entry, with no globs, exclusions, counts or commentary.** `files` is the sentence a reader sees; `plannedPaths` is the ledger report assembly preserves and the implementer write policy enforces. When a step legitimately covers a set too large to enumerate, split it or name the directory the set lives under. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`outcome` = `FAIL`); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`outcome` = `PASS`); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section. A stage that is truthfully none of those three declares `TDD exemption: user-bypass — <the user's words>`, which holds only while the user has granted it for that stage with `okstra prepare --tdd-bypass "<stage>:<reason>"` — never file the nearest of the three instead. Validator S10c enforces RED-first + GREEN; the `outcome` cell agreeing with its prefix is a schema conditional. S10e rejects an unsupported exemption reason and a `user-bypass` with no user grant (`validators/validate-implementation-plan-stages.py`).
177
177
  - **The `command` cell runs inside an okstra task worktree, not a bare checkout (BLOCKING).** okstra provisions `.okstra`, the configured sync entries (`.project-docs`, `.claude`, …), and — for `implementation` — a nested `stage-<N>/` worktree into the tree the step executes in. Two consequences bind every command you write:
178
- - **Clean-tree assertions use `okstra worktree-status --check-clean`.** A bare `git status --porcelain` is never empty there, so an assertion built on one fails on okstra's scaffolding rather than on the stage's work. The okstra command asks the same question over source paths only and exits 1 when dirty, so it stands alone as a step's assertion: `okstra worktree-status --check-clean`. Validator S13 rejects the bare form. Do not add a `git tag stage-<N>-exit` to the step okstra writes that tag itself when it settles the stage, at the commit the carry evidence records, and a step that tags mid-stage puts it on an earlier commit.
178
+ - **Clean-tree assertions use `okstra worktree-status --check-clean`.** A bare `git status --porcelain` is never empty there, so an assertion built on one fails on okstra's scaffolding rather than on the stage's work. The okstra command asks the same question over source paths only and exits 1 when dirty, so it stands alone as a step's assertion: `okstra worktree-status --check-clean`. Validator S13 rejects the bare form. Do not add a `git tag stage-<N>-exit` to the step. Stage completion records the commit in the consumer ledger without creating or moving git tags.
179
179
  - **Never read an `.okstra/` artifact back out of a git object.** `.okstra/**` is gitignored and never committed — the executor aborts a commit that stages an ignored path and the verifier reports a committed `.okstra` path as a branch defect — so `git cat-file -e <tag>:.okstra/…`, `git show <tag>:.okstra/…`, and every variant of that read can never resolve, at any tag, in any stage. A later stage that needs a QA artifact reads it from the working tree or receives it through the carry sidecar / verifier result; do not design a stage contract around one being reachable from a tag. Validator S12 rejects the read.
180
180
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
181
181
  - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — declare that a Tier3 verification script will prove this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. This phase emits the line and the `requires` set only. Do NOT write `<task_root>/qa/scripts/stage-<N>.*` and do NOT add a `runCommand` or `conformance-manifest.json` entry here — the matching `implementation` stage run creates the script file and the manifest `runCommand`. A plan that declares tests with no script file on disk is valid at this gate. The data.json `conformanceTests` value carries only the remainder after the `Conformance tests: stage-<N> — ` prefix — never the `stage-<N> — ` label itself (report assembly strips a leftover label at publication, and the implementation entry gate rejects one).
@@ -646,6 +646,7 @@
646
646
  },
647
647
  "confirmation": {
648
648
  "header": "선택 확인:",
649
+ "provider_data_scope": "\n전달 대상: 위 역할·모델 목록의 제공자(현재 세션 및 선택한 외부 모델 제공자).\n전달 자료: `{project_root}`의 이 작업에 대한 작업 개요, 작업 수행에 필요한 저장소 소스·문서, 선택한 근거 자료 및 실행 중 생성되는 관련 분석·검증 결과.\n진행을 선택하면 위 대상에 해당 자료를 전달하여 선택한 작업을 실행하는 것을 승인합니다. 선택에 없는 제공자나 작업과 무관한 자료는 승인 범위에 포함되지 않습니다. 실행 환경의 권한 검토는 별도로 적용됩니다.",
649
650
  "static_role": " static-role : {role}#{ordinal} / {model}",
650
651
  "dynamic_role": " dynamic-role : {role} / reuse selected participant model",
651
652
  "workers_implementation_default": " workers : (프로필 기본 — executor + verifier 2 + report-writer)",
@@ -136,11 +136,11 @@ The `confirm` prompt's `label` is the selection summary (one line per resolved i
136
136
 
137
137
  ### Runtime-generated selectable screens
138
138
 
139
- `wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. `wizard/picker_navigation.py` preserves all original choices while paging long lists, collecting multi-selection through toggle/complete choices, and disambiguating duplicate labels. Oversized or unsupported groups are presented one member at a time. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
139
+ `wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. Lists use same-screen question tabs only when the host supports the complete grouped selection. Otherwise, choices beyond `nativeLimits` and unsupported multi-selections use a complete numbered list. Codex has no native multi-select, so a single choice with more than three options uses `numbered-single`; multi-selection uses `numbered-multi`. Oversized or unsupported groups are presented one member at a time, with every option for that member visible. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
140
140
 
141
- Render exactly the returned current screen using its `interaction.kind`, including navigation and completion options. Submit their original values through `okstra wizard step` like other options. The runtime keeps navigation and partial selections in the state file and does not submit the underlying workflow decision until selection is complete. Do not reconstruct the full model list, perform pagination yourself, or replace model choices with a request to type `provider/model`. A repeated step ID after navigation or a toggle is the next screen, not a duplicated question; render that newly returned screen once.
141
+ Render the returned screen using its `interaction.kind`. For numbered interactions, show every option label and description in original order in one message, then accept a number, label, or value; multiple choices accept comma-separated input. Submit that reply unchanged through `okstra wizard step`. Do not add a next-page choice, truncate the list, or require the user to remember a model identifier.
142
142
 
143
- If the runtime still returns a numbered interaction when the user requires a selector, preserve the state and check installation and live capabilities. Do not invent a text-input exception. Text steps remain text steps; a direct-input choice is selected through the picker before the runtime asks for the custom value.
143
+ The complete numbered list is the intended fallback when a choice cannot fit the native control or same-screen tabs, including when the user generally prefers a selector. Text steps remain text steps; selecting the direct-input option opens the wizard's custom-value step.
144
144
 
145
145
  ### Plan decisions and execution permissions
146
146
 
@@ -168,7 +168,7 @@ Display the question only through the selected tool. Do not print it or its opti
168
168
  |---|---|
169
169
  | `read_artifacts` | Read the manifest-provided paths through the current host's file interface. |
170
170
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
171
- | `prompt_user` | Use the client-appropriate, mode-available question tool selected above for clarifications that fit `nativeLimits`. Show the question once through that tool and wait for the actual answer. Emit the question as the last thing in that turn: assistant text emitted after the call renders below the question and separates it from the user's answer. Follow the host's separate restrictions for permission requests. Use host text for unsupported interactions only when the user has not required a selectable interface; otherwise preserve the pending step and follow the recovery above. |
171
+ | `prompt_user` | Use the client-appropriate, mode-available question tool selected above for clarifications that fit `nativeLimits`. Show the question once through that tool and wait for the actual answer. Emit the question as the last thing in that turn: assistant text emitted after the call renders below the question and separates it from the user's answer. Follow the host's separate restrictions for permission requests. For lists that cannot fit the native control or same-screen tabs, show the complete numbered list and accept the next message as the answer. |
172
172
  | `dispatch_worker` | Verify each materialized invocation first. Dispatch `runner=native-session` with the current Codex host's primitive, the returned `promptPath`, and `hostModelValue`. Pass `runner=cli-wrapper` assignments to `okstra worker-dispatch --project-root <root> --run-manifest <path> --workers <ids>`; use `--dry-run` first when required. **Not in a cmux run:** when `terminalBackend` is `cmux-pane`, the cmux adapter overrides this row. |
173
173
  | `await_workers` | Await native host workers through the host primitive and CLI workers through synchronous dispatch, then verify team-state terminal records and Result Paths for both. |
174
174
  | `redispatch_worker` | Materialize and verify a fresh invocation, then start a fresh native worker or `okstra worker-dispatch` attempt according to the persisted runner. |
@@ -176,12 +176,26 @@ Display the question only through the selected tool. Do not print it or its opti
176
176
  | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Use `okstra lead-progress append --phase <phase-id>` for a checkpoint and `okstra agent-activity append --kind <kind>` for an activity record; both resolve the ledger path from the run manifest. Emit the matching `PROGRESS:` line — the command prints it as `progressLine` — and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
177
177
  | `collect_usage` | Collect artifact/rollout-backed usage through the existing Okstra token-usage path; never read Claude session JSONL as a substitute. |
178
178
 
179
+ ## Codex execution permissions
180
+
181
+ ### Permission before bundle preparation
182
+
183
+ Before the first `okstra render-bundle` invocation, check the current host's declared filesystem restrictions and approval policy. Bundle preparation writes run artifacts and can create a task or stage worktree with `git worktree add -b`, which writes to the source repository's Git metadata. `--render-only` does not make this command read-only, and writable access to the project directory or `~/.okstra` does not imply writable access to protected `.git` metadata.
184
+
185
+ When those writes are restricted, use the host's supported execution permission on the original `render-bundle` command (`sandbox_permissions: "require_escalated"` for `exec_command` when available and permitted), preserving every `outcome.renderArgv` token. Explain that the command prepares the selected run and may create its branch and worktree. Do not first run it under restrictions already known to block these writes. Apply the same check to subsequent phase or stage bundle preparations. A wizard confirmation does not grant host privileges; follow the host's approval decision. If escalation is prohibited, report the constraint and retain the invocation instead of changing filesystem permissions or choosing an alternate launcher.
186
+
187
+ If preparation has already failed with a Git lock creation error or `Operation not permitted`, preserve the exact error and retry the same command only after the host permits the required writes. This is host-call guidance, not a permission grant or a runtime-enforced check.
188
+
179
189
  ## Codex dispatch details
180
190
 
181
191
  ### Permission at the dispatch boundary
182
192
 
183
193
  Before a live CLI worker dispatch, check the current host's declared sandbox and approval policy. In a restricted Codex session, request the host's supported execution permission on the dispatch command itself (`sandbox_permissions: "require_escalated"` for `exec_command` when that mechanism is available and permitted). Scope the request to the prepared run and explain that it starts the selected worker processes. Follow the host's approval decision; a wizard confirmation or a successful preflight does not grant this permission.
184
194
 
195
+ Carry the user's existing authorization into that request: identify the task, the selected providers/models from the prepared assignments, and the task brief, relevant repository source/documents, selected evidence, and related run results those workers will process. Cite the actual `Proceed` response and the data-transfer scope displayed in that confirmation; `outcome.confirmationText` is the runtime's summary reference, not proof by itself that the user saw or accepted it. Explain this scope in the execution tool's justification rather than describing only process startup. Data-transfer authorization and host execution privileges are separate: preserve the former while requesting the latter.
196
+
197
+ Do not infer that an older confirmation included the new disclosure, widen the approved recipients or material, or treat model selection alone as blanket data-transfer consent. When the conversation already authorizes the same recipients and scope, use that evidence without asking again. If approval review rejects a dispatch, retain and report its reason; supply omitted existing evidence only through the host's permitted review mechanism. If the stated gap is not covered by the user's actual authorization, ask one focused question naming that gap and wait for the answer. Never bypass the rejection or repeat an unchanged request.
198
+
185
199
  Apply this guidance to both `okstra worker-dispatch` and the cmux override's `okstra team dispatch`, including initial workers, reverify, critic, report-writer, and retries. Read-only previews do not start workers. A session already authorized to execute outside the sandbox does not need another request. When the host prohibits escalation, preserve the pending invocation and report the execution constraint instead of trying an alternate launcher or weakening host controls.
186
200
 
187
201
  The worker's `--sandbox danger-full-access` flag only selects the child Codex policy; it does not remove restrictions inherited from the parent process. If startup reports `Operation not permitted`, retain the exact error and distinguish initialization failure from a worker verdict. Retry only through the permitted host mechanism after the execution conditions change, within the existing retry limit; do not repeat the same restricted invocation. This is host-call guidance, not a permission grant or a runtime-enforced check.
@@ -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,
@@ -100,9 +101,16 @@ def run_corrections_check(
100
101
 
101
102
  def semantic_validator(data: dict[str, Any]) -> list[str]:
102
103
  block = data.get(block_key)
103
- return validate_implementation_option_selection(
104
- block if isinstance(block, Mapping) else {}, original_ids, analysers,
105
- )
104
+ return [
105
+ f"{block_key}: {error}"
106
+ for error in validate_implementation_option_selection(
107
+ block if isinstance(block, Mapping) else {}, original_ids, analysers,
108
+ )
109
+ ]
110
+ elif task_type == "implementation-planning":
111
+
112
+ def semantic_validator(data: dict[str, Any]) -> list[str]:
113
+ return validate_plan_draft(data, project_root, manifest)
106
114
 
107
115
  return check_corrections(
108
116
  ledger=ledger,
@@ -34,7 +34,11 @@ from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignmen
34
34
  from ...path_hints import hydrate_active_run_context
35
35
  from ...worker_prompt_headers import worker_prompt_headers
36
36
  from ...worker_prompt_contract import complete_reverify_instruction, validate_reverify_prompt
37
- from ...worker_prompt_policy import is_verification_dispatch_kind
37
+ from ...worker_prompt_policy import (
38
+ critic_assignment_ref,
39
+ is_plan_critic_verification,
40
+ is_verification_dispatch_kind,
41
+ )
38
42
  from ...paths import okstra_home
39
43
  from ...final_report_paths import final_report_data_path
40
44
  from ...final_report_schema import load_schema_version
@@ -251,10 +255,28 @@ def _materialize_run(
251
255
  if args.assignment_ref not in assignments:
252
256
  # 유효값은 이 매니페스트 안에만 있고 리드는 그것을 추측할 수 없다.
253
257
  # 이름만 거절하면 읽는 쪽이 매니페스트를 직접 열어 키를 세게 된다.
258
+ critic_ref = critic_assignment_ref(str(manifest.get("taskType", "")))
259
+ recovery = ""
260
+ if (
261
+ args.assignment_ref == "reverify/critic-worker"
262
+ and critic_ref in assignments
263
+ and is_plan_critic_verification(
264
+ task_type=str(manifest.get("taskType", "")),
265
+ assignment_ref=critic_ref, dispatch_kind=args.dispatch_kind,
266
+ )
267
+ ):
268
+ recovery = (
269
+ f"; for the rostered plan critic, use --assignment-ref {critic_ref} "
270
+ "with --worker-id critic-worker and --audience reverification-worker. "
271
+ "Keep the plan-verify-r<N> dispatch kind and, on v2 runs, the selected "
272
+ "critic's --source-role-execution-ref. This is a reference correction; "
273
+ "the existing critic assignment does not need to be added or changed"
274
+ )
254
275
  raise AgentPromptCliError(
255
276
  f"assignment reference is missing from run manifest: "
256
277
  f"{args.assignment_ref}; this run declares "
257
278
  + (", ".join(sorted(assignments)) or "no invocation assignment")
279
+ + recovery
258
280
  )
259
281
  assignment_payload = assignments[args.assignment_ref]
260
282
  assignment = agent_model_assignment_from_payload(assignment_payload)
@@ -10,8 +10,6 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import re
13
- import subprocess
14
- import sys
15
13
  from dataclasses import dataclass
16
14
  from pathlib import Path
17
15
  from typing import Any, Dict, List, Optional
@@ -149,8 +147,6 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
149
147
  **fields,
150
148
  }
151
149
  _append_row(plan_run_root, record)
152
- if status == "done":
153
- _tag_stage_exit(plan_run_root, stage, fields.get("head_commit"))
154
150
  # 종결 status 는 점유 해제 이벤트이기도 하다 — 중복 append(no-op)에서도 풀어야
155
151
  # release 없이 done 만 기록된 과거 run 의 잔존 점유가 다음 호출에서 치유된다.
156
152
  if status == "done":
@@ -159,57 +155,6 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
159
155
  _release_stage_occupancy_keeping_branch(impl_task_key, stage)
160
156
 
161
157
 
162
- STAGE_EXIT_TAG = "stage-{stage}-exit"
163
-
164
-
165
- def _repo_root(start: Path) -> Optional[Path]:
166
- for candidate in [start, *start.parents]:
167
- if (candidate / ".git").exists():
168
- return candidate
169
- return None
170
-
171
-
172
- def _tag_stage_exit(plan_run_root: Path, stage: int, head_commit: Any) -> None:
173
- """Move `stage-<N>-exit` to the commit this ledger row records.
174
-
175
- The tag used to be a plan step, so it was written while the stage was still
176
- running — before the carry evidence the ledger reads. A stage then had three
177
- exit points that could disagree: the tag, the recorded `head_commit`, and
178
- the branch tip. okstra writes the tag at the moment it settles the stage, so
179
- the tag and the ledger cannot drift apart.
180
-
181
- Best effort by design: the ledger is the record and a tag is a convenience
182
- for the reader. A tree that is not a git repository, or a commit git cannot
183
- resolve, leaves the row written and says so on stderr.
184
- """
185
- if not isinstance(head_commit, str) or not head_commit.strip():
186
- return
187
- root = _repo_root(Path(plan_run_root).resolve())
188
- if root is None:
189
- return
190
- commit = head_commit.strip()
191
- tag = STAGE_EXIT_TAG.format(stage=stage)
192
- try:
193
- exists = subprocess.run(
194
- ["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
195
- capture_output=True,
196
- )
197
- if exists.returncode != 0:
198
- print(
199
- f"okstra: stage {stage} done recorded, but {commit[:12]} is not "
200
- f"a commit in {root} — `{tag}` not moved",
201
- file=sys.stderr,
202
- )
203
- return
204
- subprocess.run(
205
- ["git", "-C", str(root), "tag", "-f", tag, commit],
206
- capture_output=True,
207
- check=True,
208
- )
209
- except (OSError, subprocess.CalledProcessError) as exc:
210
- print(f"okstra: could not move `{tag}` to {commit[:12]}: {exc}", file=sys.stderr)
211
-
212
-
213
158
  def _equivalent_row_exists(plan_run_root: Path, impl_task_key: str, stage: int,
214
159
  status: str, force_reappend: bool,
215
160
  head_commit: Any) -> bool:
@@ -131,8 +131,14 @@ def validate_write_policy_payload(payload: Mapping[str, Any]) -> None:
131
131
  _relative_paths(artifact.get("allowedPaths", ()))
132
132
  if source.get("mode") not in {"source-readonly", "project-mutation"}:
133
133
  raise WritePolicyError("sourcePolicy mode is invalid")
134
- if set(source) != {"mode", "allowedRoot", "plannedPaths", "protectedPaths"}:
134
+ if set(source) - {"plannedPathsDeclared"} != {
135
+ "mode", "allowedRoot", "plannedPaths", "protectedPaths"
136
+ }:
135
137
  raise WritePolicyError("sourcePolicy is incomplete")
138
+ if "plannedPathsDeclared" in source and not isinstance(
139
+ source["plannedPathsDeclared"], bool
140
+ ):
141
+ raise WritePolicyError("sourcePolicy.plannedPathsDeclared must be a boolean")
136
142
  _absolute_text(source.get("allowedRoot"), "sourcePolicy.allowedRoot")
137
143
  _relative_paths(source.get("plannedPaths", ()))
138
144
  _relative_paths(source.get("protectedPaths", ()))
@@ -793,28 +793,17 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
793
793
 
794
794
 
795
795
  def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
796
- """ 정책의 경로 장부를 근거로 변경을 거절할 있는가.
797
-
798
- 승인된 계획서에 `plannedPaths` 컬럼이 있으면 실행기는 목록에 묶이고,
799
- 목록은 반드시 비어 있지 않다(`write_policy._planned_paths_from_report`
800
- 선언된 경우에만 항목을 싣는다). 컬럼이 없는 옛 계획서에서는 실을 값이
801
- 없어 장부가 빈 채로 온다 — 종전에는 산문에서 유도한 문장 조각(`28 rows)`,
802
- `captured in Stage 1)`)을 실었고, 그래서 계획이 지시한 파일 전부가 미허가
803
- 변경으로 읽혔다.
804
-
805
- `project-mutation` 정책에서만 빈 장부가 "물을 수 없음" 을 뜻한다.
806
- `source-readonly` 워커는 장부가 원래 비어 있고 그것이 "아무것도 바꾸지
807
- 말라" 는 뜻이므로, 그쪽 집행은 건드리지 않는다.
808
-
809
- 이 판정은 감사의 두 절반이 같은 함수를 읽는다. 종전에는 git 쪽만
810
- `plannedPathsDeclared` 를 봤는데 그 키는 `build_write_policy` 가 만드는
811
- sourcePolicy 에 아예 실리지 않아(4개 키 고정, `validate_write_policy_payload`
812
- 가 그 집합을 강제) 어느 쪽에서도 참이 된 적이 없다.
796
+ """ 계획의 미선언 목록만 면제하고, 산출물 전용 계획의 빈 소스 목록은 집행한다.
797
+
798
+ 선언 여부가 저장되기 정책은 기존의 목록 해석을 유지한다.
799
+ 정책은 경로 분류 후에도 선언 여부를 보존하므로 산출물만 남은 계획이
800
+ 임의의 소스 변경이나 커밋을 허용하지 않는다.
813
801
  """
814
802
  source = policy.source_policy
815
803
  return (
816
804
  source.get("mode") == "project-mutation"
817
805
  and not source.get("plannedPaths")
806
+ and not source.get("plannedPathsDeclared", False)
818
807
  )
819
808
 
820
809
 
@@ -211,26 +211,17 @@ class _Validator:
211
211
  def _misplaced_property_hint(
212
212
  self, name: str, path: tuple[str | int, ...],
213
213
  ) -> str:
214
- """ 키가 스키마의 다른 자리에서 정의된 필드면 "옮겨라" 를 덧붙인다.
215
-
216
- `additional property 'X' is not allowed` 만 보면 "스키마에 그런 칸이
217
- 없다" 로 읽힌다. 실제로는 X 가 다른 객체의 필드이고 작성자가 자리를
218
- 틀린 경우가 있고, 그때 지우면 다음 라운드에서 `required property 'X'
219
- is missing` 이 나온다 — 라운드 하나를 왕복에 쓴다(2026-09-09 실측,
220
- dev-10642 implementation-planning: 최상위 필수 `summary` 를
221
- `rationale` 안에 써서 지웠다가 되살렸다).
222
- """
214
+ """동명 필수 필드의 위치만 알린다. 이름 일치는 값의 의미를 증명하지 않는다."""
223
215
  found = self._required_property_owners(name) - {_format_path(path)}
224
216
  if not found:
225
217
  return ""
226
- # 최상위를 앞에 둔다. 잘못 놓인 키는 대개 최상위 필드가 한 단계 안으로
227
- # 들어간 것이고, 나머지 소유자는 같은 이름을 쓰는 다른 행 타입이다.
228
218
  owners = (
229
219
  ["<root>"] if "<root>" in found else []
230
220
  ) + sorted(found - {"<root>"})
231
221
  return (
232
- f" — '{name}' is a REQUIRED property of {', '.join(owners[:3])}, not "
233
- f"of {_format_path(path)}; move it there rather than removing it"
222
+ f" — '{name}' is also required at {', '.join(owners[:3])}; "
223
+ "a shared name does not establish where this value belongs. "
224
+ "Check the target field's schema and meaning before moving any value"
234
225
  )
235
226
 
236
227
  def _required_property_owners(self, name: str) -> set[str]:
@@ -495,14 +486,14 @@ def verdict_token_rule(schema: Mapping[str, Any], task_type: str) -> tuple[str,
495
486
 
496
487
 
497
488
  def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ...]:
498
- """ task type 의 데이터 블록(`properties[block_key]`) 요구하는 모양을 한
489
+ """최상위 필드(`properties[block_key]`) 요구하는 모양을 한
499
490
  객체당 한 줄의 저작 계약 문장으로 편다.
500
491
 
501
492
  리드는 report-writer 지시문을 쓸 때 블록 안쪽의 필수 필드·식별자 패턴·
502
493
  정수 필드·고정 길이 배열을 알 길이 프롬프트 경로에 없어 스키마 JSON 을
503
494
  손으로 파싱했고, 그래도 놓친 만큼 같은 리포트를 다시 썼다(2026-09-03 실측,
504
495
  dev-10626 implementation-option-selection: 네 회차). 여기서 뽑아 합성 묶음의
505
- 저작 계약에 싣는다. 블록이 없거나 객체가 아니면 빈 튜플이다.
496
+ 저작 계약에 싣는다. 필드가 없으면 빈 튜플이다.
506
497
 
507
498
  경로는 스키마 키다(`rankedOptions[]` 는 `- Item N` 목록의 각 항목). 같은
508
499
  정의(`$ref`)가 두 경로에 나오면 두 번째는 첫 경로를 가리킨다 — 랭킹 옵션과
@@ -514,7 +505,16 @@ def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ..
514
505
  return ()
515
506
  lines: list[str] = []
516
507
  described: dict[str, str] = {}
517
- _describe_object(root, block, block_key, lines, described)
508
+ resolved, _ = _resolved(root, block)
509
+ if resolved.get("type") == "object" or "properties" in resolved:
510
+ _describe_object(root, block, block_key, lines, described)
511
+ else:
512
+ nested: list[tuple[str, Any]] = []
513
+ constraint = _describe_property(root, block, block_key, nested)
514
+ if constraint:
515
+ lines.append(f"`{block_key}`: {constraint}")
516
+ for child_path, child in nested:
517
+ _describe_object(root, child, child_path, lines, described)
518
518
  return tuple(lines)
519
519
 
520
520
 
@@ -292,7 +292,8 @@ def _parser() -> argparse.ArgumentParser:
292
292
  complete.add_argument(
293
293
  "--items", type=Path,
294
294
  help="restore this round's dispatched queue from its prepared items artifact; "
295
- "earlier verdicts outside that queue remain unchanged",
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",
296
297
  )
297
298
  complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
298
299
  complete.add_argument(
@@ -419,7 +420,7 @@ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
419
420
  failures = validate_plan_draft(source, authority.project_root, authority.payload)
420
421
  if failures:
421
422
  raise PlanItemContractError(
422
- "owner=report-writer fieldPath=implementationPlanning: "
423
+ "owner=report-writer: "
423
424
  + "; ".join(failures)
424
425
  + "; correct the narrative in this run, then retry plan-items prepare"
425
426
  )
@@ -1726,6 +1727,17 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
1726
1727
  if not isinstance(audit, list) or not isinstance(history, list):
1727
1728
  raise PlanItemContractError("state planItems and roundHistory must be arrays")
1728
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
1729
1741
  _narrow_dispatch_queue(
1730
1742
  args, data["planBodyVerification"], {item.get("id") for item in current},
1731
1743
  )
@@ -1742,8 +1754,15 @@ def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, A
1742
1754
  if isinstance(item, Mapping) and item.get("id") in allowed
1743
1755
  ]
1744
1756
  snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in scoped}
1745
- if not snapshots or any(not row["votes"] for row in snapshots.values()):
1746
- 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
+ )
1747
1766
  summary = _plan_gate_summary(verification)
1748
1767
  classes = {row["id"]: row["stateClassification"] for row in summary["items"]}
1749
1768
  for item_id, snapshot in snapshots.items():
@@ -1916,6 +1935,12 @@ def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
1916
1935
  data, current, _audit, _history = _round_inputs(args)
1917
1936
  run_manifest = getattr(args, "run_manifest", None)
1918
1937
  payloads = _prepared_payloads(run_manifest)
1938
+ # 실제 목록에서 제외할 항목을 다음 비평 검증으로 요구하면 빈 큐에서 멈춘다.
1939
+ allowed = set(dispatch_item_ids(
1940
+ [payloads.get(item["id"], item) for item in current],
1941
+ data["planBodyVerification"].get("stageLedger"),
1942
+ ))
1943
+ current = [item for item in current if item["id"] in allowed]
1919
1944
  return next_dispatch(
1920
1945
  current, payloads, critic_rostered=_rostered_critic(run_manifest),
1921
1946
  decision_items=(
@@ -18,7 +18,7 @@ from .clarification_items import (
18
18
  from .final_report_schema import load_schema_version, validate
19
19
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
20
20
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
21
- from .report_narrative import parse_narrative
21
+ from .report_narrative import parse_narrative, task_narrative_errors
22
22
  from .report_synthesis_packet import (
23
23
  report_synthesis_packet_paths,
24
24
  verify_report_synthesis_packet_sources,
@@ -112,23 +112,12 @@ def _input_preflight_issues(
112
112
  try:
113
113
  if row.key == "narrative":
114
114
  narrative = parse_narrative(row.path.read_text(encoding="utf-8"), schema)
115
- # 서사에는 header 가 없으므로 실행 명세의 작업 유형으로 판정 규칙을 적용한다.
116
- for branch in schema.get("allOf", []):
117
- verdict = branch.get("then", {}).get("properties", {}).get("finalVerdict")
118
- if verdict is None:
119
- continue
120
- errors = validate(
121
- {**narrative, "header": {"taskType": task_type}},
122
- {
123
- "$defs": schema.get("$defs", {}),
124
- "if": branch["if"],
125
- "then": {"properties": {"finalVerdict": verdict}},
126
- },
127
- )
128
- issues.extend(
129
- AssemblyIssue(row.owner, str(row.path), "finalVerdict", error)
130
- for error in errors
115
+ issues.extend(
116
+ AssemblyIssue(
117
+ row.owner, str(row.path), error.split(":", 1)[0], error,
131
118
  )
119
+ for error in task_narrative_errors(narrative, schema, task_type)
120
+ )
132
121
  elif row.key == "agent-activity":
133
122
  row.path.read_text(encoding="utf-8")
134
123
  else:
@@ -601,7 +590,15 @@ def validate_plan_draft(
601
590
  draft = copy.deepcopy(dict(data))
602
591
  _apply_selected_direction_snapshot(draft, project_root, manifest)
603
592
  _attach_metadata(draft, manifest)
604
- return selected_direction_plan_errors(draft, project_root, manifest)
593
+ return [
594
+ *task_narrative_errors(
595
+ draft, load_schema_version("3.0"), str(manifest.get("taskType", "")),
596
+ ),
597
+ *[
598
+ f"implementationPlanning: {error}"
599
+ for error in selected_direction_plan_errors(draft, project_root, manifest)
600
+ ],
601
+ ]
605
602
 
606
603
 
607
604
  def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
@@ -20,7 +20,7 @@ from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
  from typing import Any, Callable, Mapping, Sequence
22
22
 
23
- from .final_report_schema import validate
23
+ from .final_report_schema import task_block_rules, validate
24
24
  from .json_boundary import JsonBoundaryError, load_owned_object
25
25
  from .report_markdown import humanise
26
26
  from .report_narrative import (
@@ -29,6 +29,7 @@ from .report_narrative import (
29
29
  render_narrative,
30
30
  validate_writer_owned,
31
31
  writer_owned_path_defect,
32
+ writer_owned_schema,
32
33
  )
33
34
 
34
35
 
@@ -204,7 +205,7 @@ def check_corrections(
204
205
  3. 경로 해소와 `current` 일치. `rewrite` 는 배열의 `[len]` 추가 위치를 허용한다.
205
206
  4. 작성자 소유 경로인가.
206
207
  5. replace·remove 를 적용한 사본을 작성자 소유 값 스키마와 task 의미 검증기로
207
- 대조한다. rewrite 경로 아래의 스키마 결함은 뺀다(작성자가 다시 쓸 자리다).
208
+ 대조한다. rewrite 경로 아래로 특정된 결함은 뺀다(작성자가 다시 쓸 자리다).
208
209
  6. rewrite 마다 그 경로의 스키마 제약 문장을 붙인다.
209
210
  """
210
211
  ledger_dict = dict(ledger)
@@ -279,7 +280,7 @@ def check_corrections(
279
280
  continue
280
281
  if kind == "rewrite":
281
282
  rewrite_paths.append(path)
282
- constraints[correction_id] = _constraints_for(path, block_rules)
283
+ constraints[correction_id] = _constraints_for(path, block_rules, schema)
283
284
 
284
285
  replacements = [c for c in corrections if c.get("kind") == "replace" and str(c.get("id")) in parsed]
285
286
  removals = [c for c in corrections if c.get("kind") == "remove" and str(c.get("id")) in parsed]
@@ -305,15 +306,16 @@ def check_corrections(
305
306
  # 회차에 전건을 보게 하려는 것이 이 모듈의 이유다. 결함인 교정은 적용하지
306
307
  # 않았으므로 그 자리는 기준 서사의 값 그대로다.
307
308
  if scratch is not None:
308
- for error in validate_writer_owned(scratch, schema):
309
- location = error.split(": ", 1)[0]
309
+ errors = validate_writer_owned(scratch, schema)
310
+ if semantic_validator is not None:
311
+ errors.extend(semantic_validator(scratch))
312
+ for error in errors:
313
+ location, separator, _ = error.partition(": ")
314
+ location = location if separator else "-"
310
315
  if any(location == rp or location.startswith(f"{rp}.") or location.startswith(f"{rp}[")
311
316
  for rp in rewrite_paths):
312
317
  continue
313
318
  defects.append(_defect("applied", location, error))
314
- if semantic_validator is not None:
315
- for error in semantic_validator(scratch):
316
- defects.append(_defect("applied", "-", str(error)))
317
319
 
318
320
  mechanical = not defects and not rewrite_paths
319
321
  return CorrectionsCheck(
@@ -321,16 +323,22 @@ def check_corrections(
321
323
  )
322
324
 
323
325
 
324
- def _constraints_for(path: str, block_rules: Sequence[str]) -> tuple[str, ...]:
325
- """이 경로(또는 가장 가까운 상위 객체)의 `task_block_rules` 줄."""
326
+ def _constraints_for(
327
+ path: str, block_rules: Sequence[str], schema: Mapping[str, Any],
328
+ ) -> tuple[str, ...]:
329
+ """가장 가까운 필드의 제약. 공통 필드도 검증에 쓰는 스키마에서 추출한다."""
326
330
  normalised = normalise_field_path(path)
331
+ root_key = str(parse_field_path(path)[0])
332
+ rules = block_rules
333
+ if not any(rule.startswith(f"`{root_key}`: ") for rule in rules):
334
+ rules = task_block_rules(writer_owned_schema(schema), root_key)
327
335
  candidates = [normalised]
328
336
  while "." in candidates[-1]:
329
337
  candidates.append(candidates[-1].rsplit(".", 1)[0])
330
338
  for candidate in candidates:
331
339
  lines = tuple(
332
- rule for rule in block_rules
333
- if rule.startswith(f"`{candidate}`: ")
340
+ rule for rule in rules
341
+ if rule.startswith(f"`{candidate}`: ") or rule.startswith(f"`{candidate}[]")
334
342
  )
335
343
  if lines:
336
344
  return lines
@@ -423,9 +431,10 @@ def render_corrections_section(
423
431
  lines.extend(["", "### Context", "", context.strip()])
424
432
  lines.extend([
425
433
  "",
426
- "Change nothing else: not a sentence, not a score, not an evidence path, not a "
427
- "verdict, not the `Human Summary`, and not the blockquote indentation where each "
428
- "value line sits two spaces deeper than its own `- **Label**` line.",
434
+ "Outside the correction paths, preserve every sentence, score, evidence path, "
435
+ "and verdict. Follow the schema constraints when a correction describes an "
436
+ "incompatible shape. Preserve the blockquote indentation: each value line "
437
+ "sits two spaces deeper than its own `- **Label**` line.",
429
438
  ])
430
439
  return lines
431
440
 
@@ -1088,7 +1088,7 @@ def _parser() -> argparse.ArgumentParser:
1088
1088
  "--only",
1089
1089
  action="append",
1090
1090
  default=[],
1091
- choices=list(STEP_ORDER),
1091
+ choices=list(dict.fromkeys((*STEP_ORDER, *V3_STEP_ORDER))),
1092
1092
  help=(
1093
1093
  "run only these steps (repeatable, contractual order preserved). "
1094
1094
  "Use `--only validate-run` to retry the step that usually fails "
@@ -118,8 +118,8 @@ _MACHINE_OWNED_NESTED = frozenset(
118
118
  )
119
119
 
120
120
 
121
- def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
122
- """`required` 목록에서 기계 소유 필드 이름만 뺀 스키마 사본.
121
+ def _without_machine_owned_fields(node: Any, *, negated: bool = False) -> Any:
122
+ """기계 소유 필드의 정의와 필수 조건을 작성자용 스키마 사본.
123
123
 
124
124
  `not` 아래는 건드리지 않는다. 거기서 `required: [designPreparation]` 은
125
125
  "이 필드가 있으면 안 된다" 는 뜻이고 서사에는 원래 없으니 이미 만족한다.
@@ -128,7 +128,7 @@ def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
128
128
  뒤집는다. 실제로 `direction-invalidated` 계획 분기가 그렇게 죽었다.
129
129
  """
130
130
  if isinstance(node, list):
131
- return [_without_machine_owned_required(item, negated=negated) for item in node]
131
+ return [_without_machine_owned_fields(item, negated=negated) for item in node]
132
132
  if not isinstance(node, dict):
133
133
  return node
134
134
  result: dict[str, Any] = {}
@@ -137,8 +137,13 @@ def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
137
137
  result[key] = [
138
138
  name for name in value if name not in _MACHINE_OWNED_NESTED
139
139
  ]
140
+ elif key == "properties" and isinstance(value, dict) and not negated:
141
+ result[key] = {
142
+ name: _without_machine_owned_fields(field)
143
+ for name, field in value.items() if name not in _MACHINE_OWNED_NESTED
144
+ }
140
145
  else:
141
- result[key] = _without_machine_owned_required(
146
+ result[key] = _without_machine_owned_fields(
142
147
  value, negated=negated or key == "not"
143
148
  )
144
149
  return result
@@ -169,8 +174,8 @@ def writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
169
174
  return {
170
175
  "type": "object",
171
176
  "additionalProperties": False,
172
- "properties": _without_machine_owned_required(properties),
173
- "$defs": _without_machine_owned_required(schema.get("$defs", {})),
177
+ "properties": _without_machine_owned_fields(properties),
178
+ "$defs": _without_machine_owned_fields(schema.get("$defs", {})),
174
179
  }
175
180
 
176
181
 
@@ -179,6 +184,28 @@ def validate_writer_owned(data: Mapping[str, Any], schema: Mapping[str, Any]) ->
179
184
  return validate(dict(data), writer_owned_schema(schema))
180
185
 
181
186
 
187
+ def task_narrative_errors(
188
+ data: Mapping[str, Any], schema: Mapping[str, Any], task_type: str,
189
+ ) -> list[str]:
190
+ """실행 유형에 따른 판정·후속 작업 규칙을 정본 스키마에서 검사한다."""
191
+ context = {**data, "header": {"taskType": task_type}}
192
+ errors: list[str] = []
193
+ for branch in schema.get("allOf", []):
194
+ fields = {
195
+ key: value
196
+ for key, value in branch.get("then", {}).get("properties", {}).items()
197
+ if key in {"finalVerdict", "followUpTasks"}
198
+ }
199
+ if not fields:
200
+ continue
201
+ errors.extend(validate(context, {
202
+ "$defs": schema.get("$defs", {}),
203
+ "if": branch["if"],
204
+ "then": {"properties": fields},
205
+ }))
206
+ return list(dict.fromkeys(errors))
207
+
208
+
182
209
  def writer_owned_path_defect(path: str) -> str | None:
183
210
  """이 필드 경로에 작성자가 쓸 수 없는 이유, 쓸 수 있으면 None.
184
211
 
@@ -811,7 +811,7 @@ def _schema_authoring_rules(
811
811
  if isinstance(key, str) and key in allowed
812
812
  )
813
813
  block_key = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
814
- block_rules = task_block_rules(schema, block_key) if block_key else ()
814
+ block_rules = task_block_rules(writer_owned_schema(schema), block_key) if block_key else ()
815
815
  return (
816
816
  required_top_level,
817
817
  verdict_token_rule(schema, task_type),
@@ -340,4 +340,8 @@ def confirmation_block(state: WizardState) -> str:
340
340
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
341
341
  if state.fix_cycle:
342
342
  lines.append(f" fix-cycle : {state.fix_cycle}")
343
+ lines.append(_msg(
344
+ state.workspace_root, "confirmation", "provider_data_scope",
345
+ project_root=state.project_root,
346
+ ))
343
347
  return "\n".join(lines)
@@ -35,7 +35,6 @@ from .ids import (
35
35
  from .state import Prompt, WizardError, WizardState, _is_role_selection_step
36
36
  from .prompts import _domain_prompt
37
37
  from .picker_navigation import (
38
- accept_picker_answer,
39
38
  is_split_picker,
40
39
  merge_split_picker_answer,
41
40
  present_picker,
@@ -150,11 +149,8 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
150
149
  때마다 턴이 하나씩 들었다(실측 2026-09-09, 후보 13개인 critic 화면).
151
150
 
152
151
  묶음에 못 실리면(옵션 16개 초과, 라벨 중복, 질문 묶음이 없는 세션)
153
- 단일 선택은 종전대로 쪽으로 나누고(`present_picker`), 체크박스는
154
- `CapabilityInteractionPort.plan` `numbered-multi` 로 내려 전체 목록을
155
- 한 번에 보인다. 체크박스는 쪽으로 나누지 않는다 — 쪽 사본이 추천 표시를
156
- 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히
157
- 하나)에 걸려 화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면).
152
+ `CapabilityInteractionPort.plan` 이 단일 선택은 `numbered-single`,
153
+ 체크박스는 `numbered-multi` 로 내려 전체 목록을 한 번에 보인다.
158
154
  """
159
155
  if "native_single_select" not in state.available_functions:
160
156
  return prompt
@@ -172,7 +168,7 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
172
168
  return split
173
169
  if prompt.multi:
174
170
  return prompt
175
- return present_picker(state, prompt, limit=port.native_option_limit)
171
+ return present_picker(prompt)
176
172
 
177
173
 
178
174
  def _next_prompt_screen(state: WizardState) -> Prompt:
@@ -404,13 +400,6 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
404
400
  return {"echo": "", "next": prompt_payload(state, prompt)}
405
401
  plan = _interaction_plan(state, prompt)
406
402
  value = _normalize_interaction_answer(state, prompt, plan, value)
407
- if plan.kind == "native-single":
408
- original = _next_prompt_screen(state)
409
- if original.kind == "pick_group":
410
- original = next(q for q in original.questions if q.step == prompt.step)
411
- value = accept_picker_answer(state, original, value)
412
- if value is None:
413
- return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
414
403
  if is_split_picker(prompt):
415
404
  # 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
416
405
  # 경로로 보낸다. 원본의 선택지로 검증한다.
@@ -419,8 +408,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
419
408
  elif prompt.kind == "pick_group":
420
409
  return _submit_group(state, prompt, value)
421
410
  if _is_role_selection_step(prompt.step):
422
- # 고정 단일 역할의 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수
423
- # 있다(`present_picker`). 답은 잘리지 않은 원본의 선택지로 검증한다.
411
+ # 화면의 중복 라벨을 구분했어도 답은 원본 선택지의 값으로 검증한다.
424
412
  original = next_role_prompt(state)
425
413
  if original is not None and original.step == prompt.step:
426
414
  prompt = original
@@ -6,20 +6,16 @@
6
6
  원래 step 의 제출 경로로 간다. 단일 선택(예: critic 처럼 최대 1개인 역할)도
7
7
  같은 방식으로 자르되 통틀어 하나만 고른 답이어야 한다.
8
8
 
9
- 그 크기도 넘거나 질문 묶음이 없는 세션이면, 단일 선택은 쪽으로 나누고
10
- (`present_picker`) 체크박스는 `numbered-multi` 전체 목록을 번에 보인다
11
- (`engine._native_picker_screen`). 체크박스를 쪽으로 나누던 경로는 쪽 사본이
12
- 추천 표시를 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식에 걸리므로
13
- 2026-09-09 에 뺐다.
9
+ 그 크기도 넘거나 질문 묶음이 없는 세션이면 전체 번호 목록을 한 번에 보인다.
10
+ `present_picker` 중복 라벨만 구분하고 선택지 순서와 값을 보존한다.
14
11
  """
15
12
  import json
16
13
  import math
17
14
  from dataclasses import replace
18
15
 
19
16
  from .ids import PICK_TYPE_CUSTOM
20
- from .state import Option, Prompt, WizardError, WizardState
17
+ from .state import Prompt, WizardError
21
18
 
22
- _PAGE_PREFIX = "__okstra_picker_page__:"
23
19
  _SPLIT_SEPARATOR = "#"
24
20
 
25
21
 
@@ -113,7 +109,7 @@ def merge_split_picker_answer(prompt: Prompt, value: str) -> str:
113
109
  return ",".join(chosen)
114
110
 
115
111
 
116
- def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
112
+ def present_picker(prompt: Prompt) -> Prompt:
117
113
  if prompt.kind != "pick" or not prompt.options:
118
114
  return prompt
119
115
  labels = [option.label for option in prompt.options]
@@ -121,33 +117,7 @@ def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
121
117
  option,
122
118
  label=option.label + (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
123
119
  ) for option in prompt.options]
124
- paged = len(options) > limit
125
- size = max(1, limit - 1) if paged else len(options)
126
- offset = state.picker_offsets.get(prompt.step, 0)
127
- offset = offset if 0 <= offset < len(options) else 0
128
- shown = options[offset:offset + size]
129
- label = prompt.label
130
- if paged:
131
- next_offset = offset + size if offset + size < len(options) else 0
132
- shown.append(Option(
133
- f"{_PAGE_PREFIX}{next_offset}",
134
- "다음 선택지" if next_offset else "처음 선택지로",
135
- ))
136
- label += f" ({offset + 1}–{min(offset + size, len(options))}/{len(options)})"
137
- if len(shown) == 1:
138
- shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
139
- shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
140
- o for o in shown if o.value == PICK_TYPE_CUSTOM
120
+ options = [o for o in options if o.value != PICK_TYPE_CUSTOM] + [
121
+ o for o in options if o.value == PICK_TYPE_CUSTOM
141
122
  ]
142
- return replace(prompt, label=label, options=shown)
143
-
144
-
145
- def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
146
- if value.startswith(_PAGE_PREFIX):
147
- raw_offset = value.removeprefix(_PAGE_PREFIX)
148
- if not raw_offset.isdecimal() or not 0 <= int(raw_offset) < len(prompt.options):
149
- raise WizardError("invalid picker page; select a returned navigation option")
150
- state.picker_offsets[prompt.step] = int(raw_offset)
151
- return None
152
- state.picker_offsets.pop(prompt.step, None)
153
- return value
123
+ return replace(prompt, options=options)
@@ -19,9 +19,8 @@ and stage-integration steps use.
19
19
  Use it as the assertion in a plan step:
20
20
  okstra worktree-status --check-clean
21
21
 
22
- Do not chain a `git tag stage-<N>-exit` onto it. okstra writes that tag when it
23
- settles the stage, at the commit the carry evidence records; a step that tags
24
- while the stage is still running puts it on an earlier commit.
22
+ Do not chain a `git tag stage-<N>-exit` onto it. Stage completion records the
23
+ commit in the consumer ledger without creating or moving git tags.
25
24
 
26
25
  Output: JSON { ok, path, clean, entries, excluded }. `entries` holds the
27
26
  `git status --short` rows that made it dirty; `excluded` lists the paths left
@@ -51,6 +51,8 @@ def build_write_policy(
51
51
  _relative_paths(invocation.get("protectedPaths", (".okstra", ".git")))
52
52
  ),
53
53
  }
54
+ if "plannedPathsDeclared" in invocation:
55
+ source_policy["plannedPathsDeclared"] = invocation["plannedPathsDeclared"]
54
56
  git_policy = _git_policy(invocation, source_root, source_mode)
55
57
  external_roots = _validated_auxiliary_roots(invocation, assignment)
56
58
  auxiliary_policy = {
@@ -144,18 +146,21 @@ def build_invocation_write_contract(
144
146
  )
145
147
  )
146
148
  )
149
+ source_root = worktree.resolve() if worktree is not None else root
150
+ source_paths = _planned_source_paths(
151
+ planned_paths, root, source_root, relative_artifacts
152
+ )
147
153
  invocation: dict[str, Any] = {
148
154
  "role": role,
149
155
  "projectRoot": str(root),
150
156
  "artifactPaths": list(relative_artifacts),
151
- "plannedPaths": list(planned_paths),
157
+ "plannedPaths": list(source_paths),
152
158
  "plannedPathsDeclared": planned_paths_declared,
153
159
  "protectedPaths": [".okstra", ".git"],
154
160
  "generatedPaths": list(generated_paths),
155
161
  "scratchRoots": [str(path) for path in scratch_roots],
156
162
  "auxiliaryRoots": [str(path) for path in auxiliary_roots],
157
163
  }
158
- source_root = worktree.resolve() if worktree is not None else root
159
164
  if role == "implementer":
160
165
  invocation.update({
161
166
  "expectedBaseCommit": _git_output(source_root, "rev-parse", "HEAD"),
@@ -173,6 +178,47 @@ def build_invocation_write_contract(
173
178
  return policy, derive_write_enforcement(policy, maximum_precision, role=role)
174
179
 
175
180
 
181
+ def _planned_source_paths(
182
+ paths: Sequence[str],
183
+ project_root: Path,
184
+ source_root: Path,
185
+ artifact_paths: Sequence[str],
186
+ ) -> tuple[str, ...]:
187
+ """산출물 권한은 프로젝트에, 소스 변경 목록은 배정 작업 디렉터리에 결속한다."""
188
+ sources: set[str] = set()
189
+ for value in paths:
190
+ candidate = Path(value)
191
+ if candidate.is_absolute():
192
+ root = (
193
+ source_root if candidate.is_relative_to(source_root) else project_root
194
+ )
195
+ relative = _relative_to_root(candidate, root, "planned")
196
+ else:
197
+ relative = _relative_paths((value,))[0]
198
+ root = (
199
+ project_root
200
+ if PurePosixPath(relative).parts[0] == ".okstra"
201
+ else source_root
202
+ )
203
+ _relative_to_root(root / relative, root, "planned")
204
+ relative = _relative_paths((relative,))[0]
205
+ path = PurePosixPath(relative)
206
+ if path.parts[0] == ".okstra":
207
+ if root != project_root or not any(
208
+ path.is_relative_to(allowed) for allowed in artifact_paths
209
+ ):
210
+ raise WritePolicyError(
211
+ f"planned artifact path exceeds this worker's artifact permissions: {value}"
212
+ )
213
+ else:
214
+ if path.parts[0] == ".git":
215
+ raise WritePolicyError(f"planned source path is protected: {value}")
216
+ # 원래 체크아웃의 절대 소스 경로도 실제 쓰기는 배정 작업 디렉터리에 한정된다.
217
+ _relative_to_root(source_root / relative, source_root, "planned source")
218
+ sources.add(relative)
219
+ return tuple(sorted(sources))
220
+
221
+
176
222
  def task_root_from_run_manifest(
177
223
  project_root: Path, manifest: Mapping[str, Any]
178
224
  ) -> Path | None:
@@ -191,7 +237,7 @@ def task_root_from_run_manifest(
191
237
 
192
238
  def planned_paths_from_run_manifest(
193
239
  project_root: Path, manifest: Mapping[str, Any]
194
- ) -> tuple[str, ...]:
240
+ ) -> tuple[tuple[str, ...], bool]:
195
241
  active_value = manifest.get("activeRunContextPath")
196
242
  if not isinstance(active_value, str) or not active_value:
197
243
  raise WritePolicyError("implementer write policy has no active run context")
@@ -421,7 +467,8 @@ def _planned_paths_from_report(
421
467
  if isinstance(path, str) and path.strip()
422
468
  }
423
469
  if paths:
424
- return _relative_paths(tuple(sorted(paths))), True
470
+ # 경로 분류는 산출물 권한과 배정 작업 디렉터리를 함께 아는 정책 생성기가 맡는다.
471
+ return tuple(sorted(paths)), True
425
472
  # A plan approved before `plannedPaths` existed carries its paths only in
426
473
  # the prose `files` cell. That prose does not survive being split into
427
474
  # paths — it mis-splits globs and parentheticals — so the ledger stays
@@ -148,6 +148,7 @@
148
148
  "mode": {"enum": ["source-readonly", "project-mutation"]},
149
149
  "allowedRoot": {"type": "string", "pattern": "^/"},
150
150
  "plannedPaths": {"type": "array", "items": {"type": "string", "pattern": "\\S"}, "uniqueItems": true},
151
+ "plannedPathsDeclared": {"type": "boolean"},
151
152
  "protectedPaths": {"type": "array", "items": {"type": "string", "pattern": "\\S"}, "uniqueItems": true}
152
153
  },
153
154
  "additionalProperties": false
@@ -8651,7 +8651,7 @@
8651
8651
  "minLength": 1
8652
8652
  },
8653
8653
  "plannedPaths": {
8654
- "description": "Repository-relative paths this step may write, one per entry. `files` is prose for a human reader and cannot serve as a path ledger it carries globs, exclusions and counts so the implementer write policy reads this list instead.",
8654
+ "description": "Paths this step may write, one per entry. Source paths are relative to the assigned worktree; absolute paths under that worktree or the project checkout identify the same repository-relative source. `.okstra` artifact paths are anchored at the original project root and may be absolute. The write-policy builder checks artifact paths against the worker's existing artifact permissions and keeps them out of the source ledger. Keep absolute artifact paths in executable commands. `files` is human-readable prose, not a path ledger.",
8655
8655
  "type": "array",
8656
8656
  "minItems": 1,
8657
8657
  "items": {
@@ -8796,7 +8796,7 @@
8796
8796
  "minLength": 1
8797
8797
  },
8798
8798
  "plannedPaths": {
8799
- "description": "Repository-relative paths this step may write, one per entry. `files` is prose for a human reader and cannot serve as a path ledger it carries globs, exclusions and counts so the implementer write policy reads this list instead.",
8799
+ "description": "Paths this step may write, one per entry. Source paths are relative to the assigned worktree; absolute paths under that worktree or the project checkout identify the same repository-relative source. `.okstra` artifact paths are anchored at the original project root and may be absolute. The write-policy builder checks artifact paths against the worker's existing artifact permissions and keeps them out of the source ledger. Keep absolute artifact paths in executable commands. `files` is human-readable prose, not a path ledger.",
8800
8800
  "type": "array",
8801
8801
  "minItems": 1,
8802
8802
  "items": {
@@ -53,7 +53,7 @@ The wizard tells you which relay operation to use via `next.interaction.kind`. S
53
53
  - `kind: "done"` → input collection finished; move to Step 5.
54
54
  - `kind: "aborted"` → the user picked abort; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
55
55
 
56
- When native single selection is available, the runtime divides long lists and unsupported multi-selections into selectable screens. Render the returned screen without rebuilding the full list. This applies to plan confirmation, stages, role counts, and provider/model lists. Submit navigation and completion option values normally; the runtime retains the current step until its answer is complete. A ban on textual choice lists does not authorize asking the user to type a model identifier. If a required selector is unavailable, preserve state and follow the relay's `recovery` object. Genuine text steps still collect text.
56
+ When native selection is available, the runtime keeps choices in that control or in same-screen question tabs when the complete selection fits. Otherwise, it returns a complete numbered list: show all labels and descriptions in one message and accept a number, label, or value (comma-separated for multiple choices). This applies to plan confirmation, stages, role counts, and provider/model lists. Do not paginate choices or add a next-page option. Follow the returned `interaction.kind`, including numbered interactions when the user generally prefers a selector. If the declared selector itself fails, follow the relay's `recovery` object. Genuine text steps still collect text.
57
57
 
58
58
  Submit the answer shape required by `interaction.answerProtocol`; do not add normalization beyond the registered relay's explicit mapping. Invalid, out-of-range, or ambiguous answers return `ok: false` and must re-render the same complete interaction.
59
59
 
@@ -228,6 +228,8 @@ When `next.step == "confirm"`, the prompt's `label` already carries the full sel
228
228
 
229
229
  `okstra wizard confirmation --state-file <path>` returns the same block as `{ok: true, text: ...}` for a text-only host or when the user asks to see the summary again; it is not a substitute for the label.
230
230
 
231
+ Keep the provider data-transfer disclosure in that label alongside the selected roles/models and task inputs. The user's actual `Proceed` response authorizes the displayed recipients and task-material scope; it does not grant host execution privileges. When dispatching, follow the host relay's guidance to carry that response and scope into the execution-permission request. A freshly generated `confirmationText` does not retroactively add disclosure to an older user response.
232
+
231
233
  ## Step 5: Render the task bundle
232
234
 
233
235
  When `next.kind == "done"`, fetch the public wizard outcome:
@@ -261,6 +263,8 @@ Before you ask the user for any approval, clarification, or decision after worke
261
263
 
262
264
  Build the `okstra render-bundle` invocation from `outcome.renderArgv`, passing every token verbatim and in order (including empty strings — they are intentional `use phase default` markers).
263
265
 
266
+ Before invoking it, follow the active host relay's execution-permission guidance. For Codex, read "Permission before bundle preparation" in the relay already selected at preflight: bundle preparation can write protected Git metadata while creating a branch and worktree, even with `--render-only`. Apply this guidance to the first invocation and each subsequent phase or stage preparation.
267
+
264
268
  Analysis sidetracks therefore forward wizard-owned tokens such as `--analysis-target "<value>"` and `--evidence-inputs "<value>"` when they are present. These are examples of the verbatim token rule, not a separate hard-coded argument list.
265
269
 
266
270
  Step 3's empty-answer and escaping rules apply verbatim: every flag in `renderArgv` whose following value is the empty string MUST still be passed explicitly (e.g. `--workers ""`, `--directive ""`) — `render-bundle` distinguishes "flag absent" from "flag present with empty value", and the wizard's intent is always the latter.
@@ -460,6 +464,10 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
460
464
  - Each wizard run owns its own state file (one per `okstra wizard new-state-file`); two parallel skill invocations do not collide.
461
465
  - The skill must NOT call `okstra.sh` (or any other bash entrypoint) that would re-implement the orchestration. The wizard + `render-bundle` is the single authority.
462
466
 
467
+ ## Boundary updates
468
+
469
+ Follow the rendered launch prompt's "Progress, remaining work, and recommendation" guidance at each phase or implementation-stage boundary, at task completion, and before a controlled session pause or handoff. Include results, remaining work, and the recommended action with its reason. During an authorized continuous run, report the boundary and continue the queue; when the lead returns, relay its closeout without repeating an already delivered update.
470
+
463
471
  ## Failure Modes
464
472
 
465
473
  | Symptom | Cause | Fix |
@@ -9420,6 +9420,9 @@ def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
9420
9420
  failures.extend(selected_direction_plan_errors(
9421
9421
  data, Path(str(manifest.get("projectRoot") or run_manifest_path.parent)), manifest
9422
9422
  ))
9423
+ _append_stage_data_failures(
9424
+ data, failures, _task_root_from_run_dir(report_path.parent.parent),
9425
+ )
9423
9426
  _validate_planning_conformance_declared(report_path, failures)
9424
9427
  print(
9425
9428
  json.dumps(