okstra 0.223.0 → 0.224.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/commands/pr/pr.d.mts +2 -0
  2. package/dist/commands/pr/pr.mjs +12 -3
  3. package/dist/commands/pr/pr.mjs.map +1 -1
  4. package/docs/cli.md +1 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/clean-code.md +8 -1
  8. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  9. package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
  10. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  11. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
  12. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
  13. package/runtime/python/okstra_ctl/phases/implementation/spec.md +1 -1
  14. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +98 -33
  15. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +22 -2
  16. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
  17. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  18. package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
  19. package/runtime/python/okstra_ctl/plan_items.py +12 -9
  20. package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
  21. package/runtime/python/okstra_ctl/report_finalize.py +8 -3
  22. package/runtime/python/okstra_ctl/run.py +3 -0
  23. package/runtime/python/okstra_ctl/stage_fix_carry.py +118 -16
  24. package/runtime/python/okstra_ctl/stage_rebase.py +3 -0
  25. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
  26. package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
  27. package/runtime/python/okstra_ctl/wizard/registry.py +2 -0
  28. package/runtime/python/okstra_ctl/wizard/state.py +5 -0
  29. package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
  30. package/runtime/python/okstra_ctl/wizard/steps_options.py +11 -0
  31. package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
  32. package/runtime/python/okstra_ctl/worktree/rebase.py +11 -1
  33. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  34. package/runtime/schemas/final-report-v3.0.schema.json +3 -1
  35. package/runtime/skills/okstra-pr-gen/SKILL.md +2 -2
  36. package/runtime/templates/pr/drafting-rules.md +11 -4
@@ -54,6 +54,14 @@ from okstra_ctl.plan_items import (
54
54
  with_response_format,
55
55
  )
56
56
  from okstra_ctl.paths import RunRef
57
+ from okstra_ctl.plan_item_evidence import (
58
+ capture_review_inputs,
59
+ dependency_scopes,
60
+ reusable_item_ids,
61
+ review_fingerprint,
62
+ review_files_unchanged,
63
+ )
64
+ from okstra_ctl.plan_review_context import render_review_context
57
65
  from okstra_ctl.stage_ledger import build_stage_ledger
58
66
  from okstra_ctl.claim_reproduction import NOT_RUNNABLE, reproduce
59
67
  from okstra_ctl.final_report_schema import load_schema_version
@@ -118,13 +126,39 @@ def _queue_for(
118
126
  run_manifest: Path | None,
119
127
  previous_hashes: Mapping[str, str] | None = None,
120
128
  tied_ids: Sequence[str] | None = None,
129
+ previous_rows: Sequence[Mapping[str, Any]] = (),
130
+ review_inputs: Mapping[str, Any] | None = None,
121
131
  ) -> list[str]:
122
132
  ledger = _merged_ledger(planning, run_manifest)
123
133
  if tied_ids is not None:
124
134
  return tie_vote_item_ids(items, ledger, tied_ids)
135
+ inputs = review_inputs or _review_inputs(items, planning, run_manifest, previous_rows)
136
+ reusable = reusable_item_ids(previous_rows, inputs)
137
+ candidates = set(dispatch_item_ids(items, ledger))
125
138
  if previous_hashes:
126
- return reverify_item_ids(items, previous_hashes, ledger)
127
- return dispatch_item_ids(items, ledger)
139
+ candidates.update(reverify_item_ids(items, previous_hashes, ledger))
140
+ return [item["id"] for item in items if item["id"] in candidates - reusable]
141
+
142
+
143
+ def _review_inputs(
144
+ items: list[dict[str, Any]], planning: Mapping[str, Any],
145
+ run_manifest: Path | None, rows: Sequence[Mapping[str, Any]],
146
+ ) -> dict[str, Any]:
147
+ if run_manifest is None:
148
+ return {}
149
+ authority = validated_run_authority(run_manifest)
150
+ return capture_review_inputs(items, planning, authority.payload, authority.project_root, rows)
151
+
152
+
153
+ def _checked_review_inputs(run_manifest: Path | None) -> dict[str, Any]:
154
+ root = _probe_project_root(run_manifest)
155
+ if root is None:
156
+ return {}
157
+ path = _prepared_items_path(run_manifest)
158
+ inputs = _load_json_object(path).get("reviewInputs", {}) if path.is_file() else {}
159
+ if not review_files_unchanged(inputs, root):
160
+ raise PlanItemContractError("review evidence changed during dispatch; prepare and verify again")
161
+ return inputs
128
162
 
129
163
 
130
164
  def _queue_kwargs(args: argparse.Namespace) -> dict[str, Any]:
@@ -133,7 +167,9 @@ def _queue_kwargs(args: argparse.Namespace) -> dict[str, Any]:
133
167
  if state is None:
