okstra 0.141.0 → 0.141.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.141.0",
3
+ "version": "0.141.1",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.141.0",
3
- "builtAt": "2026-07-30T08:24:35.754Z",
2
+ "package": "0.141.1",
3
+ "builtAt": "2026-07-30T08:59:20.587Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -351,7 +351,7 @@ Lead's responsibilities in this sub-step (in order):
351
351
  An `AGREE` response records the considered counterexample and exclusion reason in its note; unverified external material is `verification-error`, not `DISAGREE`.
352
352
  2. Dispatch a single plan-body reverify round to every analyser worker in the roster (`claude`, `codex`, and `antigravity` when opted in). `Report writer worker` is NOT a participant in this round.
353
353
  3. Aggregate verdicts and resolve the gate result to one of `passed` / `passed-with-dissent` / `blocked-by-disagreement` / `aborted-non-result`.
354
- 4. Write `runs/<task-type>/state/plan-body-verification.json` (schema in the plan-body-verification contract).
354
+ 4. Write `runs/<task-type>/state/plan-body-verification.json` (schema in the plan-body-verification contract), appending one `roundHistory[]` entry per round — including every self-fix re-verification round, since data.json keeps only the final verdicts.
355
355
  5. Populate `### 5.5.9 Plan Body Verification` in the final-report file (template at `templates/reports/final-report.template.md` §5.5.9 — Round count, Gate result, per-item verdict tables grouped under each plan item's `subject`, Dissent log).
356
356
  6. For every `majority-disagree` plan item, append a row to `## 1. Clarification Items` with `Blocks=approval` and the 1:1 ID match in the verdict table's `Classification` column (`majority-disagree → C-<N>`). Do NOT create a parallel `Open Questions` block — see `prompts/profiles/implementation-planning.md` self-review step 6 for the orphan-on-either-side contract.
357
357
  7. Publish the YAML frontmatter `approved:` field as `false`. There is no in-body `- [ ] Approved` marker line — approval lives only in the frontmatter (see [plan-body-verification](./plan-body-verification.md) §"Round protocol" step 9). The user may flip it to `true` only when the gate is `passed` or `passed-with-dissent`. **Enforced:** `validators/validate-run.py` `validate_phase_boundary` fails a report shipping `approved: true` under `blocked-by-disagreement` / `aborted-non-result`, and run-prep (`scripts/okstra_ctl/run.py` `_validate_approved_plan`) fail-closes the same case. Manually flipping a blocked gate to passing is a contract violation.
@@ -183,7 +183,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
183
183
  **Record the cause, not just the outcome.** The gate value names the outcome; `planBodyVerification.gateBlockedBy` (array) names every input that blocked it — `majority-disagree`, `coverage-gap`, `non-result`. Two independent inputs can block: a `majority-disagree` plan item, and a Requirement Coverage `gap` / `blocked C-NNN` row (`prompts/profiles/implementation-planning.md` §"Requirement Coverage"). A coverage-only block still renders as `blocked-by-disagreement` because that is the only blocking non-abort value, so **without `gateBlockedBy` the report asserts a worker disagreement that never happened** and the reader hunts for a dissent that does not exist. Leave the array empty for a passing gate. **Enforced:** `validators/validate-run.py` `_validate_gate_blocked_by` cross-checks the declared causes against the recorded verdicts and coverage rows, and fails a passing gate that has a blocking coverage row — the coverage rule was prose-only before.
184
184
 
185
185
  **A coverage row citing this run's own `C-NNN` is not an independent blocker.** When a coverage row's `blocked C-NNN` points at a clarification that step 8 below promoted from a `majority-disagree` item in *this same run*, that blocker is already counted once as the plan item. Counting it again as a coverage gap makes the run block on a clarification it just authored, and the row carries into the next run as a fresh blocker — the Requirement Coverage ↔ Clarification cycle. Such rows are excluded from `coverage-gap`. **Enforced:** `validators/validate-run.py` `_independent_coverage_blockers`.
186
- 6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below) and populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
186
+ 6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
187
187
  7. **Self-fix loop (up to `selfFixMaxRounds`, targeting planner-fixable defects).** After aggregation, while at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead runs self-fix rounds **before** promoting anything to the user:
