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.
- package/dist/commands/lifecycle/install.mjs +1 -0
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +6 -6
- package/docs/contributor-change-matrix.md +2 -2
- package/docs/project-structure-overview.md +8 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +18 -0
- package/runtime/python/okstra_ctl/contract_graph.py +75 -10
- package/runtime/python/okstra_ctl/doctor.py +13 -3
- package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
- package/runtime/python/okstra_ctl/incremental_carry.py +19 -2
- package/runtime/python/okstra_ctl/next_phase.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +14 -0
- package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
- package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
- package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
- package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
- package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
- package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -0
- package/runtime/python/okstra_ctl/profile_show.py +7 -1
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +3 -3
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_html/router.py +9 -33
- package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
- package/runtime/python/okstra_ctl/report_views.py +17 -1
- package/runtime/python/okstra_ctl/run.py +46 -154
- package/runtime/python/okstra_ctl/stage_targets.py +9 -286
- package/runtime/python/okstra_ctl/user_response.py +199 -4
- package/runtime/python/okstra_ctl/verification_target.py +1 -1
- package/runtime/python/okstra_ctl/wizard/state.py +6 -2
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
- package/runtime/skills/okstra-user-response/SKILL.md +23 -4
- package/runtime/validators/validate-run.py +59 -187
- package/runtime/validators/validate_session_conformance.py +70 -1
- package/docs/task-process/README.md +0 -82
- package/docs/task-process/common-flow.md +0 -173
- package/docs/task-process/error-analysis.md +0 -103
- package/docs/task-process/implementation-option-selection.md +0 -70
- package/docs/task-process/implementation-planning.md +0 -180
- package/docs/task-process/implementation.md +0 -226
- package/docs/task-process/release-handoff.md +0 -220
- package/docs/task-process/requirements-discovery.md +0 -113
- /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
- /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
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7980
|
+
return
|
|
7981
|
+
_refuse_unsuitable_final_verification_route(data, failures, token, routing_token)
|
|
8094
7982
|
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|