134
168
  raise PlanItemContractError("--tie-vote requires --state")
135
169
  return {"tied_ids": _tied_ids(state)}
136
- return {"previous_hashes": _previous_hashes(getattr(args, "state", None))}
170
+ state = getattr(args, "state", None)
171
+ rows = _state_plan_body_items(_load_json_object(state), state) if state and state.is_file() else []
172
+ return {"previous_hashes": _previous_hashes(state), "previous_rows": rows}
137
173
 
138
174
 
139
175
  def _tied_ids(state_path: Path) -> list[str]:
@@ -170,8 +206,8 @@ def _parser() -> argparse.ArgumentParser:
170
206
  prepare.add_argument(
171
207
  "--state",
172
208
  type=Path,
173
- help="previous plan-body state; when it carries verifiedContentHash "
174
- "rows the dispatch queue shrinks to the self-fix reverify set, and "
209
+ help="previous plan-body state; only matching body and complete "
210
+ "evidence fingerprints shrink the self-fix reverify queue, and "
175
211
  "each queued item's recorded votes and selfFixNote are carried "
176
212
  "into the prompt as its `**Prior round dissent**` block",
177
213
  )
@@ -251,8 +287,8 @@ def _parser() -> argparse.ArgumentParser:
251
287
  "--prior-state",
252
288
  type=Path,
253
289
  help="the previous run's plan-body-verification-<task-type>-<seq>.json. "
254
- "A newly seeded item whose contentHash still equals that run's "
255
- "verifiedContentHash inherits its verdicts and is tagged "
290
+ "A newly seeded item with matching body and complete evidence "
291
+ "fingerprints inherits its verdicts and is tagged "
256
292
  "carriedForwardFromSeq, so round 1 does not re-judge text nobody "
257
293
  "changed. Requires --state, refuses a state file belonging to "
258
294
  "another task, and never carries on a matching id alone",
@@ -474,12 +510,21 @@ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
474
510
  + "; correct the narrative in this run, then retry plan-items prepare"
475
511
  )
476
512
  envelope = _envelope(source)
513
+ kwargs = _queue_kwargs(args)
514
+ envelope["reviewInputs"] = _review_inputs(
515
+ envelope["items"], _planning(source), args.run_manifest,
516
+ kwargs.get("previous_rows", []),
517
+ )
477
518
  envelope["dispatchQueue"] = _queue_for(
478
519
  envelope["items"],
479
520
  _planning(source),
480
521
  args.run_manifest,
481
- **_queue_kwargs(args),
522
+ **kwargs,
523
+ review_inputs=envelope["reviewInputs"],
482
524
  )
525
+ envelope["reusedItems"] = sorted(reusable_item_ids(
526
+ kwargs.get("previous_rows", []), envelope["reviewInputs"],
527
+ ))
483
528
  if getattr(args, "tie_vote", False):
484
529
  envelope["dispatchKind"] = "critic-tie"
485
530
  envelope["tieSplits"] = _tie_splits_from_state(
@@ -998,6 +1043,7 @@ def _render_inputs(run_manifest: Path) -> str:
998
1043
 
999
1044
 
1000
1045
  def _prompt(args: argparse.Namespace) -> str:
1046
+ inputs = _checked_review_inputs(args.run_manifest)
1001
1047
  envelope = _load_json_object(
1002
1048
  _prepared_items_path(args.run_manifest, require_regular=True)
1003
1049
  )
@@ -1021,6 +1067,11 @@ def _prompt(args: argparse.Namespace) -> str:
1021
1067
  line("Item count", len(items)),
1022
1068
  _render_inputs(args.run_manifest),
1023
1069
  ]
1070
+ authority = validated_run_authority(args.run_manifest)
1071
+ rows.append(render_review_context(
1072
+ [item["id"] for item in items], inputs, authority.payload, authority.project_root,
1073
+ ))
1074
+ _checked_review_inputs(args.run_manifest)
1024
1075
  for index, item in enumerate(items, 1):
1025
1076
  if not isinstance(item, Mapping):
1026
1077
  continue
@@ -1075,6 +1126,12 @@ def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
1075
1126
  )
1076
1127
  if actual.get("dispatchQueue") != expected_queue:
1077
1128
  raise PlanItemContractError("prepared dispatch queue does not match")
1129
+ current_inputs = _review_inputs(
1130
+ expected["items"], _planning(source), args.run_manifest,
1131
+ _queue_kwargs(args).get("previous_rows", []),
1132
+ )
1133
+ if actual.get("reviewInputs") != current_inputs:
1134
+ raise PlanItemContractError("prepared review evidence changed; prepare again")
1078
1135
  if not getattr(args, "tie_vote", False):
1079
1136
  # 라운드 2+ 의 반대 의견 반입은 여기서만 검사된다. 봉투에서 빠지면