188
188
  - **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect — one constant declared twice, one responsibility given two owners — and the coverage rows that cite them fail as a consequence, not independently. Lead MUST partition this round's targets into cause groups and instruct each group as **"remove this cause"**, naming the derivatives it accounts for. **Handing report-writer a bare item list is forbidden**: patched one at a time, each correction leaves the sibling sections still asserting the old value, so the next round re-finds the same family and the budget drains without converging. Record the partition in `planBodyVerification.selfFixGroups[]` (`round`, `causeSummary`, `itemIds`). One group per item is a legitimate outcome only when the items genuinely share no cause — recorded that way, it is a visible diagnosis rather than a skipped one. **Enforced:** `validators/validate-run.py` `_validate_self_fix_grouping` requires the partition, ties `selfFixRoundsApplied` to the highest recorded round, and fails any corrected item that belongs to no group.
189
189
  - lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
@@ -191,7 +191,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
191
191
  - **Drop plan items whose element the round deleted.** A self-fix rewrite may remove a plan element (a validation check, a rollback row). `P-*` ids are positional, so a deletion shifts every later row and silently re-points surviving verdicts at their neighbours — and a verdict recorded against a removed element keeps blocking a gate while being unfindable in the plan, so reading the plan never reveals the cause. After each round, re-extract plan items with `okstra plan-items extract` and re-verify any item whose `subject` no longer matches; never carry the old vote forward across a shift. **Enforced:** `validators/validate-run.py` `_validate_verdicts_match_current_subjects` (re-pointing) and `_validate_plan_item_extraction_completeness` (dangling ids).
192
192
  - lead re-runs plan-body verification (focused on the corrected items + adjacent items the rewrite touched, plus any `needs-reverify` items whose peer failed to vote last round). After re-verification, overwrite `planItems[].verdicts` with the new verdicts. **The round's verdicts MUST be transcribed into `planBodyVerification.planItems[].verdicts` in the final report's data.json before the gate is declared** — the gate is re-derived from that table, so declaring a gate over an empty one leaves it unauditable. **Enforced:** `_validate_round_recorded_verdicts`.
193
193
  - for an item whose `majority-disagree` was resolved by self-fix, record `self-fixed in round <N>: <what was fixed>` in `planItems[].selfFixNote`. A resolved item does not create a clarification.
194
- - **Round completion.** A round is complete only after the renderer has run on the corrected data.json and lead has reconciled instructed groups against applied corrections — every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion.
194
+ - **Round completion.** A round is complete only after the renderer has run on the corrected data.json, lead has appended the round to the state file per step 6, and lead has reconciled instructed groups against applied corrections — every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion. The state-file append is not optional bookkeeping: the next re-verification overwrites data.json's `planItems[].verdicts`, so a round that never reached `roundHistory[]` leaves no record anywhere of what it blocked on — which is the whole reason this file exists. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one `roundHistory[]` entry per round `1..roundCount`, each carrying its own `gateResult` and cited by at least one item's `rounds[]`, and requires the file's `selfFixRoundsApplied` to match the report's.
195
195
  - **Loop termination.** Lead — not the report-writer worker — records the round count in `planBodyVerification.selfFixRoundsApplied` at each round's end, and why the loop stopped in `planBodyVerification.selfFixStopReason`. The count must equal the highest `round` in `selfFixGroups[]`, so it is derivable from recorded work rather than self-reported:
196
196
  - `all-resolved` — no planner-fixable `majority-disagree` item remains. Exit.
197
197
  - `no-progress` — the round resolved **zero** planner-fixable items relative to the previous round. Exit even with budget left: the same rewrite would repeat. Newly *introduced* defects count against progress, so a rewrite that trades one defect for another stops the loop rather than churning.
@@ -215,24 +215,23 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
215
215
 
216
216
  **Which file is authoritative for what.** These two representations are *not* required to agree, and a lead that tries to make them match is doing unnecessary work:
217
217
 
218
- | | records | overwritten by a self-fix round? |
218
+ | | records | what a self-fix round does to it |
219
219
  |---|---|---|
220
- | `state/plan-body-verification-<task-type>-<seq>.json` | the round-by-round history, including rounds later superseded | noit is the only place earlier rounds survive |
221
- | `data.json` `implementationPlanning.planBodyVerification` | the **final** state after the self-fix loop | yes — each re-verification replaces `planItems[].verdicts` |
220
+ | `state/plan-body-verification-<task-type>-<seq>.json` | the round-by-round history, including rounds later superseded | **appends**a new `roundHistory[]` entry and new `planItems[].rounds[]` votes; earlier rounds are left untouched, and this is the only place they survive |
221
+ | `data.json` `implementationPlanning.planBodyVerification` | the **final** state after the self-fix loop | **overwrites** — each re-verification replaces `planItems[].verdicts` |
222
222
 
