okstra 0.221.0 → 0.223.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 (48) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +5 -5
  3. package/docs/cli.md +7 -7
  4. package/docs/project-structure-overview.md +4 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/convergence.md +3 -1
  8. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  9. package/runtime/prompts/lead/phase-routing.md +1 -1
  10. package/runtime/python/okstra_ctl/analysis_packet.py +8 -6
  11. package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
  12. package/runtime/python/okstra_ctl/convergence.py +155 -19
  13. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
  14. package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
  15. package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
  16. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
  17. package/runtime/python/okstra_ctl/correction_cli.py +7 -7
  18. package/runtime/python/okstra_ctl/correction_round.py +40 -0
  19. package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
  20. package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
  21. package/runtime/python/okstra_ctl/next_phase.py +22 -9
  22. package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
  23. package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
  24. package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
  25. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
  26. package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +42 -1
  27. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
  28. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
  29. package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
  30. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  31. package/runtime/python/okstra_ctl/process_group.py +4 -0
  32. package/runtime/python/okstra_ctl/report_assembly.py +50 -13
  33. package/runtime/python/okstra_ctl/report_html/common.py +3 -1
  34. package/runtime/python/okstra_ctl/report_projections.py +46 -4
  35. package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
  36. package/runtime/python/okstra_ctl/run.py +8 -5
  37. package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
  38. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
  39. package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
  40. package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
  41. package/runtime/schemas/final-report-v2.0.schema.json +7 -2
  42. package/runtime/schemas/final-report-v3.0.schema.json +43 -2
  43. package/runtime/skills/okstra-run/SKILL.md +1 -0
  44. package/runtime/templates/reports/html/base.template.html +6 -1
  45. package/runtime/templates/reports/html/i18n/en.json +1 -0
  46. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  47. package/runtime/validators/validate-run.py +10 -6
  48. package/runtime/validators/validate_session_conformance.py +19 -1
@@ -12,7 +12,10 @@ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
12
12
  from okstra_ctl.stage_map import parse_stage_map_for_prepare
13
13
  from okstra_ctl.phases.catalog import migrated_report_template
14
14
  from okstra_ctl.handoff_error import HandoffError
15
- from .operations import _require_eligible, compute_eligibility, _parse_stages_csv
15
+ from .operations import (
16
+ DEFAULT_RELEASE_BASES, _parse_stages_csv, _require_eligible,
17
+ anchor_base_branches, compute_eligibility,
18
+ )
16
19
 
17
20
 