1080
1137
  # 프롬프트에도 빠지고, 반대한 워커는 자기 판정을 그대로 다시 적는다.
@@ -1134,6 +1191,7 @@ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
1134
1191
  ("fixability", block.fixability),
1135
1192
  ("note", block.note),
1136
1193
  ("explanation", block.explanation),
1194
+ ("dependencies", block.dependencies),
1137
1195
  ):
1138
1196
  if value:
1139
1197
  row[key] = value
@@ -1367,24 +1425,6 @@ def _carry_prior_run_verdicts(
1367
1425
  added: list[dict[str, Any]],
1368
1426
  hashes: Mapping[str, str],
1369
1427
  ) -> _PriorRunCarry | None:
1370
- """직전 run 이 같은 본문에 내린 판정을 새로 시드된 행에 옮긴다.
1371
-
1372
- 실패한 계획 run 을 다시 돌면 판정이 하나도 넘어오지 않았다. 이월은 답변된
1373
- clarification 이 있는 경로에만 있고(`incremental_scope.decide_scope` 는
1374
- 답변된 `C-NNN` 이 없으면 `full` 을 되돌려 `incremental_carry` 를 도달 불가로
1375
- 만든다), 그래서 아무도 고치지 않은 문장이 매 재실행마다 다시 채점됐다.
1376
-
1377
- 행 모양은 `incremental_carry._stamp_carried` 와 같다 — `verdicts` 를 그대로
1378
- 옮기고, `carriedForwardFromSeq` 에 직전 seq 를 찍고, `verifiedContentHash` 를
1379
- 이번 추출 해시로 맞춘다. 그 해시가 시드의 `dispatchQueue` 계산에 그대로
1380
- 들어가므로(`_queue_for` → `reverify_item_ids`), 이월된 행은 라운드 1 큐에서
1381
- 빠진다. 큐를 따로 줄이지 않는 이유가 이것이다: incremental 경로가
1382
- `incremental_carry._recompute_dispatch_queue` 에서 쓰는 것과 같은 장치다.
1383
-
1384
- id 만 같은 행은 절대 이월하지 않는다. `P-*` id 는 위치에서 나오므로 계획이
1385
- 한 줄만 밀려도 같은 번호가 다른 문장을 가리킨다. 해시가 같아야 그 문장이
1386
- 같은 문장이다.
1387
- """
1388
1428
  prior = getattr(args, "prior_state", None)
1389
1429
  if prior is None:
1390
1430
  return None
@@ -1420,9 +1460,17 @@ def _carry_prior_run_verdicts(
1420
1460
  current_hash = hashes.get(item_id)
1421
1461
  if not current_hash or previous.get("verifiedContentHash") != current_hash:
1422
1462
  continue
1463
+ manifest = getattr(args, "run_manifest", None)
1464
+ inputs = (
1465
+ _load_json_object(_prepared_items_path(manifest)).get("reviewInputs", {})
1466
+ if manifest is not None else {}
1467
+ )
1468
+ if item_id not in reusable_item_ids([previous], inputs):
1469
+ continue
1423
1470
  row["verdicts"] = copy.deepcopy(verdicts)
1424
1471
  row["carriedForwardFromSeq"] = prev_seq
1425
1472
  row["verifiedContentHash"] = current_hash
1473
+ row["verifiedReviewHash"] = previous["verifiedReviewHash"]
1426
1474
  count += 1
1427
1475
  return _PriorRunCarry(prev_seq, count)
1428
1476
 
@@ -1526,6 +1574,7 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
1526
1574
  _planning(source),
1527
1575
  getattr(args, "run_manifest", None),
1528
1576
  previous,
1577
+ previous_rows=recorded,
1529
1578
  )
1530
1579
  verification["gating"] = (
1531
1580
  verification.get("gating") is True
@@ -1609,8 +1658,21 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1609
1658
  f"gate is re-derived from that table, so a verdict with nowhere to "
1610
1659
  f"land would be scored as if it were never cast"
1611
1660
  )
1661
+ review_inputs = _checked_review_inputs(getattr(args, "run_manifest", None))
1612
1662
  if args.round_number < 1:
1613
1663
  raise PlanItemContractError("--round must be 1 or greater")
1664
+ _write_round_verdicts(args, data, recorded, rows, review_inputs)
1665
+ if requires_plan_repair(verification):
1666
+ verification["gating"] = True
1667
+ _validate_advisory_round(verification, args.round_number)
1668
+ write_json_atomic(target, data)
1669
+ return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
1670
+
1671
+
1672
+ def _write_round_verdicts(
1673
+ args: argparse.Namespace, data: Mapping[str, Any], recorded: list[dict[str, Any]],
1674
+ rows: Mapping[str, list[dict[str, Any]]], review_inputs: Mapping[str, Any],
1675
+ ) -> None:
1614
1676
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1615
1677
  append = bool(getattr(args, "append", False))