223
- **The gate is computed from data.json.** The state file is the audit trail: after a successful self-fix loop the two legitimately differ (the sidecar shows what the first round blocked on, data.json shows the resolved result), and that difference is the record of the fix working. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_file` requires the file to exist with `schemaVersion` / `gateResult` / `planItems` once a round has run, and deliberately does not compare its `gateResult` to data.json.
223
+ **The gate is computed from data.json.** The state file is the audit trail: after a successful self-fix loop the two legitimately differ (the sidecar shows what *each* round blocked on, data.json shows only the resolved result), and that difference is the record of the fix working. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_file` requires the file to exist with `schemaVersion` / `planItems` / `roundHistory` once a round has run, then `_validate_plan_body_state_rounds` checks the round coverage described in §"Round protocol" step 7. Neither compares a gate value to data.json — the two views are supposed to differ, and demanding equality would fail every run whose self-fix loop worked.
224
+
225
+ The per-round structures mirror the finding-convergence state artifact ([convergence](./convergence.md) §"Convergence State Artifact"): `roundHistory[]` is the round-level ledger, and each item's `rounds[]` is its per-round vote history — the same split as that file's `roundHistory[]` / `findings[].rounds[]`.
224
226
 
225
227
  ```json
226
228
  {
227
- "schemaVersion": "1.0",
229
+ "schemaVersion": "1.1",
228
230
  "phase": "implementation-planning",
229
- "round": 1,
230
231
  "effectiveMaxRounds": 1,
231
232
  "gating": true,
232
233
  "verificationMode": "lightweight",
233
- "gateResult": "passed | passed-with-dissent | blocked-by-disagreement | aborted-non-result",
234
- "gateBlockedBy": ["majority-disagree | coverage-gap | non-result"],
235
- "selfFixRoundsApplied": 0,
234
+ "selfFixRoundsApplied": 1,
236
235
  "selfFixStopReason": "not-attempted | all-resolved | no-progress | max-rounds-reached",
237
236
  "planItems": [
238
237
  {
@@ -240,8 +239,13 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
240
239
  "subject": "Option A: split upload v2 into a new module",
241
240
  "sourceSection": "4.5.1",
242
241
  "ticketId": "<id-or-unknown>",
243
- "votes": {"claude-worker": "AGREE", "codex-worker": "AGREE"},
244
- "classification": "full-consensus",
242
+ "rounds": [
243
+ {
244
+ "round": 1,
245
+ "votes": {"claude-worker": "AGREE", "codex-worker": "AGREE"},
246
+ "classification": "full-consensus"
247
+ }
248
+ ],
245
249
  "clarificationId": null
246
250
  },
247
251
  {
@@ -249,22 +253,50 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
249
253
  "subject": "Stage 2 Step 3: run migration then regression test",
250
254
  "sourceSection": "4.5.4",
251
255
  "ticketId": "TICKET-123",
252
- "votes": {"claude-worker": "DISAGREE(a)", "codex-worker": "DISAGREE(a)"},
253
- "classification": "majority-disagree",
254
- "clarificationId": "C-7"
256
+ "rounds": [
257
+ {
258
+ "round": 1,
259
+ "votes": {"claude-worker": "DISAGREE(a)", "codex-worker": "DISAGREE(a)"},
260
+ "classification": "majority-disagree"
261
+ },
262
+ {
263
+ "round": 2,
264
+ "votes": {"claude-worker": "AGREE", "codex-worker": "AGREE"},
265
+ "classification": "full-consensus"
266
+ }
267
+ ],
268
+ "selfFixNote": "self-fixed in round 1: migration path corrected to db/migrations/0007_add_variable_flag.sql",
269
+ "clarificationId": null
255
270
  }
256
271
  ],
257
- "dispatches": [
258
- {"role": "claude-worker", "resultPath": "...", "terminalStatus": "completed"}
272
+ "roundHistory": [
273
+ {
274
+ "round": 1,
275
+ "gateResult": "blocked-by-disagreement",
276
+ "gateBlockedBy": ["majority-disagree"],
277
+ "dispatches": [
278
+ {"role": "claude-worker", "resultPath": "...", "terminalStatus": "completed"}
279
+ ]
280
+ },
281
+ {
282
+ "round": 2,
283
+ "gateResult": "passed-with-dissent",
284
+ "gateBlockedBy": [],
285
+ "dispatches": [
286
+ {"role": "claude-worker", "resultPath": "...", "terminalStatus": "completed"}
287
+ ]
288
+ }
259
289
  ]
260
290
  }
261
291
  ```
