okstra 0.205.0 → 0.206.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 (53) hide show
  1. package/dist/commands/lifecycle/install.mjs +1 -0
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +6 -6
  4. package/docs/contributor-change-matrix.md +2 -2
  5. package/docs/project-structure-overview.md +8 -5
  6. package/package.json +2 -3
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  9. package/runtime/prompts/lead/phase-routing.md +18 -0
  10. package/runtime/python/okstra_ctl/contract_graph.py +75 -10
  11. package/runtime/python/okstra_ctl/doctor.py +13 -3
  12. package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
  13. package/runtime/python/okstra_ctl/incremental_carry.py +19 -2
  14. package/runtime/python/okstra_ctl/next_phase.py +2 -2
  15. package/runtime/python/okstra_ctl/paths.py +14 -0
  16. package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
  17. package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
  18. package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
  19. package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
  20. package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
  21. package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
  22. package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
  23. package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
  24. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
  25. package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
  26. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -0
  27. package/runtime/python/okstra_ctl/profile_show.py +7 -1
  28. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  29. package/runtime/python/okstra_ctl/report_assembly.py +3 -3
  30. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  31. package/runtime/python/okstra_ctl/report_html/router.py +9 -33
  32. package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
  33. package/runtime/python/okstra_ctl/report_views.py +17 -1
  34. package/runtime/python/okstra_ctl/run.py +46 -154
  35. package/runtime/python/okstra_ctl/stage_targets.py +9 -286
  36. package/runtime/python/okstra_ctl/user_response.py +199 -4
  37. package/runtime/python/okstra_ctl/verification_target.py +1 -1
  38. package/runtime/python/okstra_ctl/wizard/state.py +6 -2
  39. package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
  40. package/runtime/skills/okstra-user-response/SKILL.md +23 -4
  41. package/runtime/validators/validate-run.py +59 -187
  42. package/runtime/validators/validate_session_conformance.py +70 -1
  43. package/docs/task-process/README.md +0 -82
  44. package/docs/task-process/common-flow.md +0 -173
  45. package/docs/task-process/error-analysis.md +0 -103
  46. package/docs/task-process/implementation-option-selection.md +0 -70
  47. package/docs/task-process/implementation-planning.md +0 -180
  48. package/docs/task-process/implementation.md +0 -226
  49. package/docs/task-process/release-handoff.md +0 -220
  50. package/docs/task-process/requirements-discovery.md +0 -113
  51. /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
  52. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
  53. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.md +0 -0