1616
1678
  replaces_critic = append and any(
@@ -1627,18 +1689,20 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1627
1689
  rows,
1628
1690
  data.get("roundHistory"),
1629
1691
  args.round_number,
1630
- target,
1631
- discard_open_rounds=discard_open_rounds,
1692
+ args.state or args.data,
1693
+ discard_open_rounds=bool(getattr(args, "discard_open_rounds", False)),
1632
1694
  )
1633
1695
  writer = _append_item_verdicts if append else _replace_item_verdicts
1634
1696
  for item in recorded:
1635
1697
  if isinstance(item, Mapping) and item.get("id") in rows:
1636
1698
  writer(item, rows[item["id"]], args.round_number, project_root)
1637
- if requires_plan_repair(verification):
1638
- verification["gating"] = True
1639
- _validate_advisory_round(verification, args.round_number)
1640
- write_json_atomic(target, data)
1641
- return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
1699
+ review_inputs = {**review_inputs, "dependencies": dependency_scopes(recorded)}
1700
+ for item in recorded:
1701
+ if isinstance(item, Mapping) and item.get("id") in rows:
1702
+ item.pop("verifiedReviewHash", None)
1703
+ fingerprint = review_fingerprint(item, review_inputs)
1704
+ if fingerprint is not None:
1705
+ item["verifiedReviewHash"] = fingerprint
1642
1706
 
1643
1707
 