18
21
  def _collect_handoff_source_report_rows(
@@ -74,6 +77,43 @@ def _select_handoff_stages(stage_map: list, rows: list, selection: str) -> list[
74
77
  return nums
75
78
 
76
79
 
80
+ def _render_anchor_base_candidates(project_root: Path, inp) -> str:
81
+ """구현 기준 커밋을 담은 기본 후보가 없으면, 그 커밋에 있는 origin 브랜치를
82
+ Q2 의 첫 후보로 올린다. 다른 task 의 미병합 stage 위에 쌓인 task 는 기본
83
+ 후보 어느 것을 base 로 잡아도 그 task 의 커밋까지 PR 에 실린다."""
84
+ from okstra_ctl import worktree_registry
85
+ from okstra_ctl.work_categories import resolve_work_category
86
+ from okstra_ctl.worktree import is_ancestor
87
+
88
+ anchor = worktree_registry.get_implementation_base(
89
+ inp.project_id, inp.task_group, inp.task_id) or ""
90
+ if not anchor:
91
+ return ""
92
+ if any(is_ancestor(project_root, anchor, f"refs/remotes/origin/{name}")
93
+ for name in DEFAULT_RELEASE_BASES):
94
+ return ""
95
+ work_category = resolve_work_category(
96
+ inp.work_category, project_root=project_root,
97
+ task_group=inp.task_group, task_id=inp.task_id)
98
+ branches = anchor_base_branches(project_root, anchor,
99
+ work_category=work_category, task_id=inp.task_id)
100
+ held = branches["at"] + branches["containing"]
101
+ defaults = ", ".join(f"`{name}`" for name in DEFAULT_RELEASE_BASES)
102
+ lines = [f"- Implementation anchor base: `{anchor}` — no default candidate "
103
+ f"({defaults}) contains it, so a PR on any of them would also carry "
104
+ "the commits of the work this task was built on."]
105
+ if held:
106
+ lines.append(f"- Recommended release base (offer first in Q2): `{held[0]}`")
107
+ else:
108
+ lines.append("- Recommended release base: none — no origin branch holds the "
109
+ "anchor; push the branch it came from before release-handoff.")
110
+ lines.append("- origin branches at the anchor: "
111
+ + (", ".join(f"`{n}`" for n in branches["at"]) or "none"))
112
+ lines.append("- origin branches containing the anchor: "
113
+ + (", ".join(f"`{n}`" for n in branches["containing"]) or "none"))
114
+ return "\n".join(lines)
115
+
116
+
77
117
  def materialize_handoff_input(
78
118
  workspace_root: Path, project_root: Path, inp, *, install_hint: str,
79
119
  ) -> dict:
@@ -133,6 +173,7 @@ def materialize_handoff_input(
133
173
  "HANDOFF_STAGES": stages_csv,
134
174
  "HANDOFF_SOURCE_REPORTS": source_rows,
135
175
  "HANDOFF_CONDITIONS": source_conditions,
176
+ "HANDOFF_ANCHOR_BASE_CANDIDATES": _render_anchor_base_candidates(project_root, inp),
136
177
  })
137
178
  inp.brief_path = out
138
179
  return {"HANDOFF_STAGES": stages_csv}
@@ -123,6 +123,51 @@ def _merge_base_for(*, project_root, project_id, task_group, task_id,
123
123
  raise HandoffError(str(exc)) from exc
124
124
 
125
125
 
126
+ # 입력 문서 `Candidate Release Base Branches` 의 기본 후보와 같은 목록이다.
127
+ DEFAULT_RELEASE_BASES = ("staging", "preprod", "prod", "main", "dev")
128
+
129
+
130
+ def _origin_branches(project_root, flag: str, commit: str, own_prefix: str) -> List[str]:
131
+ """`git branch -r <flag> <commit>` 중 origin 브랜치 이름. 이 task 자신의 stage
132
+ 브랜치는 릴리스 base 가 될 수 없으므로 뺀다."""
133
+ res = _git(Path(project_root), "branch", "-r", "--format=%(refname)", flag, commit)
134
+ if res.returncode != 0:
135
+ return []
136
+ prefix = "refs/remotes/origin/"
137
+ names = [line.strip()[len(prefix):] for line in res.stdout.splitlines()
138
+ if line.strip().startswith(prefix)]
139
+ return [n for n in names if n != "HEAD"
140
+ and n != own_prefix and not n.startswith(own_prefix + "-")]
141
+
142
+
143
+ def anchor_base_branches(project_root, anchor: str, *, work_category: str,
144
+ task_id: str) -> Dict[str, List[str]]:
145
+ """구현 기준 커밋을 tip 으로 가진 origin 브랜치와 그 커밋을 담은 브랜치."""
146
+ own = compute_branch_name(work_category=work_category, task_id_segment=task_id)
147
+ at = _origin_branches(project_root, "--points-at", anchor, own)
148
+ containing = [n for n in _origin_branches(project_root, "--contains", anchor, own)
149
+ if n not in at]
150
+ return {"at": at, "containing": containing}
151
+
152
+
153
+ def _foreign_anchor_error(*, stage: int, base_branch: str, anchor: str,
154
+ branches: Dict[str, List[str]]) -> HandoffError:
155
+ held = branches["at"] + branches["containing"]
156
+ if held:
157
+ fix = f"re-run pr-plan with --base {held[0]}"
158
+ else:
159
+ fix = ("no origin branch holds that commit — push the branch it came "
160
+ f"from, or wait until it is merged into origin/{base_branch}")
161
+ return HandoffError(
162
+ f"stage {stage} would target origin/{base_branch}, but this task was "
163
+ f"implemented on {anchor}, which origin/{base_branch} does not contain "
164
+ "— the PR would also carry every commit between them, which belong to "
165
+ "other work. pr-plan derives stacking within this task only; a task "
166
+ "built on another task's unmerged branch needs that branch as its "
167
+ f"release base. origin branches at {anchor}: {branches['at'] or 'none'}; "
168
+ f"containing it: {branches['containing'] or 'none'}. {fix}")
169
+
170
+
126
171
  def _resolve_stage_base(
127
172
  *, stage: int, deps: List[int], selected: List[int], pr_covered: set,
128
173
  done: Dict[int, Dict[str, Any]], project_root, base_branch: str,
@@ -135,6 +180,13 @@ def _resolve_stage_base(
135
180
  if not is_ancestor(project_root, _done_head(done, d),
136
181
  f"origin/{base_branch}")]
137
182
  if not live:
183
+ if anchor_base and not is_ancestor(project_root, anchor_base,
184
+ f"origin/{base_branch}"):
185
+ raise _foreign_anchor_error(
186
+ stage=stage, base_branch=base_branch, anchor=anchor_base,
187
+ branches=anchor_base_branches(
188
+ project_root, anchor_base, work_category=work_category,
189
+ task_id=task_id))
138
190
  commit = _run_git(["rev-parse", f"origin/{base_branch}"],
139
191
  project_root).stdout.strip()
140
192
  return {"base_kind": "release-base", "base_branch": base_branch,
@@ -31,13 +31,14 @@
31
31
  `okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --stage <N>`
32
32
  Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, a run still holds the stage, the target branch already deleted, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra worktree for that stage no longer exists — if the lead's cwd was inside it, EVERY subsequent step (final-report authoring included) MUST use absolute paths or run from the MAIN worktree. Then route to the final-report self-review pass.
33
33
  2. **Release base branch** (only when the user picked `push + PR`) — present these options and capture exactly one:
34
+ - the `Recommended release base` from the input document's `## Candidate Release Base Branches`, when that section names one — first, marked recommended
34
35
  - `preprod`
35
36
  - `main`
36
37
  - `direct input` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
37
- This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage targets that stage's branch. The chosen base MUST NOT be one of the stage branches. If it is, re-ask.
38
+ This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage of this task targets that stage's branch. The release base MUST contain the task's implementation anchor base. `pr-plan` derives stacking inside this task only; when the task was built on another task's unmerged stage branch, the release base is that predecessor task's stage branch, which prepare names as the recommended base. The chosen base MUST NOT be one of this task's stage branches. If it is, re-ask.
38
39
  2p. **PR plan** (only when the user picked `push + PR`) — run
39
40
  `okstra handoff pr-plan --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <HANDOFF_STAGES> --base <chosen-base>`.
40
- It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
41
+ It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. When the error says the release base does not contain the implementation anchor, the error names the `--base` that does: show it verbatim and re-ask Q2 with that branch first. **Enforced:** `okstra_ctl.phases.release_handoff.operations._resolve_stage_base` refuses the plan. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
41
42
  A `merge-base` row means `pr-plan` created a branch merging that stage's predecessors. That branch is a PR base only — it is never a PR head — and it MUST be pushed before the PR that targets it.
42
43
  The output also carries `push_plan` and `warnings`. `push_plan` is every branch the PRs sit on, already in push order (each `merge-base` first, then the stage heads in dependency order), with the `commit` it must reach and an `action` of `create` (origin does not have it), `fast-forward` (origin is behind) or `up-to-date` (origin already has that commit — do not push it). GitHub resolves a PR's head and base by name on origin, so this list, not the local branch set, is what makes the stack real. A `warnings` row means `pr-plan` fast-forwarded a local branch to origin because origin was ahead: the commits origin adds are outside what this run verified. Quote every `warnings` row verbatim in Executed Commands and say so in the branch/PR explanation — the user is deciding whether to release commits this run did not verify. `pr-plan` refuses outright when a branch moved outside okstra or diverged from origin, since only a force push could deliver it.
43
44
  2b. **Pre-merge conflict probe** (only when the user picked `push + PR`) — before pushing, for each planned stage in dependency order:
@@ -48,7 +48,9 @@ taskType: "{{FM_TASK_TYPE}}"
48
48
 
49
49
  ## Candidate Release Base Branches
50
50
 
51
- The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them.
51
+ The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them. That derivation covers stacking inside this task only. The release base must contain the task's implementation anchor base; when this task was built on another task's unmerged stage branch, the release base is that branch, and `pr-plan` refuses a base that lacks the anchor.
52
+
53
+ {{HANDOFF_ANCHOR_BASE_CANDIDATES}}
52
54
 
53
55
  - Default options offered to the user: `staging` | `preprod` | `prod` | `main` | `dev` | custom input
54
56
  - Repo-specific preference, if known (e.g. `main` is the integration branch):
@@ -141,7 +141,7 @@ stateDiagram-v2
141
141
  User interaction is exactly three steps.
142
142
 
143
143
  1. Q1 action: `local checkout`, `push + PR`, `skip`
144
- 2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly
144
+ 2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly. When no default candidate contains the task's implementation anchor base (the task was built on another task's unmerged stage branch), prepare names the origin branch at the anchor as the recommended first option, and `pr-plan` refuses a release base that lacks the anchor
145
145
  3. Q3 PR title/body: every stage's draft in one question — `use as-is`, `edit then proceed`, `cancel`
146
146
 
147
147
  The merge-conflict probe happens only for `push + PR`, once per stage against that stage's own base.
@@ -84,6 +84,10 @@ def kill_group(pgid: int) -> bool:
84
84
  os.killpg(pgid, signal.SIGKILL)
85
85
  except ProcessLookupError:
86
86
  return False
87
+ except PermissionError:
88
+ # expected-miss: macOS 는 남은 구성원이 아직 거둬지지 않은 좀비뿐인 그룹에 EPERM 을
89
+ # 준다. 죽일 살아 있는 프로세스가 없다는 뜻이라, 끝난 워커의 결과를 버릴 이유가 아니다.
90
+ return False
87
91
  return True
88
92
 
89
93
 
@@ -18,6 +18,7 @@ from .clarification_items import (
18
18
  )
19
19
  from .final_report_schema import load_schema_version, validate
20
20
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
21
+ from .correction_round import correction_convergence_states, correction_round
21
22
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
22
23
  from .report_narrative import parse_narrative, task_narrative_errors
23
24
  from .report_synthesis_packet import (
@@ -98,6 +99,32 @@ def _read_json(row: ReportInputPath) -> dict[str, Any]:
98
99
  return value
99
100
 
100
101
 
102
+ def _correction_round_convergence(
103
+ initial: ReportInputPath, cross: Mapping[str, Any],
104
+ ) -> tuple[list[dict[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
105
+ """Each correction round's re-convergence, projected like the initial one, and the
106
+ evidence it promotes labelled `convergence <scope>`."""
107
+ rows: list[dict[str, Any]] = []
108
+ promoted: list[tuple[str, Mapping[str, Any]]] = []
109
+ consensus = len(cross["consensus"])
110
+ differences = len(cross["differences"])
111
+ for scope, path in correction_convergence_states(initial.path):
112
+ row = ReportInputPath(initial.key, initial.owner, path)
113
+ try:
114
+ result = project_convergence(
115
+ _read_json(row),
116
+ first_consensus=consensus + 1, first_difference=differences + 1,
117
+ )
118
+ except ReportProjectionError as exc:
119
+ _fail(row.owner, path, "$", exc)
120
+ projected = result["crossVerification"]
121
+ consensus += len(projected["consensus"])
122
+ differences += len(projected["differences"])
123
+ rows.append({"round": correction_round(scope), **projected})
124
+ promoted.extend((f"convergence {scope}", item) for item in result["promotedEvidence"])
125
+ return rows, promoted
126
+
127
+
101
128
  def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
102
129
  try:
103
130
  return {row.key: row for row in report_input_paths(project_root, manifest)}
@@ -488,6 +515,11 @@ def _compose(
488
515
  data = {"schemaVersion": "3.0", **narrative, **execution}
489
516
  data["tokenUsage"] = usage
490
517
  data["crossVerification"] = convergence_data["crossVerification"]
518
+ rounds, round_evidence = _correction_round_convergence(
519
+ inputs["convergence"], data["crossVerification"]
520
+ )
521
+ if rounds:
522
+ data["crossVerification"]["correctionRounds"] = rounds
491
523
  data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
492
524
  # 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
493
525
  for reason in validate_blocked_answer_channel(data):
@@ -504,8 +536,13 @@ def _compose(
504
536
  if scope:
505
537
  data["verificationScope"] = scope
506
538
  data["agentActivity"] = activities
507
- data["evidence"] = {"primary": _promoted_evidence(convergence_data)}
508
- data["missingInformation"] = _census_missing_information(project_root, manifest)
539
+ data["evidence"] = {"primary": _promoted_evidence([
540
+ *(("convergence", item) for item in convergence_data["promotedEvidence"]),
541
+ *round_evidence,
542
+ ])}
543
+ data["missingInformation"] = _missing_information(
544
+ project_root, manifest, convergence_data["criticUnverified"],
545
+ )
509
546
  # 스냅샷 복사가 _attach_metadata 보다 먼저다 — frontmatter 가
510
547
  # selectedDirectionRef.snapshotPath 를 읽는다.
511
548
  _apply_selected_direction_snapshot(data, project_root, manifest)
@@ -529,16 +566,16 @@ def _attach_discussion(data: dict[str, Any], project_root: Path, manifest: Mappi
529
566
  _fail("discussion-record", store.records_path, "discussion", exc)
530
567
 
531
568
 
532
- def _census_missing_information(
533
- project_root: Path, manifest: Mapping[str, Any],
569
+ def _missing_information(
570
+ project_root: Path, manifest: Mapping[str, Any], critic_unverified: list[dict[str, Any]],
534
571
  ) -> list[dict[str, Any]]:
535
- """Coverage-census warnings are the only rows this field carries under the
536
- v3 contract, so they number from `R-001`."""
537
- state = read_census_state(project_root, manifest)
538
- if state is None:
539
- return []
572
+ """Coverage-census warnings and unjudged critic gaps are the only rows this
573
+ field carries under the v3 contract, so they number from `R-001`."""
540
574
  _project_id, _task_group, task_id = _identity(manifest)
541
- rows = census_warning_rows(state, ticket_id=task_id or "unknown")
575
+ ticket_id = task_id or "unknown"
576
+ state = read_census_state(project_root, manifest)
577
+ rows = [] if state is None else census_warning_rows(state, ticket_id=ticket_id)
578
+ rows += [{**row, "ticketId": row["ticketId"] or ticket_id} for row in critic_unverified]
542
579
  return [{"id": f"R-{index:03d}", **row} for index, row in enumerate(rows, start=1)]
543
580
 
544
581
 
@@ -717,16 +754,16 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
717
754
  }
718
755
 
719
756
 
720
- def _promoted_evidence(convergence: Mapping[str, Any]) -> list[dict[str, Any]]:
757
+ def _promoted_evidence(rows: list[tuple[str, Mapping[str, Any]]]) -> list[dict[str, Any]]:
721
758
  return [
722
759
  {
723
760
  "id": f"E-{index:03d}",
724
761
  "ticketId": row["ticketId"],
725
762
  "evidence": row["evidence"],
726
763
  "sourceItems": row["sourceItems"],
727
- "source": "convergence",
764
+ "source": source,
728
765
  }
729
- for index, row in enumerate(convergence.get("promotedEvidence") or [], start=1)
766
+ for index, (source, row) in enumerate(rows, start=1)
730
767
  ]
731
768
 
732
769
 
@@ -403,8 +403,10 @@ def anchor_index(
403
403
  for row_id in sorted(found)
404
404
  if isinstance(row_id, str) and _anchorable(row_id)
405
405
  }
406
+ cross = data.get("crossVerification") if isinstance(data.get("crossVerification"), dict) else {}
407
+ rounds = [cross, *(r for r in cross.get("correctionRounds") or [] if isinstance(r, dict))]
406
408
  for block in ("consensus", "differences"):
407
- for row in _dig(data, ("crossVerification", block)):
409
+ for row in (row for scope in rounds for row in scope.get(block) or []):
408
410
  row_id = row.get("id") if isinstance(row, dict) else None
409
411
  if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
410
412
  index[row_id] = f"id-xv-{row_id}"
@@ -261,8 +261,14 @@ def _source_items(finding: Mapping[str, Any]) -> list[str]:
261
261
  return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
262
262
 
263
263
 
264
- def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
265
- """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다."""
264
+ def project_convergence(
265
+ state: Mapping[str, Any], *, first_consensus: int = 1, first_difference: int = 1,
266
+ ) -> dict[str, Any]:
267
+ """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다.
268
+
269
+ 정정 라운드 재수렴은 초기 행 다음 번호부터 매긴다 — 행 id 가 리포트 앵커라 한 리포트
270
+ 안에서 겹치면 안 된다.
271
+ """
266
272
  consensus = []
267
273
  differences = []
268
274
  promoted = []
@@ -274,7 +280,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
274
280
  ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
275
281
  if finding.get("classification") in {"full-consensus", "partial-consensus"}:
276
282
  row = {
277
- "id": f"CV-{len(consensus) + 1:03d}",
283
+ "id": f"CV-{len(consensus) + first_consensus:03d}",
278
284
  "ticketId": ticket_id,
279
285
  "statement": _text(finding.get("summary"), "Converged finding"),
280
286
  "sourceItems": _source_items(finding),
@@ -298,7 +304,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
298
304
  "position": "origin" if worker == finding.get("originWorker") else "dissent",
299
305
  })
300
306
  differences.append({
301
- "id": f"D-{len(differences) + 1:03d}",
307
+ "id": f"D-{len(differences) + first_difference:03d}",
302
308
  "ticketId": ticket_id,
303
309
  "disagreement": _text(finding.get("summary"), "Unresolved difference"),
304
310
  "workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
@@ -311,5 +317,41 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
311
317
  "differences": differences,
312
318
  },
313
319
  "promotedEvidence": promoted,
320
+ "criticUnverified": _critic_unverified(state),
314
321
  }
322
+ critic = (state.get("config") or {}).get("critic")
323
+ if isinstance(critic, Mapping):
324
+ result["crossVerification"]["criticGaps"] = {
325
+ "proposed": critic.get("gapsProposed", 0),
326
+ "merged": critic.get("gapsMerged", 0),
327
+ "unverified": critic.get("gapsUnverified", 0),
328
+ "duplicate": critic.get("gapsDuplicate", 0),
329
+ }
315
330
  return result
331
+
332
+
333
+ def _critic_unverified(state: Mapping[str, Any]) -> list[dict[str, Any]]:
334
+ """미판정 gap 하나당 Missing Information 행 하나 — id 와 기본 ticket 은 조립이 채운다."""
335
+ ledger = (state.get("criticVerification") or {}).get("gaps") or []
336
+ tickets = {
337
+ gap.get("gapId"): (gap.get("ticketIds") or [None])[0]
338
+ for gap in ledger
339
+ if isinstance(gap, Mapping)
340
+ }
341
+ rows = []
342
+ for gap in state.get("unverifiedGaps") or []:
343
+ if not isinstance(gap, Mapping):
344
+ continue
345
+ gap_id = _text(gap.get("gapId"), "unknown")
346
+ rows.append({
347
+ "ticketId": tickets.get(gap_id),
348
+ "item": f"Coverage critic gap `{gap_id}` was never verified: "
349
+ f"{_text(gap.get('summary'), 'no summary')}",
350
+ "risk": (
351
+ f"Verification did not complete ({_text(gap.get('reason'), 'unknown reason')}), "
352
+ "so no analyser has judged this gap; it is neither a finding nor a rejection."
353
+ ),
354
+ "owner": "Okstra lead",
355
+ "source": "critic-unverified",
356
+ })
357
+ return rows
@@ -14,6 +14,7 @@ from typing import Any, Mapping
14
14
  from okstra_token_usage.blocks import accounting_workers
15
15
 
16
16
  from .analysis_packet import reference_source_extracts
17
+ from .correction_round import correction_convergence_states
17
18
  from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
18
19
  from .phases.implementation_option_selection.authoring import (
19
20
  requirement_instructions as selection_requirement_instructions,
@@ -251,6 +252,17 @@ class ReportSynthesisPacket:
251
252
  ]
252
253
  if self.task_type == "implementation-planning":
253
254
  return self._planning_requirement_instructions()
255
+ if self.task_type == "implementation" and any(
256
+ source.label.startswith(f"{CONVERGENCE_STATE_LABEL} (correction-r")
257
+ for source in self.sources
258
+ ):
259
+ return [
260
+ "Each `Convergence state (correction-r<N>)` source is correction round N's "
261
+ "re-convergence of the verifiers' re-verification; the plain `Convergence "
262
+ "state` is the initial round's. Describe the stage's open findings from the "
263
+ "last round's re-convergence, not the initial one; report what each round "
264
+ "fixed through `implementation.correctionRounds`.",
265
+ ]
254
266
  if self.task_type != "implementation-option-selection":
255
267
  return []
256
268
  return selection_requirement_instructions(
@@ -371,6 +383,7 @@ _SOURCE_FIELDS = (
371
383
  ("Final report schema", "orchestrator", "instructionSet", "finalReportSchemaPath", True),
372
384
  ("Convergence state", "convergence", "run", "convergenceStatePath", True),
373
385
  )
386
+ CONVERGENCE_STATE_LABEL = "Convergence state"
374
387
 
375
388
 
376
389
  _READ_CHUNK_BYTES = 16_000
@@ -514,7 +527,7 @@ def _source_markdown(
514
527
  brief = next((item.content.strip() for item in sources if item.label == "Task brief"), "")
515
528
  if len(brief.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
516
529
  content = content.replace(brief, 'Read the complete "Source: Task brief" above.')
517
- if source.label == "Convergence state":
530
+ if source.label.startswith(CONVERGENCE_STATE_LABEL):
518
531
  content = _convergence_view(content)
519
532
  if content != source.content.rstrip("\n"):
520
533
  view = [
@@ -815,6 +828,14 @@ def _source_specs(
815
828
  path = str(Path(instruction_set) / "final-report-schema.json")
816
829
  if path or required:
817
830
  specs.append((label, owner, path, required))
831
+ if label == CONVERGENCE_STATE_LABEL and path:
832
+ specs.extend(
833
+ (f"{CONVERGENCE_STATE_LABEL} ({scope})", owner,
834
+ _relative(project_root, round_final), True)
835
+ for scope, round_final in correction_convergence_states(
836
+ _resolve(project_root, path)
837
+ )
838
+ )
818
839
  if carried_source:
819
840
  specs.append((CARRIED_STAGES_LABEL, "orchestrator", carried_source, True))
820
841
  specs.extend(_user_response_specs(project_root, narrative_path))
@@ -3063,6 +3063,7 @@ def _write_instruction_set_sources(
3063
3063
  )
3064
3064
  if inp.clarification_response_path else ""
3065
3065
  ),
3066
+ verification_target_path=ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
3066
3067
  )
3067
3068
  if inp.task_type == "technical-verification":
3068
3069
  packet += materialize_technical_input_packet(
@@ -3370,10 +3371,11 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
3370
3371
  f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
3371
3372
  f"{inp.task_type}")
3372
3373
  workflow = existing.get("workflow") or {}
3373
- if workflow.get("lastCompletedPhase") != "release-handoff":
3374
+ if not fix_cycles.delivery_completed(workflow):
3374
3375
  raise PrepareError(
3375
- "--fix-cycle yes 는 release-handoff 까지 완료된 task 에만 허용됩니다 "
3376
- "(workflow.lastCompletedPhase 확인)")
3376
+ "--fix-cycle yes 는 release-handoff 로 전달까지 끝난 task 에만 허용됩니다 "
3377
+ "(workflow.lastCompletedPhase 가 release-handoff 이고, skip·미전달 stage 로 "
3378
+ "다음 phase 가 release-handoff 로 남아 있지 않아야 함)")
3377
3379
  brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
3378
3380
  fix_cycles.append_opened(
3379
3381
  task_root, target_report=str(existing.get("latestReportRecordPath", "")),
@@ -3385,7 +3387,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3385
3387
  """fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
3386
3388
 
3387
3389
  - lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
3388
- lastCompletedPhase 가 release-handoff 면 닫는다. manifest 는 prepare 가
3390
+ lastCompletedPhase 가 release-handoff 면 닫는다. 다음 phase 포인터가 다시
3391
+ release-handoff 를 가리키면(skip·미전달 stage) 닫지 않는다. manifest 는 prepare 가
3389
3392
  이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
3390
3393
  - opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
3391
3394
  PrepareError.
@@ -3398,7 +3401,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3398
3401
 
3399
3402
  open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
3400
3403
 
3401
- if open_c and workflow.get("lastCompletedPhase") == "release-handoff":
3404
+ if open_c and fix_cycles.delivery_completed(workflow):
3402
3405
  rows = fix_cycles.read_rows(task_root)
3403
3406
  handoff_attached = any(
3404
3407
  r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
@@ -1,4 +1,4 @@
1
- """Best-effort git reconciliation shared by stage prepare flows."""
1
+ """Git reconciliation shared by stage prepare flows."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import sys
@@ -31,3 +31,51 @@ def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
31
31
  )
32
32
  except Exception as exc:
33
33
  print(f"git-reconcile skipped: {exc}", file=sys.stderr)
34
+
35
+
36
+ class StaleStageRecordsError(RuntimeError):
37
+ """A recorded stage SHA no longer matches git and only the user can say which commit counts."""
38
+
39
+
40
+ def require_reconciled_stages(
41
+ inp: Any, plan_run_root: Path, *, stage: int | None = None,
42
+ ) -> None:
43
+ """Apply auto items, then refuse while a confirm item remains.
44
+
45
+ A prepare that restores, rebases or merges stage branches calls this first:
46
+ building on a recorded commit the branch no longer carries integrates work
47
+ nobody verified. ``stage`` narrows the check to that stage's done row.
48
+ """
49
+ from .git_reconcile import _git, classify_task, guidance
50
+
51
+ auto_reconcile_best_effort(inp, plan_run_root)
52
+ # A project prepared without git (worktree status skipped-not-git) has no
53
+ # history the recorded SHAs could disagree with.
54
+ if _git(Path(inp.project_root), "rev-parse", "--git-dir").returncode != 0:
55
+ return
56
+ coordinates = dict(
57
+ project_id=inp.project_id,
58
+ task_group=inp.task_group,
59
+ task_id=inp.task_id,
60
+ work_category=inp.work_category,
61
+ )
62
+ pending = [
63
+ item
64
+ for item in classify_task(
65
+ project_root=Path(inp.project_root),
66
+ plan_run_root=plan_run_root,
67
+ **coordinates,
68
+ )
69
+ if item.classification == "confirm"
70
+ and (stage is None or item.stage == stage)
71
+ ]
72
+ if not pending:
73
+ return
74
+ lines = "\n".join(
75
+ f" {f'stage {item.stage}' if item.kind == 'done' else 'anchor'}: "
76
+ f"{item.reason} (recorded {item.recorded})"
77
+ for item in pending
78
+ )
79
+ raise StaleStageRecordsError(
80
+ f"{lines}\n{guidance(plan_run_root=plan_run_root, **coordinates)}"
81
+ )
@@ -215,14 +215,14 @@ def _stage_plan_for_confirmation(
215
215
 
216
216
 
217
217
  def _fix_cycle_confirm_required(state: WizardState) -> bool:
218
- """완료(release-handoff) task 에 entry phase 로 재진입하고, 아직 열린 fix
219
- cycle 이 없을 때만 묻는다."""
218
+ """전달까지 끝난(release-handoff) task 에 entry phase 로 재진입하고, 아직
219
+ 열린 fix cycle 이 없을 때만 묻는다."""
220
220
  if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
221
221
  return False
222
222
  task_root = task_dir(Path(state.project_root),
223
223
  state.task_group, state.task_id)
224
224
  workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
225
- if workflow.get("lastCompletedPhase") != "release-handoff":
225
+ if not fix_cycles.delivery_completed(workflow):
226
226
  return False
227
227
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
228
228