@@ -1,6 +1,6 @@
1
1
  """Read the prepared final-verification target snapshot.
2
2
 
3
- `run.write_verification_target_snapshot` writes it; two consumers read it —
3
+ `phases.final_verification.entry.write_verification_target_snapshot` writes it; two consumers read it —
4
4
  report assembly, which records the run's `verificationScope`, and `validate-run`,
5
5
  which re-checks the published report against it. The digest rule lived only in
6
6
  the validator, so a second reader would have been a second implementation of it;
@@ -14,6 +14,7 @@ from okstra_ctl.role_requirements import (
14
14
  load_role_profile,
15
15
  )
16
16
  from okstra_ctl.ids import slugify_task_segment
17
+ from okstra_ctl.phases.catalog import PhaseAssetError, profile_markdown
17
18
 
18
19
  from .ids import (
19
20
  PICK_TYPE_CUSTOM,
@@ -307,7 +308,10 @@ def _slug_or_die(value: str, field_name: str) -> str:
307
308
  # ---- Roster / profile helpers -------------------------------------------
308
309
 
309
310
  def _profile_path(workspace_root: Path, task_type: str) -> Path:
310
- return workspace_root / "prompts" / "profiles" / f"{task_type}.md"
311
+ try:
312
+ return profile_markdown(workspace_root, task_type)
313
+ except PhaseAssetError as exc:
314
+ raise WizardError(str(exc)) from exc
311
315
 
312
316
 
313
317
  _V2_STATE_FIELDS = (
@@ -366,7 +370,7 @@ def _role_selection_enabled(state: WizardState) -> bool:
366
370
  return False
367
371
  try:
368
372
  load_role_profile(_profile_path(Path(state.workspace_root), state.task_type))
369
- except (OSError, RoleProfileError):
373
+ except (OSError, RoleProfileError, WizardError):
370
374
  return False
371
375
  return True
372
376
 
@@ -29,6 +29,11 @@ from okstra_ctl.user_response import PlanDecisionRecord, parse_plan_decision
29
29
  from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
30
30
  from okstra_ctl import fix_cycles
31
31
  from okstra_ctl.paths import task_dir, task_runs_dir
32
+ from okstra_ctl.phases.final_verification.wizard import (
33
+ StageAnswerError,
34
+ validate_stage_answer,
35
+ whole_task_verification_allowed,
36
+ )
32
37
  from okstra_project.state import read_task_manifest
33
38
 
34
39
  from .ids import (
@@ -422,7 +427,10 @@ def _whole_task_allowed(
422
427
  return False
423
428
  if done is None:
424
429
  done = _stage_lifecycle_snapshot(state, stages).done_stages
425
- return all(s.stage_number in done for s in stages)
430
+ return whole_task_verification_allowed(
431
+ stage_numbers=tuple(stage.stage_number for stage in stages),
432
+ done_stages=done,
433
+ )
426
434
 
427
435
 
428
436
  def _build_approved_plan_pick(state: WizardState) -> Prompt:
@@ -623,20 +631,14 @@ def _impl_stage_marker(t, lifecycle) -> str:
623
631
  def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
624
632
  if state.task_type == "implementation":
625
633
  return _submit_impl_stage_pick(state, answer)
626
- # final-verification: 단일선택 유지 (whole-task 또는 단일 정수; auto 불가)
627
- if not answer:
628
- raise WizardError("value required")
629
- if answer == WHOLE_TASK_STAGE:
630
- if not _whole_task_allowed(state):
631
- raise WizardError(
632
- "whole-task verification requires final-verification "
633
- "with all stages done")
634
- else:
635
- try:
636
- int(answer)
637
- except ValueError:
638
- raise WizardError(
639
- f"answer must be whole-task or a stage number, got {answer!r}")
634
+ try:
635
+ validate_stage_answer(
636
+ answer,
637
+ whole_task_token=WHOLE_TASK_STAGE,
638
+ whole_task_allowed=answer == WHOLE_TASK_STAGE and _whole_task_allowed(state),
639
+ )
640
+ except StageAnswerError as exc:
641
+ raise WizardError(str(exc)) from exc
640
642
  state.selected_stage = answer
641
643
  return f"stage: {answer}"
642
644
 
@@ -6,7 +6,7 @@ description: >-
6
6
 
7
7
  # OKSTRA User Response
8
8
 
9
- Use this skill for open `C-*` clarification items and explicit plan decisions. The user alone selects or writes every answer. Never infer an answer or approval.
9
+ Use this skill for open `C-*` clarification items, explicit plan decisions, and the implementation direction a finished `implementation-option-selection` comparison awaits. The user alone selects or writes every answer. Never infer an answer or approval.
10
10
 
11
11
  The model-facing commands are fixed text reads and typed transaction writes:
12
12
 
@@ -17,6 +17,7 @@ The model-facing commands are fixed text reads and typed transaction writes:
17
17
  | `user-response begin` | Open a sidecar transaction for one report identity. |
18
18
  | `user-response answer` | Add or replace one validated clarification answer. |
19
19
  | `user-response plan-decision` | Record an explicit plan decision in the transaction. |
20
+ | `user-response direction` | Record the implementation direction the user picked in the transaction. |
20
21
  | `user-response legacy-report-authoring` | Record legacy report-authoring permission for report contract 2.0 only. |
21
22
  | `user-response finalize` | Atomically merge and publish the user-owned sidecar. |
22
23
 
@@ -71,7 +72,7 @@ Never invent a picker function. Never ask the user to type a number when the nat
71
72
  okstra user-response list-view --home <resolved-home> --project <projectId> --limit 3
72
73
  ```
73
74
 
74
- The view gives `Task key`, `Task type`, `Report`, open-item counts, and readability status. If the count is zero, answer `No task has open clarification items.` and stop. Do not continue with an unreadable entry.
75
+ The view gives `Task key`, `Task type`, `Report`, open-item counts, `Direction selection required`, and readability status. If the count is zero, answer `No task has open clarification items.` and stop. Do not continue with an unreadable entry.
75
76
 
76
77
  Present up to three task choices through the host picker. A host free-text row or unmatched next message is the report path or task key.
77
78
 
@@ -89,6 +90,8 @@ Contract 3.0 options also expose `reach` and `scopeEffects`. Contract 3.0 approv
89
90
 
90
91
  When an axis says `not stated in the report`, repeat that text. Do not infer missing report-owned impact. The skill must **never invent it**.
91
92
 
93
+ When the view prints `Direction candidates:`, the comparison awaits a direction. Each `Direction option N:` row gives the candidate id and name, `Recommended`, `Goal`, `Core mechanism`, and `Selectable`. The `Direction picker:` block lists only selectable candidates, recommended first, each with its `Option number`.
94
+
92
95
  ## Step 2b: Investigate cited context before asking
93
96
 
94
97
  Do not present a picker from the raw field dump. For each still-open item, read the investigation list the view printed:
@@ -127,9 +130,15 @@ Use the displayed values to confirm the user's choice. Do not copy a predefined
127
130
 
128
131
  Copy `kind` from the view. A `reframe` does not satisfy the gate. If the user asks what an item means, explain from the view plus the cited files already read, then ask the same item again.
129
132
 
133
+ ## Step 3b: Ask for the direction
134
+
135
+ Only when `Current direction selection: none` and `Direction picker:` has rows. Ask one single-select question through the host picker, after any clarification whose answer would change the choice. The body says that the comparison is finished, that the chosen candidate becomes the input of `implementation-planning`, and that planning cannot start until one is chosen. Copy each `Direction picker:` `- Label:` / `Description:` pair in order. Remember the `Option number` of the picked row. A `Selectable: no - <reason>` candidate is never offered; when the user asks for it, state the reason.
136
+
137
+ When the user adds a note or a constraint for the planner, keep their words verbatim for Step 6.
138
+
130
139
  ## Step 4: Confirm the complete response
131
140
 
132
- Echo each clarification ID, kind, disposition, value, and rationale. Include any explicit plan decision or legacy report-authoring decision. Ask through the host picker, two options:
141
+ Echo each clarification ID, kind, disposition, value, and rationale. Include any explicit plan decision, direction, or legacy report-authoring decision. Ask through the host picker, two options:
133
142
 
134
143
  1. `Record as shown` (Recommended)
135
144
  2. `Change an answer`
@@ -150,7 +159,7 @@ For a predefined option, pass only its one-based number from the fixed view:
150
159
  okstra user-response answer --transaction <transaction> --clarification-id <C-NNN> --kind <kind> --option-number <N>
151
160
  ```
152
161
 
153
- Every value, rationale, and reason body file must be a regular file under `<projectRoot>/.okstra/tmp/user-response/`; do not use an external file or a symbolic link. For a direct user answer, write the exact value there. Write the rationale to a separate Markdown file only when present. Then run:
162
+ Every value, rationale, and reason body file — and every direction note or constraints file — must be a regular file under `<projectRoot>/.okstra/tmp/user-response/`; do not use an external file or a symbolic link. For a direct user answer, write the exact value there. Write the rationale to a separate Markdown file only when present. Then run:
154
163
 
155
164
  ```bash
156
165
  okstra user-response answer --transaction <transaction> --clarification-id <C-NNN> --kind <kind> --disposition <disposition> --value-file <value.md> [--rationale-file <rationale.md>]
@@ -174,6 +183,14 @@ okstra user-response plan-decision --transaction <transaction> --status <revisio
174
183
 
175
184
  Never infer a plan decision from the user's tone.
176
185
 
186
+ When the user picked a direction, pass the picked row's `Option number`. Write a note or constraints the user stated to separate Markdown files in that same temporary directory, one constraint per line:
187
+
188
+ ```bash
189
+ okstra user-response direction --transaction <transaction> --option-number <N> [--note-file <note.md>] [--constraints-file <constraints.md>]
190
+ ```
191
+
192
+ The command refuses a candidate the planning gate would refuse and a report that does not await a direction.
193
+
177
194
  Only for a report whose fixed view says `Report contract: 2.0`, an explicit legacy report-authoring decision may be recorded. A reason file in that same temporary directory is always required:
178
195
 
179
196
  ```bash
@@ -194,6 +211,8 @@ Leave this guidance in the final answer:
194
211
 
195
212
  > This answer was recorded in the `user-responses/` sidecar (`<sidecar path>`). Re-running this task with `/okstra-run` attaches the answer to the next eligible phase.
196
213
 
214
+ When a direction was recorded, add: start `implementation-planning` with `/okstra-run` and pick this report in the wizard's direction-report step.
215
+
197
216
  ## Output rules
198
217
 
199
218
  - Keep responses in the user's language.
@@ -101,6 +101,10 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
101
101
  stages_for_clarification,
102
102
  )
103
103
  from okstra_ctl import next_phase # noqa: E402
104
+ from okstra_ctl.phases.final_verification.validation import ( # noqa: E402
105
+ validate_final_verification_content,
106
+ validate_verification_target_match,
107
+ )
104
108
  from okstra_ctl.clarification_items import ( # noqa: E402
105
109
  APPROVAL_BLOCKS,
106
110
  clarification_disposition,
@@ -1429,6 +1433,29 @@ def _dispatch_roster_key(row: Mapping[str, Any]) -> str:
1429
1433
  return ""
1430
1434
 
1431
1435
 
1436
+ def _dispatch_row_paths(team_state: Mapping[str, Any], worker_id: str) -> tuple[str, str]:
1437
+ """이 로스터 워커의 dispatch 행이 기록한 (promptPath, resultPath).
1438
+
1439
+ v2 assignment 로 띄운 워커는 로스터 행의 경로가 빈 채로 남는다.
1440
+ `workers[].promptPath` 는 첫 v1 dispatch 하나만 가리키는 필드이고 v2 행은
1441
+ 그것을 채우지 않는다(`okstra_ctl.dispatch_state.worker_dispatch_records` 의
1442
+ 주석, 2026-09-08 실측). 그래서 acceptance critic 처럼 v2 로만 띄우는 워커는
1443
+ 프롬프트와 결과 파일이 디스크에 그대로 있는데도 "promptPath 가 없다",
1444
+ "결과 파일이 없다" 로 보고됐다(2026-09-24, jobs final-verification 002).
1445
+ 사실은 dispatch 행에 있으므로 그리로 폴백한다.
1446
+ """
1447
+ prompt = ""
1448
+ result = ""
1449
+ for row in team_state.get("workerDispatches") or ():
1450
+ if not isinstance(row, Mapping) or _dispatch_roster_key(row) != worker_id:
1451
+ continue
1452
+ prompt = prompt or str(row.get("promptPath") or "")
1453
+ result = result or str(
1454
+ row.get("workerResultPath") or row.get("resultPath") or ""
1455
+ )
1456
+ return prompt, result
1457
+
1458
+
1432
1459
  def _validate_cmux_workers_were_dispatched_by_okstra(
1433
1460
  team_state: dict,
1434
1461
  workers: list,
@@ -1628,14 +1655,26 @@ def validate_team_state(
1628
1655
  f"{role} must use modelExecutionValue `{expected_model_execution_value}`"
1629
1656
  )
1630
1657
 
1658
+ # 경로는 로스터 행이 소유하지만 v2 dispatch 는 그 행을 채우지 않는다.
1659
+ # 비어 있을 때만 dispatch 행에서 읽는다 — 로스터 행에 값이 있으면 그것이
1660
+ # 대조 대상이다.
1661
+ roster_prompt = str(worker.get("promptPath") or "")
1662
+ roster_result = str(worker.get("resultPath") or "")
1663
+ if not roster_prompt or not roster_result:
1664
+ fallback_prompt, fallback_result = _dispatch_row_paths(
1665
+ team_state, str(worker.get("workerId") or "")
1666
+ )
1667
+ roster_prompt = roster_prompt or fallback_prompt
1668
+ roster_result = roster_result or fallback_result
1669
+
1631
1670
  expected_result_relative = expected.get("resultPath")
1632
- result_relative = worker.get("resultPath", "")
1671
+ result_relative = roster_result
1633
1672
  if expected_result_relative and result_relative != expected_result_relative:
1634
1673
  failures.append(
1635
1674
  f"{role} must use resultPath `{expected_result_relative}`"
1636
1675
  )
1637
1676
  expected_prompt_relative = expected.get("promptPath")
1638
- prompt_relative = worker.get("promptPath", "")
1677
+ prompt_relative = roster_prompt
1639
1678
  if expected_prompt_relative and prompt_relative != expected_prompt_relative:
1640
1679
  failures.append(
1641
1680
  f"{role} must use promptPath `{expected_prompt_relative}`"
@@ -5341,19 +5380,6 @@ def _consumers_rows(report_path: Path) -> list[dict] | None:
5341
5380
  return rows
5342
5381
 
5343
5382
 
5344
- # 이 스냅샷의 다이제스트 규칙과 파싱은 `okstra_ctl.verification_target` 하나가
5345
- # 쥔다. 조립도 같은 파일을 읽어 `verificationScope` 를 기록하므로, 사본을 두면
5346
- # 규칙이 갈리는 순간 한쪽이 정상 target 을 변조로 판정한다.
5347
- from okstra_ctl.verification_target import ( # noqa: E402
5348
- TARGET_FIELD_RES as _TARGET_FIELD_RES,
5349
- read_verification_target as _read_verification_target_impl,
5350
- )
5351
-
5352
-
5353
- def _read_verification_target(project_root: Path, relative: str) -> dict | None:
5354
- return _read_verification_target_impl(project_root, relative)
5355
-
5356
-
5357
5383
  _PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
5358
5384
 
5359
5385
 
@@ -5713,66 +5739,6 @@ def _validate_verifier_command_log_is_read_only(
5713
5739
 
5714
5740
 
5715
5741
 
5716
- def _validate_verification_target_match(
5717
- data: dict,
5718
- run_manifest: dict,
5719
- project_root: Path,
5720
- failures: list[str],
5721
- ) -> None:
5722
- """The verification report must mirror the target it was prepared against.
5723
-
5724
- `verificationScope`, the worktree, and the base/head refs were entirely
5725
- self-declared: the schema required the fields to exist but nothing compared
5726
- them to the digest-verified snapshot written at prep time. That matters
5727
- because both `handoff.compute_eligibility` and the `release-handoff`
5728
- routing check read `verificationScope` — a single-stage run that writes
5729
- `whole-task` passes both, and an `accepted` verdict can be rendered against
5730
- a worktree or head nobody verified.
5731
- """
5732
- # 최상위 `verificationTargetPath` 가 run 매니페스트의 실물 키다(render.py).
5733
- # `instructionSet` 블록은 active-run-context 의 것이라 여기서 읽으면 검사가
5734
- # 통째로 건너뛰어졌다(실측 2026-09-06, dev-10626 final-verification 001).
5735
- relative = str(run_manifest.get("verificationTargetPath") or "").strip()
5736
- if not relative:
5737
- return
5738
- target = _read_verification_target(project_root, relative)
5739
- if target is None:
5740
- return
5741
-
5742
- source = (data.get("finalVerification") or {}).get("sourceImplementationReport") or {}
5743
- declared = {
5744
- "scope": str(data.get("verificationScope") or "").strip(),
5745
- "worktree": str(source.get("worktreePath") or "").strip(),
5746
- "base": str(source.get("implementationBaseRef") or "").strip(),
5747
- "head": str(source.get("capturedHeadSha") or "").strip(),
5748
- }
5749
- for key, expected in ((k, target[k]) for k in _TARGET_FIELD_RES):
5750
- actual = declared[key]
5751
- if expected and actual and actual != expected:
5752
- failures.append(
5753
- f"final-verification report declares {key} `{actual}` but the "
5754
- f"prepared verification target says `{expected}` "
5755
- f"(`{relative}`). The report must mirror the target it was "
5756
- "prepared against — `verificationScope` in particular gates "
5757
- "both stage-group eligibility and release-handoff routing, so "
5758
- "a self-declared value lets a run be judged as something it "
5759
- "was not."
5760
- )
5761
-
5762
- declared_stages = {
5763
- row.get("stage")
5764
- for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
5765
- if isinstance(row, dict) and isinstance(row.get("stage"), int)
5766
- }
5767
- if target["stages"] and declared_stages and declared_stages != target["stages"]:
5768
- failures.append(
5769
- f"final-verification report covers stages {sorted(declared_stages)} "
5770
- f"but the prepared target names {sorted(target['stages'])} "
5771
- f"(`{relative}`). A verdict must not be rendered for a stage set "
5772
- "nobody prepared evidence for."
5773
- )
5774
-
5775
-
5776
5742
  def _validate_verified_row_recorded(
5777
5743
  data: dict,
5778
5744
  report_path: Path,
@@ -7990,98 +7956,19 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
7990
7956
  )
7991
7957
 
7992
7958
 
7993
- _ADDED_SURFACE_NO_CALLER_RE = re.compile(r"^\s*none\b", re.IGNORECASE)
7994
-
7995
-
7996
- def _validate_added_surface_audit(data: dict, failures: list[str]) -> None:
7997
- """Every surface the diff added is traced to a requirement, exempted, or paid for.
7998
-
7999
- The coverage table proves each requirement reached the diff. Nothing proved
8000
- the reverse — that each thing the diff added answers a requirement — so work
8001
- nobody asked for passed every gate. This check reads the reverse table and
8002
- refuses a row that calls itself over-delivery without the blocker or
8003
- condition it became: a caller-less surface is an acceptance blocker, and a
8004
- surface with callers but no requirement is a conditional-acceptance
8005
- condition (ADR-0009 grades the two differently on purpose).
8006
- """
8007
- fv = data.get("finalVerification")
8008
- if not isinstance(fv, Mapping):
8009
- return
8010
- rows = fv.get("addedSurfaceAudit")
8011
- if not isinstance(rows, list):
8012
- return
8013
- blocker_ids = {
8014
- str(row.get("id"))
8015
- for row in (fv.get("acceptanceBlockers") or [])
8016
- if isinstance(row, Mapping)
8017
- }
8018
- condition_ids = {
8019
- str(row.get("id"))
8020
- for row in ((data.get("finalVerdict") or {}).get(
8021
- "conditionalAcceptanceConditions") or [])
8022
- if isinstance(row, Mapping)
8023
- }
8024
- for row in rows:
8025
- if not isinstance(row, Mapping):
8026
- continue
8027
- row_id = str(row.get("id") or "<id 없음>")
8028
- disposition = str(row.get("disposition") or "")
8029
- note = str(row.get("note") or "")
8030
- if disposition == "traced" and not str(row.get("requirement") or "").strip():
8031
- failures.append(
8032
- f"final-verification: addedSurfaceAudit {row_id} is `traced` but "
8033
- "names no requirement — a surface is traced to something the "
8034
- "brief asked for, or it is not traced."
8035
- )
8036
- continue
8037
- if disposition != "over-delivery":
8038
- continue
8039
- caller_less = bool(
8040
- _ADDED_SURFACE_NO_CALLER_RE.match(str(row.get("callers") or ""))
8041
- )
8042
- expected, known = (
8043
- ("AB", blocker_ids) if caller_less else ("CA", condition_ids)
8044
- )
8045
- cited = set(re.findall(rf"\b{expected}-\d{{3,}}\b", note))
8046
- if not cited:
8047
- failures.append(
8048
- f"final-verification: addedSurfaceAudit {row_id} is "
8049
- f"`over-delivery` with callers "
8050
- f"{'none' if caller_less else 'recorded'}, so its note MUST cite "
8051
- f"the `{expected}-NNN` row it became — "
8052
- + (
8053
- "a caller-less surface is an acceptance blocker"
8054
- if caller_less
8055
- else "a surface with callers but no requirement is a "
8056
- "conditional-acceptance condition"
8057
- )
8058
- + "."
8059
- )
8060
- continue
8061
- missing = sorted(cited - known)
8062
- if missing:
8063
- failures.append(
8064
- f"final-verification: addedSurfaceAudit {row_id} cites "
8065
- f"{missing}, which the report does not carry."
8066
- )
8067
-
8068
-
8069
7959
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
8070
- """Enforce verdict ↔ blocker/condition/routing consistency on the
8071
- final-verification data.json (SSOT). The schema guarantees field SHAPE;
8072
- these are the cross-field invariants the release-handoff gate depends on.
8073
-
8074
- No-op for non-final-verification data so the caller's gate stays defensive.
8075
- """
7960
+ """단계 내용 판정 뒤에 이동 적합성을 본다. 다른 작업 유형은 건너뛴다."""
8076
7961
  if (data.get("header") or {}).get("taskType") != "final-verification":
8077
7962
  return
8078
- _validate_added_surface_audit(data, failures)
7963
+ validate_final_verification_content(data, failures)
7964
+ _validate_final_verification_routing(data, failures)
7965
+
7966
+
7967
+ def _validate_final_verification_routing(data: dict, failures: list[str]) -> None:
7968
+ """다음 단계 이름이 판정과 맞는지 본다. 대상 선택은 추론하지 않는다."""
8079
7969
  verdict = data.get("finalVerdict") or {}
8080
7970
  token = (verdict.get("verdictToken") or "").strip().lower()
8081
- fv = data.get("finalVerification") or {}
8082
- blockers = fv.get("acceptanceBlockers") or []
8083
- conditions = verdict.get("conditionalAcceptanceConditions") or []
8084
- routing_value = fv.get("routingRecommendation")
7971
+ routing_value = (data.get("finalVerification") or {}).get("routingRecommendation")
8085
7972
  routing_token = ""
8086
7973
  if isinstance(routing_value, dict):
8087
7974
  routing_token = str(routing_value.get("target") or "")
@@ -8090,23 +7977,16 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
8090
7977
  "final-verification: routingRecommendation.target must name exactly one "
8091
7978
  "supported routing target."
8092
7979
  )
8093
- routing_token = None
7980
+ return
7981
+ _refuse_unsuitable_final_verification_route(data, failures, token, routing_token)
8094
7982
 
8095
- if token == "accepted" and blockers:
8096
- failures.append(
8097
- "final-verification: verdict `accepted` but acceptanceBlockers is "
8098
- "non-empty — an accepted verdict must have zero blockers."
8099
- )
8100
- if token == "blocked" and not blockers:
8101
- failures.append(
8102
- "final-verification: verdict `blocked` but acceptanceBlockers is "
8103
- "empty — a blocked verdict must list at least one blocker."
8104
- )
8105
- if token == "conditional-accept" and not conditions:
8106
- failures.append(
8107
- "final-verification: verdict `conditional-accept` but "
8108
- "conditionalAcceptanceConditions is empty — list every condition."
8109
- )
7983
+
7984
+ def _refuse_unsuitable_final_verification_route(
7985
+ data: dict,
7986
+ failures: list[str],
7987
+ token: str,
7988
+ routing_token: str,
7989
+ ) -> None:
8110
7990
  if routing_token in RELEASE_HANDOFF_TARGETS and not release_handoff_allowed(data):
8111
7991
  blocking = blocking_condition_ids(data)
8112
7992
  reason = (
@@ -8121,7 +8001,6 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
8121
8001
  "or a `conditional-accept` whose every condition declares "
8122
8002
  "`blocksReleaseHandoff: false`."
8123
8003
  )
8124
-
8125
8004
  if routing_token == "final-verification" and token == "accepted":
8126
8005
  failures.append(
8127
8006
  "final-verification: routingRecommendation cites `final-verification` "
@@ -8129,13 +8008,6 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
8129
8008
  "to re-verify. Route to release-handoff or done."
8130
8009
  )
8131
8010
 
8132
- scope = data.get("verificationScope", "whole-task")
8133
- if scope not in ("whole-task", "single-stage"):
8134
- failures.append(
8135
- f"final-verification: verificationScope must be `whole-task` or "
8136
- f"`single-stage`, got {scope!r}."
8137
- )
8138
-
8139
8011
 
8140
8012
  def validate_report_views(report_path: Path, failures: list[str]) -> None:
8141
8013
  """Enforce Phase 7 step 1.5 (BLOCKING) — the self-contained HTML