1644
1708
  def _stamped_verdicts(
@@ -2110,6 +2174,7 @@ def _reject_unbacked_self_fix_stop(
2110
2174
 
2111
2175
 
2112
2176
  def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
2177
+ _checked_review_inputs(args.run_manifest)
2113
2178
  if args.round_number < 1:
2114
2179
  raise PlanItemContractError("--round must be 1 or greater")
2115
2180
  data, current, audit, history = _round_inputs(args)
@@ -413,11 +413,11 @@ round before any host or provider process starts.
413
413
 
414
414
  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.
415
415
 
416
- 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.
416
+ 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. The runtime reuses a verdict only when its body, complete declared evidence closure, shared analysis packet, plan metadata, reviewer assignments and review contract have the same fingerprint. A missing or uncertain dependency declaration, unresolved reference, non-result or executable fact probe stays in the re-verification queue. Detailed verdicts and their original rounds remain unchanged for reused items. File dependencies cover whole files; step items also include preceding steps; plan-wide and preparation items include all plan items. Python resolves the one convergence-owned state path from that run identity. Dispatch only after exact-match validation succeeds. `observed` / `deferred` stages remain omitted. An empty queue opens no worker dispatch and creates no verification round. **Enforced:** `implementation_planning.authoring._queue_for`, `plan_item_evidence.review_fingerprint`, `_validate_prepared` and `tests/run/test_plan_items.py`. An incremental carry without evidence snapshots keeps active items queued until prepare can validate their dependencies.
417
417
 
418
418
  **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 [...]`.
419
419
 
420
- **On a re-run, add `--prior-state <the previous run's plan-body-verification-<task-type>-<seq>.json>`.** A newly seeded item whose `contentHash` equals that run's `verifiedContentHash` for the same id inherits its verdicts, is tagged `carriedForwardFromSeq`, and drops out of round 1's `dispatchQueue` — in the state and in the sibling `plan-items-*.json` the next `okstra plan-items prompt` reads. Without it a re-run re-judges every line the previous run already settled, and the same dissent re-opens under a new `P-*` number. A matching id alone never carries: `P-*` ids are positional, so a plan that gained one line hands the old id to a different sentence. The flag requires `--state` and refuses a prior state file that sits under another task's root — a plan-body state carries no task identity of its own, so its path is the only identity there is. **Enforced:** `okstra_ctl.plan_items_cli._carry_prior_run_verdicts`.
420
+ **On a re-run, add `--prior-state <the previous run's plan-body-verification-<task-type>-<seq>.json>`.** A newly seeded item inherits verdicts only when its body and verified evidence fingerprint match the prepared snapshot for the same task. Reused rows carry `carriedForwardFromSeq`; their full verdicts are preserved. Legacy body-only records re-enter verification. A matching id alone never carries: `P-*` ids are positional. The flag requires `--state` and refuses another task's state. **Enforced:** `implementation_planning.authoring._carry_prior_run_verdicts` and `plan_item_evidence.reusable_item_ids`.
421
421
 
422
422
  **`--run-manifest` is what scopes the gate to the stage you are starting.** Seed uses it to overlay disk `done` / `active` onto `planBodyVerification.stageLedger`. The current plan's depends-on fills `ready` / `blocked` when no prior plan exists, so a first run does not treat every stage as in-scope. **Enforced:** `okstra_ctl.plan_items.planning_stage_ledger`.
423
423
  2. For each analyser worker in the roster, use `okstra plan-items prompt` output verbatim as the instruction body in the materialization sequence above. Read §"Re-verification rounds (round 2+)" before preparing a later round; read §"Plan-body reverify prompt" only when diagnosing a prompt-contract failure.
@@ -693,6 +693,23 @@ and a `- Plan narrative:` line from its `reportNarrativePath`. The worker's
693
693
  are what let a verifier read the requirement a selected-direction `P-Req-*` row
694
694
  names by `originalRequirementId`. The lead does not add them by hand.
695
695
 
696
+ When a complete input snapshot is available, the renderer also provides inline
697
+ review context. Read that context and the assigned payloads first. It preserves
698
+ the packet except its navigation index and generated analysis Coverage Census,
699
+ and carries plan metadata plus supporting items as lossless JSON. Supporting
700
+ items are narrowed only when the previous dependency closure is complete and
701
+ every body in that closure is unchanged; otherwise all other plan items are
702
+ included. A changed body, missing declaration, unresolved reference or uncertain
703
+ cross-item claim requires the full context or the original Inputs paths.
704
+ Read those paths when the supplied context does not resolve a question, and
705
+ record the additional dependencies. Omission never proves absence.
706
+ Packets containing finding-like headings are quoted as source data, preserving
707
+ their text without adding foreign items to the plan-verification queue.
708
+
709
+ Enforcement: `render_review_context` in `okstra_ctl.plan_review_context` selects
710
+ the context; `test_plan_review_context_preserves_facts_and_falls_back_for_unknown_dependencies`
711
+ in `tests/run/test_plan_items.py` checks retained facts and conservative fallback.
712
+
696
713
  Carrying the packet does not license re-analysis. It is there so the verifier
697
714
  can resolve a plan item back to the requirement it claims to satisfy; the
698
715
  posture in §"Adversarial plan-body posture" still applies, and this round does
@@ -847,6 +864,9 @@ never recorded, and the round is scored on the items that remain.
847
864
  **Probe** (only when Claim is fact): <one line of JSON, e.g. {"kind": "path-absent", "path": "src/missing.ts"}>
848
865
  **Note**: <the falsification candidate considered and why it is excluded; required even for AGREE>
849
866
  **Explanation**: <2-3 sentences>
867
+ **Dependencies**: {"complete": false, "itemIds": [], "paths": []}
868
+
869
+ Keep the detailed Note and Explanation. Declare all direct and transitive plan-item and whole-file evidence dependencies. Set `complete` to true only when that boundary is known. Include source files behind references. External or dynamic evidence, unlisted narrative dependencies and unresolved references keep `complete` false. This declaration controls reuse eligibility, not the verdict itself. Missing and malformed declarations are treated as unknown by `verdict_blocks._block` and `plan_item_evidence.review_fingerprint`.
850
870
 
851
871
  ### P-Opt-2
852
872
  ...
@@ -68,6 +68,7 @@ from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
68
68
 
69
69
  from okstra_ctl.plan_items import ( # noqa: E402
70
70
  content_hash,
71
+ PlanItemContractError,
71
72
  expected_plan_item_ids,
72
73
  extract_plan_items,
73
74
  )
@@ -2543,6 +2544,7 @@ def validate_plan_body_section(
2543
2544
  _validate_round_recorded_verdicts(data, failures)
2544
2545
  _validate_verdicts_match_current_subjects(data, failures)
2545
2546
  _validate_verdict_rounds_outlive_self_fix(data, failures)
2547
+ _validate_review_evidence(data, report_path, failures)
2546
2548
  _validate_unresolved_tie_was_reverified(data, failures)
2547
2549
  _validate_advisory_plan_body_gating(data, failures)
2548
2550
  _validate_plan_item_extraction_completeness(data, failures)
@@ -2553,6 +2555,37 @@ def validate_plan_body_section(
2553
2555
  return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
2554
2556
 
2555
2557
 
2558
+ def _validate_review_evidence(data: dict, report_path: Path, failures: list[str]) -> None:
2559
+ from okstra_ctl.convergence import validated_run_authority, ConvergenceContractError
2560
+ from okstra_ctl.plan_item_evidence import capture_review_inputs, review_fingerprint
2561
+
2562
+ planning = data.get("implementationPlanning") or {}
2563
+ pbv = planning.get("planBodyVerification") or {}
2564
+ rows = pbv.get("planItems") or []
2565
+ if not any(row.get("verifiedReviewHash") for row in rows):
2566
+ return
2567
+ sequences = _manifest_seq_for_report(report_path, "manifests")
2568
+ if len(sequences) != 1:
2569
+ failures.append("plan review evidence has no unambiguous run manifest")
2570
+ return
2571
+ path = report_path.parent.parent / "manifests" / (
2572
+ f"run-manifest-implementation-planning-{next(iter(sequences))}.json"
2573
+ )
2574
+ try:
2575
+ authority = validated_run_authority(path)
2576
+ inputs = capture_review_inputs(
2577
+ extract_plan_items(planning), planning, authority.payload, authority.project_root, rows,
2578
+ )
2579
+ except (ConvergenceContractError, PlanItemContractError) as exc:
2580
+ failures.append(f"cannot validate plan review evidence: {exc}")
2581
+ return
2582
+ stale = [row["id"] for row in rows if _item_stage_scope_bucket(row, pbv) == "in-scope"
2583
+ and row.get("verifiedReviewHash")
2584
+ and row["verifiedReviewHash"] != review_fingerprint(row, inputs)]
2585
+ if stale:
2586
+ failures.append(f"plan review evidence changed for {stale}; prepare and re-verify those items")
2587
+
2588
+
2556
2589
  def _gate_summary_item(
2557
2590
  item: dict,
2558
2591
  pbv: dict,
@@ -29,7 +29,7 @@ Report shapes remain in `schemas/final-report-v2.0.schema.json` and `schemas/fin
29
29
 
30
30
  `release-handoff` is the terminal phase that pushes already-committed implementation stages with a release-ready verdict, or hands them off as pull requests. **One stage is one PR.** The PR head is that stage's stack branch; the base comes from its `depends-on` — the release base for a stage with no live dependency, the predecessor's branch for a stage with one (a stacked PR), and a branch merging the predecessors for a stage with several.
31
31
 
32
- When a predecessor advances, `okstra_ctl.stage_rebase.synchronize_stage_stack` rebases downstream stages in dependency order rather than merging the predecessor into them. It records the new bases and completion commits, preserving prior heads under `refs/okstra/rebase-backups/`. Implementation entry and verification target acquisition use the same operation. PR planning stops after a stack change and requires fresh final-verification; the new completion rows invalidate earlier verification through `consumers.verified_accepted_stages`. Dirty or active stages, rewritten unproven predecessors, and rebase conflicts stop synchronization. The tests in `tests/worktree/test_stage_rebase.py` cover ordering, preservation, and verification invalidation. Origin divergence remains a delivery error, never a reason to merge old origin history into the rebased branch.
32
+ When a predecessor advances, `okstra_ctl.stage_rebase.synchronize_stage_stack` rebases downstream stages in dependency order rather than merging the predecessor into them. It records the new bases and completion commits, preserving prior heads under `refs/okstra/rebase-backups/`. Implementation entry and verification target acquisition use the same operation. PR planning stops after a stack change and requires fresh final-verification; the new completion rows invalidate earlier verification through `consumers.verified_accepted_stages`. Dirty or active stages, rewritten unproven predecessors, and rebase conflicts stop synchronization. A completed stage whose predecessor was reopened, and so has no done row, is left unchanged; the first synchronization after that predecessor is done again rebases it. The tests in `tests/worktree/test_stage_rebase.py` cover ordering, preservation, and verification invalidation. Origin divergence remains a delivery error, never a reason to merge old origin history into the rebased branch.
33
33
 
34
34
  Because the PRs are a stack, they must be merged in dependency order with a merge commit or a rebase-merge. A squash replaces the commits the next PR's base points at, so the stack breaks — every PR body states this, and the lead never merges.
35
35
 
@@ -0,0 +1,237 @@
1
+ """Conservative evidence snapshots for plan-verification verdict reuse."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import stat
8
+ from collections.abc import Mapping, Sequence
9
+ from copy import deepcopy
10
+ from pathlib import Path
11
+ from typing import Any, TypedDict
12
+
13
+ from .plan_items import content_hash, is_critic_worker
14
+ from .paths import find_asset_root
15
+ from .report_narrative import writer_owned_data
16
+
17
+
18
+ class FileSnapshot(TypedDict):
19
+ path: str
20
+ hash: str
21
+ mode: int | None
22
+
23
+
24
+ class ReviewInputs(TypedDict):
25
+ version: int
26
+ items: dict[str, dict[str, Any]]
27
+ files: dict[str, FileSnapshot | None]
28
+ dependencies: dict[str, list[dict[str, Any]]]
29
+ workers: list[str]
30
+ context: str
31
+ complete: bool
32
+ planContext: dict[str, Any]
33
+ priorReviewHashes: dict[str, str]
34
+
35
+
36
+ def _digest(value: object) -> str:
37
+ return hashlib.sha256(
38
+ json.dumps(value, sort_keys=True, ensure_ascii=False).encode("utf-8")
39
+ ).hexdigest()
40
+
41
+
42
+ def _file_snapshot(root: Path, name: str) -> FileSnapshot | None:
43
+ path = root / name
44
+ try:
45
+ resolved = path.resolve(strict=False)
46
+ if not resolved.is_relative_to(root.resolve()):
47
+ return None
48
+ before = path.stat()
49
+ if stat.S_ISREG(before.st_mode):
50
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
51
+ after = path.stat()
52
+ changed = any(getattr(before, field) != getattr(after, field) for field in (
53
+ "st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode",
54
+ ))
55
+ if changed or resolved != path.resolve(strict=False):
56
+ return None
57
+ return {"path": str(resolved), "hash": digest, "mode": stat.S_IMODE(before.st_mode)}
58
+ except FileNotFoundError:
59
+ if not path.is_symlink():
60
+ return {"path": str(path.resolve(strict=False)), "hash": "absent", "mode": None}
61
+ except (OSError, RuntimeError):
62
+ return None
63
+ return None
64
+
65
+
66
+ def _payload_paths(value: object) -> set[str]:
67
+ if isinstance(value, str):
68
+ return {value} if value and not any(c in value for c in "\n`*") else set()
69
+ if isinstance(value, list):
70
+ return set().union(*(_payload_paths(entry) for entry in value))
71
+ if isinstance(value, Mapping):
72
+ return _payload_paths(value.get("path"))
73
+ return set()
74
+
75
+
76
+ def capture_review_inputs(
77
+ items: Sequence[Mapping[str, Any]],
78
+ planning: Mapping[str, Any],
79
+ manifest: Mapping[str, Any],
80
+ root: Path,
81
+ previous_rows: Sequence[Mapping[str, Any]] = (),
82
+ ) -> ReviewInputs:
83
+ paths: set[str] = set()
84
+ for item in items:
85
+ payload = item.get("payload") or {}
86
+ paths.update(
87
+ match for match in re.findall(r"`([^`\s]+)`", json.dumps(payload))
88
+ if "/" in match or "." in match
89
+ )
90
+ for field in ("files", "plannedPaths"):
91
+ paths.update(_payload_paths(payload.get(field)))
92
+ for row in previous_rows:
93
+ for vote in row.get("verdicts", []):
94
+ dependencies = vote.get("dependencies")
95
+ if isinstance(dependencies, Mapping) and isinstance(dependencies.get("paths"), list):
96
+ paths.update(p for p in dependencies.get("paths", []) if isinstance(p, str))
97
+ packet = manifest.get("analysisPacketPath")
98
+ if isinstance(packet, str) and packet:
99
+ paths.add(packet)
100
+ metadata = writer_owned_data({"implementationPlanning": planning})["implementationPlanning"]
101
+ for field in ("planBodyVerification", "optionCandidates", "stepwiseExecution",
102
+ "dependencyMigrationRisk", "validationChecklist", "rollbackStrategy",
103
+ "requirementCoverage", "variationPointAnalysis"):
104
+ metadata.pop(field, None)
105
+ metadata["stages"] = [
106
+ {key: value for key, value in stage.items() if key != "stepwiseExecution"}
107
+ for stage in metadata.get("stages", [])
108
+ ]
109
+ contracts = find_asset_root(("agents", "common.json"))
110
+ contract_hashes = {
111
+ name: _file_snapshot(contracts, name) if contracts else None for name in (
112
+ "agents/common.json", "agents/roles/analyser.json",
113
+ "prompts/duties/reverification-worker.json",
114
+ )
115
+ }
116
+ package = Path(__file__).resolve().parent
117
+ contract_hashes.update({name: _file_snapshot(package, name) for name in (
118
+ "plan_items.py", "plan_item_evidence.py", "plan_review_context.py", "phases/implementation_planning/authoring.py",
119
+ "phases/implementation_planning/instructions/plan-body-verification.md",
120
+ )})
121
+ files = {name: _file_snapshot(root, name) for name in sorted(paths)}
122
+ return {
123
+ "version": 1,
124
+ "planContext": metadata,
125
+ "priorReviewHashes": {str(row["id"]): str(row.get("verifiedReviewHash", "")) for row in previous_rows},
126
+ "items": {str(item["id"]): dict(item) for item in items},
127
+ "files": files,
128
+ "dependencies": dependency_scopes(previous_rows),
129
+ "workers": [row["workerId"] for row in manifest.get("workerAssignments", [])
130
+ if row.get("workerId") and not is_critic_worker(row["workerId"])
131
+ and not row["workerId"].startswith("report-writer")],
132
+ "context": _digest([metadata, contract_hashes, manifest.get("workerAssignments"),
133
+ manifest.get("agentContract"), manifest.get("invocationAssignments"),
134
+ files.get(packet)]),
135
+ "complete": bool(packet and files.get(packet) and files[packet]["hash"] != "absent"
136
+ and all(value and value["hash"] != "absent" for value in contract_hashes.values())),
137
+ }
138
+
139
+
140
+ def dependency_scopes(rows: Sequence[Mapping[str, Any]]) -> dict[str, list[dict[str, Any]]]:
141
+ return {
142
+ str(row["id"]): deepcopy(row["verdicts"]) for row in rows
143
+ if isinstance(row.get("id"), str) and isinstance(row.get("verdicts"), list)
144
+ and row["verdicts"]
145
+ }
146
+
147
+
148
+ def _vote_dependencies(votes: object, workers: Sequence[str]) -> tuple[set[str], set[str]] | None:
149
+ if (not isinstance(votes, list) or not votes or not workers
150
+ or any(not isinstance(vote, Mapping) for vote in votes)
151
+ or set(workers) - {vote.get("worker") for vote in votes}):
152
+ return None
153
+ item_ids, paths = set(), set()
154
+ for vote in votes:
155
+ dependencies = vote.get("dependencies")
156
+ if (vote.get("verdict") not in {"AGREE", "DISAGREE", "SUPPLEMENT"}
157
+ or vote.get("claimKind") == "fact"
158
+ or not isinstance(dependencies, Mapping)
159
+ or dependencies.get("complete") is not True):
160
+ return None
161
+ for field in ("itemIds", "paths"):
162
+ values = dependencies.get(field)
163
+ if not isinstance(values, list) or any(not isinstance(v, str) or not v for v in values):
164
+ return None
165
+ item_ids.update(dependencies["itemIds"])
166
+ paths.update(dependencies["paths"])
167
+ return item_ids, paths
168
+
169
+
170
+ def _dependency_closure(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> tuple[set[str], set[str]] | None:
171
+ items = inputs.get("items", {})
172
+ graph = {**inputs.get("dependencies", {}), str(row["id"]): row.get("verdicts")}
173
+ pending, item_ids, paths = [str(row["id"])], set(), set()
174
+ while pending:
175
+ key = pending.pop()
176
+ if key in item_ids:
177
+ continue
178
+ if key not in items:
179
+ return None
180
+ declared = _vote_dependencies(graph.get(key), inputs.get("workers", []))
181
+ if declared is None:
182
+ return None
183
+ if any(not inputs.get("files", {}).get(name)
184
+ or inputs["files"][name]["hash"] == "absent" for name in declared[1]):
185
+ return None
186
+ item_ids.add(key)
187
+ pending.extend(declared[0])
188
+ paths.update(declared[1])
189
+ payload = items[key].get("payload") or {}
190
+ if key.startswith(("P-Opt-", "P-Var-", "P-Dir-", "P-Dep-", "P-Prep-")):
191
+ pending.extend(items)
192
+ paths.update(inputs.get("files", {}))
193
+ elif key.startswith("P-Step-"):
194
+ pending.extend(
195
+ other_id for other_id, other in items.items()
196
+ if other_id.startswith("P-Step-") and other["payload"].get("stage") == payload.get("stage")
197
+ and other["payload"].get("step", 0) < payload.get("step", 0)
198
+ )
199
+ for field in ("files", "plannedPaths"):
200
+ paths.update(_payload_paths(payload.get(field)))
201
+ return item_ids, paths
202
+
203
+
204
+ def review_fingerprint(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> str | None:
205
+ items, files = inputs.get("items", {}), inputs.get("files", {})
206
+ item = items.get(row.get("id"))
207
+ if not inputs.get("complete") or not item or row.get("contentHash") != content_hash(item):
208
+ return None
209
+ closure = _dependency_closure(row, inputs)
210
+ if closure is None:
211
+ return None
212
+ item_ids, paths = closure
213
+ if any(name not in files or files[name] is None for name in paths):
214
+ return None
215
+ return _digest({
216
+ "context": inputs["context"],
217
+ "items": {key: content_hash(items[key]) for key in sorted(item_ids)},
218
+ "files": {name: files[name] for name in sorted(paths)},
219
+ "votes": row["verdicts"],
220
+ })
221
+
222
+
223
+ def review_files_unchanged(inputs: Mapping[str, Any], root: Path) -> bool:
224
+ return all(
225
+ value == _file_snapshot(root, name)
226
+ for name, value in inputs.get("files", {}).items()
227
+ )
228
+
229
+
230
+ def reusable_item_ids(
231
+ rows: Sequence[Mapping[str, Any]], inputs: Mapping[str, Any],
232
+ ) -> set[str]:
233
+ return {
234
+ str(row["id"]) for row in rows
235
+ if row.get("verifiedReviewHash")
236
+ and row["verifiedReviewHash"] == review_fingerprint(row, inputs)
237
+ }