262
292
 
263
- `dispatches[].terminalStatus` mirrors finding convergence (`completed | timeout | error | not-run`). A wrapper-recorded `cli-failure` is a run-error-log event, not a terminal statusrecord that dispatch's `terminalStatus` as `error`.
293
+ > Abbreviated example: a one-round run has a single `roundHistory[]` entry and a single `rounds[]` entry per item. `P-Opt-1` above is not re-verified in round 2 because the round is focused on the corrected items and the ones the rewrite touched (step 7) an item may legitimately carry fewer `rounds[]` entries than `roundHistory[]` has rounds, but every round in `roundHistory[]` must appear on at least one item.
294
+
295
+ `roundHistory[].gateResult` / `gateBlockedBy` are that round's own gate resolution (§"Round protocol" step 5), not the run's final one — the final value lives in data.json. `dispatches[].terminalStatus` mirrors finding convergence (`completed | timeout | error | not-run`). A wrapper-recorded `cli-failure` is a run-error-log event, not a terminal status — record that dispatch's `terminalStatus` as `error`.
264
296
 
265
- `planItems[].classification` enum: `full-consensus | partial-consensus | dissent-isolated | majority-disagree | needs-reverify | contested`. `needs-reverify` is the peer-error shape from §"Round protocol" step 4 (a single-vote-blocking kind with fewer than 2 participating non-error votes) — it survives into the state file when the round budget runs out before the re-dispatch resolves it, and `_recompute_plan_body_gate` folds it into `passed-with-dissent`. `contested` only appears when `maxRounds > 1`; at default `maxRounds=1` any otherwise-unresolved item folds into `partial-consensus` per the round protocol above.
297
+ `planItems[].rounds[].classification` enum: `full-consensus | partial-consensus | dissent-isolated | majority-disagree | needs-reverify | contested`. `needs-reverify` is the peer-error shape from §"Round protocol" step 4 (a single-vote-blocking kind with fewer than 2 participating non-error votes) — it survives into the state file when the round budget runs out before the re-dispatch resolves it, and `_recompute_plan_body_gate` folds it into `passed-with-dissent`. `contested` only appears when `maxRounds > 1`; at default `maxRounds=1` any otherwise-unresolved item folds into `partial-consensus` per the round protocol above.
266
298
 
267
- `planItems[].votes.<worker>` stores `AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT` as emitted. A terminal non-result dispatch produces a non-result `verification-error` for each assigned item; a completed dispatch produces a per-item `verification-error` when that item is `UNVERIFIABLE`. Either `verification-error` MUST NOT be converted to `DISAGREE` and is excluded from consensus. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.
299
+ `planItems[].rounds[].votes.<worker>` stores `AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT` as emitted. A terminal non-result dispatch produces a non-result `verification-error` for each assigned item; a completed dispatch produces a per-item `verification-error` when that item is `UNVERIFIABLE`. Either `verification-error` MUST NOT be converted to `DISAGREE` and is excluded from consensus. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.
268
300
 
269
301
  `planBodyVerification.selfFixRoundsApplied` (int, default 0): how many self-fix rounds actually ran. `planBodyVerification.selfFixStopReason` (enum, default `not-attempted`): why the loop exited — see §"Round protocol" step 7. `planItems[].verdicts[].fixability` is each `DISAGREE`'s `planner-fixable | needs-user-input` judgement, recorded in the final data.json (`§5.5.9`).
270
302
 
@@ -3491,6 +3491,9 @@ def _read_verification_target(project_root: Path, relative: str) -> dict | None:
3491
3491
  return parsed
3492
3492
 
3493
3493
 