@@ -9813,7 +9685,7 @@ def main() -> int:
9813
9685
  Path(args.state).resolve() if args.state else None,
9814
9686
  )
9815
9687
  if task_type == "final-verification":
9816
- _validate_verification_target_match(
9688
+ validate_verification_target_match(
9817
9689
  validation_data,
9818
9690
  run_manifest,
9819
9691
  project_root,
@@ -383,6 +383,9 @@ def _collect_lead_evidence(
383
383
  if candidate is not None:
384
384
  sessions.setdefault(sid, candidate)
385
385
  evidence = _LeadEvidence(window=(since, until))
386
+ ledger_progress = _ledger_progress(
387
+ team_state, run_manifest, project_root, task_type, suffix
388
+ )
386
389
  for sid, path in sorted(sessions.items()):
387
390
  progress, reads, agent_name = _scan_one_jsonl(path, since, until)
388
391
  if agent_name and sid != lead_sid:
@@ -400,7 +403,10 @@ def _collect_lead_evidence(
400
403
  "implementation entry-guard conformance cannot be verified, which "
401
404
  "fails the run (same principle as the token-usage accuracy contract)."
402
405
  )
403
- evidence.progress.sort()
406
+ # 전사와 원장은 같은 체크포인트의 두 기록이다. 계약은 둘 다 요구하고
407
+ # (`lead-progress append` 로 기록, 같은 줄을 대화에 raw 로 emit), 검사는
408
+ # 어느 쪽에 남았든 그 체크포인트를 본 것으로 판정한다.
409
+ evidence.progress = _merge_progress(evidence.progress, ledger_progress)
404
410
  for ts_list in evidence.sidecar_reads.values():
405
411
  ts_list.sort()
406
412
  if _is_activity_contract_v1_planning(run_manifest):
@@ -643,6 +649,69 @@ def _collect_artifact_lead_evidence(
643
649
  return evidence, None
644
650
 
645
651
 
652
+ def _ledger_progress(
653
+ team_state: dict,
654
+ run_manifest: Mapping[str, Any],
655
+ project_root: Path,
656
+ task_type: str,
657
+ suffix: str | None,
658
+ ) -> list[tuple[str, str, str]]:
659
+ """원장에 기록된 이 run 의 PROGRESS 체크포인트. 못 읽으면 빈 목록.
660
+
661
+ `okstra lead-progress append` 는 호스트와 무관하게 체크포인트를
662
+ `leadEventsPath` 에 쓰고, 리드 계약은 모든 체크포인트를 그 명령으로
663
+ 기록하라고 요구한다(`prompts/lead/okstra-lead-contract.md` "Progress
664
+ reporting"). 그런데 `claude-jsonl` 증거 경로는 세션 전사만 훑어서, 계약대로
665
+ 기록한 run 이 체크포인트 전건 누락으로 보고됐다 — 기본 호스트에서 그 명령의
666
+ 출력을 읽는 소비자가 없었다(2026-09-24, jobs final-verification 002:
667
+ 원장에 progress 30행, advisory 10건).
668
+
669
+ 원장 행은 스크랩한 대화 텍스트보다 약한 증거가 아니다. `--phase` 는 열거된
670
+ phase id 만 받고 `--worker` 는 로스터 역할로 다시 쓰이므로, 그 행은 검증된
671
+ 입력으로 okstra 자신이 쓴 것이다.
672
+ """
673
+ events_path, _error = _resolve_lead_events_path(
674
+ team_state, run_manifest, project_root
675
+ )
676
+ if events_path is None:
677
+ return []
678
+ try:
679
+ events = read_lead_events(events_path)
680
+ except LeadEventParseError:
681
+ return []
682
+ run_seq = _run_sequence(run_manifest, suffix)
683
+ rows: list[tuple[str, str, str]] = []
684
+ for event in events:
685
+ if event.event_type not in ("progress", "progress-checkpoint"):
686
+ continue
687
+ if not _event_matches_run(event, team_state, run_manifest, task_type, run_seq):
688
+ continue
689
+ progress = _progress_line_from_event(event)
690
+ if progress is not None:
691
+ rows.append(progress)
692
+ return rows
693
+
694
+
695
+ def _merge_progress(
696
+ rows: list[tuple[str, str, str]],
697
+ extra: list[tuple[str, str, str]],
698
+ ) -> list[tuple[str, str, str]]:
699
+ """두 증거 출처의 체크포인트를 합친다. 같은 줄은 한 번만 남는다.
700
+
701
+ 리드는 계약상 원장에 기록하고 같은 줄을 대화에 내보내므로, 합치면 같은
702
+ 체크포인트가 두 번 들어온다. 서술 정확성 검사는 줄 단위로 대조하니 중복은
703
+ 판정을 바꾸지 않지만, 보고 문구에 같은 줄이 두 번 실리는 것을 막는다.
704
+ """
705
+ seen = {(phase, line) for _ts, phase, line in rows}
706
+ for ts, phase, line in extra:
707
+ if (phase, line) in seen:
708
+ continue
709
+ seen.add((phase, line))
710
+ rows.append((ts, phase, line))
711
+ rows.sort()
712
+ return rows
713
+
714
+
646
715
  def _conformance_evidence_source(
647
716
  team_state: dict,
648
717
  ) -> tuple[str | None, str | None]: