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