3494
+ _PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
3495
+
3496
+
3494
3497
  def _validate_plan_body_state_file(
3495
3498
  data: dict,
3496
3499
  report_path: Path,
@@ -3505,8 +3508,8 @@ def _validate_plan_body_state_file(
3505
3508
  rounds found. Both defect investigations of this phase depended on the
3506
3509
  sidecar to recover that history.
3507
3510
 
3508
- Deliberately does NOT cross-check `gateResult` against data.json: the two
3509
- are different views by design (per-round history vs. final state), and
3511
+ Deliberately does NOT cross-check any gate against data.json: the two are
3512
+ different views by design (per-round history vs. final state), and
3510
3513
  demanding equality would fail every run whose self-fix loop worked.
3511
3514
  """
3512
3515
  ip = data.get("implementationPlanning")
@@ -3537,12 +3540,79 @@ def _validate_plan_body_state_file(
3537
3540
  except (OSError, json.JSONDecodeError) as exc:
3538
3541
  failures.append(f"plan-body verification state file is unreadable: {exc}")
3539
3542
  return
3540
- for key in ("schemaVersion", "gateResult", "planItems"):
3541
- if key not in state:
3542
- failures.append(
3543
- f"plan-body verification state file `{expected.name}` is "
3544
- f"missing required key `{key}`."
3545
- )
3543
+ missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
3544
+ for key in missing:
3545
+ failures.append(
3546
+ f"plan-body verification state file `{expected.name}` is "
3547
+ f"missing required key `{key}`."
3548
+ )
3549
+ if not missing:
3550
+ _validate_plan_body_state_rounds(
3551
+ state, pbv, expected.name, round_count, failures
3552
+ )
3553
+
3554
+
3555
+ def _validate_plan_body_state_rounds(
3556
+ state: dict,
3557
+ pbv: dict,
3558
+ name: str,
3559
+ round_count: int,
3560
+ failures: list[str],
3561
+ ) -> None:
3562
+ """Every round that ran must survive in the sidecar, its votes included.
3563
+
3564
+ The round protocol used to write this file once, before the self-fix loop,
3565
+ and never asked for it again — so a run with three re-verifications kept
3566
+ round 1 only, and the superseded rounds this file exists to preserve were
3567
+ exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
3568
+ data.json against `round` 1 here).
3569
+ """
3570
+ history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
3571
+ # A non-int `round` names no round, so it cannot cover one — and reading it
3572
+ # into a set would abort the whole validation on unhashable lead-authored JSON.
3573
+ recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
3574
+ if recorded != set(range(1, round_count + 1)):
3575
+ seen = sorted(recorded)
3576
+ failures.append(
3577
+ f"plan-body verification state file `{name}` records `roundHistory[]` "
3578
+ f"rounds {seen} but the report declares `roundCount`={round_count}. "
3579
+ f"One entry per round 1..{round_count} is required: data.json keeps "
3580
+ "only the final verdicts, so a sidecar frozen at an earlier round "
3581
+ "loses every round it superseded (plan-body-verification.md "
3582
+ '§"Round protocol" step 7 "Round completion").'
3583
+ )
3584
+ gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
3585
+ if gateless:
3586
+ failures.append(
3587
+ f"plan-body verification state file `{name}`: `roundHistory[]` "
3588
+ f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
3589
+ "gate is what tells the reader which round blocked and on what, and "
3590
+ "the sidecar is the only place it survives."
3591
+ )
3592
+ declared = pbv.get("selfFixRoundsApplied")
3593
+ if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
3594
+ failures.append(
3595
+ f"plan-body verification state file `{name}` records "
3596
+ f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
3597
+ f"report declares {declared}. The sidecar is rewritten at each round's "
3598
+ "end, so a stale count means the later rounds were never written to it."
3599
+ )
3600
+ voted = {
3601
+ vote["round"]
3602
+ for item in (state.get("planItems") or [])
3603
+ if isinstance(item, dict)
3604
+ for vote in (item.get("rounds") or [])
3605
+ if isinstance(vote, dict) and isinstance(vote.get("round"), int)
3606
+ }
3607
+ uncited = [n for n in sorted(recorded & set(range(1, round_count + 1)))
3608
+ if n not in voted]
3609
+ if uncited:
3610
+ failures.append(
3611
+ f"plan-body verification state file `{name}`: round(s) {uncited} "
3612
+ "appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
3613
+ "records a vote cast in them. A re-verification round whose verdicts "
3614
+ "were never written down is precisely the history this file holds."
3615
+ )
3546
3616
 
3547
3617
 
3548
3618
  _QA_NOT_CONFIGURED_TEMPLATE = "qa-command not configured: {category}"