okstra 0.225.0 → 0.226.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 (75) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.mjs +5 -0
  4. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  5. package/dist/commands/pr/pr.mjs +42 -13
  6. package/dist/commands/pr/pr.mjs.map +1 -1
  7. package/docs/cli.md +19 -3
  8. package/docs/project-structure-overview.md +2 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/lib/okstra/cli.sh +5 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -0
  13. package/runtime/bin/lib/okstra/usage.sh +3 -0
  14. package/runtime/bin/okstra.sh +2 -0
  15. package/runtime/prompts/duties/code-reviewer.json +7 -4
  16. package/runtime/prompts/duties/technical-verification-worker.json +2 -1
  17. package/runtime/prompts/lead/convergence.md +4 -1
  18. package/runtime/prompts/lead/phase-routing.md +7 -3
  19. package/runtime/prompts/lead/team-contract.md +1 -1
  20. package/runtime/prompts/wizard/prompts.ko.json +15 -0
  21. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
  22. package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
  23. package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
  24. package/runtime/python/okstra_ctl/brief_sources.py +200 -0
  25. package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
  26. package/runtime/python/okstra_ctl/cmux.py +22 -5
  27. package/runtime/python/okstra_ctl/code_review_coverage.py +26 -2
  28. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
  29. package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
  30. package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
  31. package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
  32. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
  33. package/runtime/python/okstra_ctl/manager_split.py +185 -27
  34. package/runtime/python/okstra_ctl/next_phase.py +3 -1
  35. package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
  36. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
  37. package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
  38. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
  39. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  40. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
  41. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
  42. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
  43. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
  44. package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
  45. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
  46. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  47. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
  48. package/runtime/python/okstra_ctl/report_assembly.py +23 -2
  49. package/runtime/python/okstra_ctl/report_views.py +2 -1
  50. package/runtime/python/okstra_ctl/run.py +46 -1
  51. package/runtime/python/okstra_ctl/run_context.py +13 -8
  52. package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
  53. package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
  54. package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
  55. package/runtime/python/okstra_ctl/wizard/render.py +4 -0
  56. package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
  57. package/runtime/python/okstra_ctl/wizard/state.py +4 -0
  58. package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
  59. package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
  60. package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
  61. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +74 -0
  63. package/runtime/schemas/final-report-v3.0.schema.json +99 -2
  64. package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
  65. package/runtime/skills/okstra-code-review/SKILL.md +2 -0
  66. package/runtime/skills/okstra-code-review/references/census-rules.md +16 -2
  67. package/runtime/skills/okstra-code-review/references/review-calibration.md +7 -1
  68. package/runtime/skills/okstra-manager/SKILL.md +15 -2
  69. package/runtime/skills/okstra-pr-gen/SKILL.md +15 -1
  70. package/runtime/templates/reports/brief.template.md +10 -1
  71. package/runtime/templates/reports/html/i18n/en.json +8 -0
  72. package/runtime/templates/reports/html/i18n/ko.json +8 -0
  73. package/runtime/templates/worker-prompt-preamble.md +1 -1
  74. package/runtime/validators/validate-brief.py +39 -1
  75. package/runtime/validators/validate-run.py +5 -0
@@ -52,12 +52,18 @@ def requirement_instructions(original_requirement_ids: Sequence[str], participat
52
52
  "A ranked option is valid only when every participating analyser "
53
53
  "supplied one feasibility vote, at least "
54
54
  f"{required_feasible_votes(participating_analysers)} of them are "
55
- "`feasible`, and both `safetyBlockers` and `unresolvedFeasibilityFacts` "
56
- "are empty.",
55
+ "`feasible`, `safetyBlockers` is empty, and every "
56
+ "`unresolvedFeasibilityFacts` entry is a `deferred-gate` fact that names "
57
+ "its `gate`. A candidate whose `splitAcceptance.clarificationRef` names "
58
+ "the user's `accept-risk` answer to the split needs one `feasible` vote, "
59
+ "and its remaining facts stay on the row as the accepted risk; never set "
60
+ "`splitAcceptance` before that answer is recorded.",
57
61
  ("Classify each `unresolvedFeasibilityFacts` entry with `resolutionKind`: "
58
62
  "`user-decision` requires nonempty `clarificationRefs` linking that fact "
59
63
  "to actual C-NNN records, including answered records; `technical-verification` "
60
- "requires no user question. Preserve the fact, whyItMatters, and evidence. "
64
+ "requires no user question; `deferred-gate` is for a fact only a later gate "
65
+ "can close (a deployed-environment measurement technical-verification may not "
66
+ "run) and requires `gate` naming that gate. Preserve the fact, whyItMatters, and evidence. "
61
67
  "Historical unclassified facts remain readable but require classification "
62
68
  "before blocked report reassembly. Saving does not lift blocked routing."),
63
69
  ("After a linked decision is answered, preserve its disposition and selected "
@@ -73,8 +79,10 @@ def requirement_instructions(original_requirement_ids: Sequence[str], participat
73
79
  "`requirement-fit`, `correctness-risk`, `architecture-fit`, "
74
80
  "`weightedScore`, then id.",
75
81
  "In `candidate-comparison`, keep `preselectedDirection` null. In "
76
- "`preselected-validation`, emit exactly one validated option, an empty "
77
- "`candidateAudit`, and a cited `preselectedDirection`.",
82
+ "`preselected-validation`, keep a cited `preselectedDirection`; a "
83
+ "validated direction is the only ranked option with an empty "
84
+ "`candidateAudit`, and a direction that did not validate is the only "
85
+ "`candidateAudit` row with no ranked option.",
78
86
  "Record candidate validity and selection mode; the lead chooses the next "
79
87
  "phase under `## implementation-option-selection` in "
80
88
  "`prompts/lead/phase-routing.md`.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "allowed": " - candidate-comparison evidence for up to three options per worker\n - preselected-validation of exactly ONE candidate: every analyser returns its own feasibility verdict on that one direction, `candidateAudit` stays empty, and no analyser generates an alternative. It is not a re-evaluation of the merged set — `okstra_ctl.phases.implementation_option_selection.validation._validate_mode` rejects a preselected-validation report carrying more than one option or a non-empty audit\n - a ranked display of at most three options with requirement mappings\n - an audit record for every rejected candidate\n - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)",
2
+ "allowed": " - candidate-comparison evidence for up to three options per worker\n - preselected-validation of exactly ONE candidate: every analyser returns its own feasibility verdict on that one direction and no analyser generates an alternative. A validated direction is the only ranked option with an empty `candidateAudit`; a direction that did not validate is the only `candidateAudit` row and the run routes `blocked` or `technical-verification`. It is not a re-evaluation of the merged set — `okstra_ctl.phases.implementation_option_selection.validation._validate_mode` rejects any other shape\n - a ranked display of at most three options with requirement mappings\n - an audit record for every rejected candidate\n - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)",
3
3
  "forbidden": [
4
4
  "source or configuration edits, refactors, or fix attempts",
5
5
  "tests, builds, migrations, deployments, or any state-mutating command",
@@ -2,9 +2,15 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from pathlib import Path
5
+ from okstra_ctl.final_report_paths import final_report_data_path
6
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
7
+ from okstra_ctl.path_resolve import relative_to_project_root
5
8
  from okstra_ctl.prepare_error import PrepareError
6
9
  from okstra_ctl.scope_provenance import brief_end_state_id_sequence
7
10
 
11
+ CANDIDATE_COMPARISON = "candidate-comparison"
12
+ PRESELECTED_VALIDATION = "preselected-validation"
13
+
8
14
 
9
15
  def validate_brief_requirements(brief_path: Path) -> None:
10
16
  if not brief_end_state_id_sequence(brief_path):
@@ -12,3 +18,64 @@ def validate_brief_requirements(brief_path: Path) -> None:
12
18
  "regenerate the brief with stable end-state IDs before starting "
13
19
  "implementation-option-selection"
14
20
  )
21
+
22
+
23
+ def selection_mode(preselected_direction: str) -> str:
24
+ return PRESELECTED_VALIDATION if preselected_direction.strip() else CANDIDATE_COMPARISON
25
+
26
+
27
+ def upstream_next_steps(carry_in: Path) -> tuple[str, ...]:
28
+ """The next-phase instruction a carried-in report gave this phase.
29
+
30
+ The clarification carry-in projects only the report's C-NNN rows, so a
31
+ routing rationale such as "confirm one already-chosen direction" never
32
+ reached the designers.
33
+ """
34
+ data_path = final_report_data_path(carry_in)
35
+ if not data_path.is_file():
36
+ return ()
37
+ try:
38
+ data = load_owned_object(data_path, artifact="carried-in final report")
39
+ except JsonBoundaryError as exc:
40
+ raise PrepareError(str(exc)) from exc
41
+ card = data.get("verdictCard")
42
+ steps = [card.get("nextStep")] if isinstance(card, dict) else []
43
+ for row in data.get("recommendedNextSteps") or []:
44
+ if isinstance(row, dict):
45
+ steps.append(row.get("text"))
46
+ return tuple(step.strip() for step in steps if isinstance(step, str) and step.strip())
47
+
48
+
49
+ def selection_mode_packet_block(
50
+ preselected_direction: str, carry_in: Path | None, project_root: Path,
51
+ ) -> str:
52
+ mode = selection_mode(preselected_direction)
53
+ lines = ["## Direction Selection Mode", "", f"This run's mode: `{mode}`.", ""]
54
+ if mode == PRESELECTED_VALIDATION:
55
+ lines += [
56
+ "The lead preselected the direction below. Validate only this direction",
57
+ "as `Worker direction-selection procedure` describes for",
58
+ "`preselected-validation`: do not generate, rank or compare alternatives.",
59
+ "Record open choices inside the direction as uncertainties with evidence.",
60
+ "",
61
+ "### Preselected Direction and Citation",
62
+ "",
63
+ preselected_direction.strip(),
64
+ ]
65
+ else:
66
+ lines += [
67
+ "No direction was preselected for this run. Propose and compare",
68
+ "candidates as `Worker direction-selection procedure` describes for",
69
+ "`candidate-comparison`.",
70
+ ]
71
+ steps = upstream_next_steps(carry_in) if carry_in is not None else ()
72
+ if steps:
73
+ lines += [
74
+ "",
75
+ "### Upstream Next-Phase Instruction",
76
+ "",
77
+ f"From `{relative_to_project_root(final_report_data_path(carry_in), project_root)}`:",
78
+ "",
79
+ *(f"- {step}" for step in steps),
80
+ ]
81
+ return "\n".join(lines)
@@ -8,7 +8,8 @@
8
8
  - Worker direction-selection procedure:
9
9
  - In `candidate-comparison` mode, produce candidate, supporting and contradicting evidence, criterion scores, and requirement mappings. For every candidate, state one feasibility verdict — `feasible`, `not-feasible`, or `uncertain` — with a rationale and the strongest counterevidence, each citing inspected evidence. Write the rationale as long as the argument needs: say why each cited finding supports the verdict, not only which findings do. The report's `feasibilityVotes` row for this worker is built from that statement, so a candidate without one leaves the writer nothing but another worker's words.
10
10
  - In `candidate-comparison` mode only, submit at most three candidates. A candidate must be feasible from inspected evidence, not from an assumed future change.
11
- - In `preselected-validation` mode, receive one preselected direction from the lead and validate its evidence, counterevidence, criterion scores, and requirement mappings, and state the same feasibility verdict with its rationale and counterevidence. The worker must not generate new candidates.
11
+ - Read the run's mode, and in `preselected-validation` the one preselected direction with its citation, from the packet's `## Direction Selection Mode` section (`--preselected-direction`).
12
+ - In `preselected-validation` mode, validate the one preselected direction: its evidence, counterevidence, criterion scores, and requirement mappings, and state the same feasibility verdict with its rationale and counterevidence. The worker must not generate new candidates.
12
13
  - Do not produce detailed file lists, stage maps, execution commands, or a plan approval request.
13
14
  - Census aspects:
14
15
  - requirement mapping: Which candidate or preselected direction satisfies this requirement, and how?
@@ -26,7 +27,8 @@
26
27
  - Map every displayed candidate or preselected direction to the stable brief end-state IDs it satisfies, preserves, or leaves unresolved.
27
28
  - **Close the vote gaps before you conclude `blocked`.** Round 1 runs the designers in parallel, so each one votes only on the candidates it proposed and the merged set ends up with a different hole per analyser. A candidate that is otherwise sound then fails the every-analyser clause and drops out of the ranking — three such candidates blocked a run whose comparison had in fact converged (2026-09-10, dev-10629-4: IO-001, IO-002 and IO-003 each held two `feasible` votes and each was missing a different designer). Run `okstra option-votes gaps --task-manifest <taskManifestPath> --narrative <report writer narrative>` before assembly. For each analyser it names, dispatch one vote-completion assignment asking for that analyser's own feasibility verdict, rationale, and counterevidence on the named candidate — no new candidate, so the run stays in `candidate-comparison` mode. The command reports no gap when a vote cannot settle the block (safety blockers, unresolved feasibility facts, too few feasible verdicts); that is the honest `blocked`.
28
29
  - Clarification request policy (phase-specific addenda — shared policy is in `_common-contract.md`):
29
- - Classify every `unresolvedFeasibilityFacts` entry with `resolutionKind`: `user-decision` requires nonempty `clarificationRefs` naming the actual C-NNN records for that fact; `technical-verification` requires source review, design evidence, or experiments and does not require a user question. Preserve `fact`, `whyItMatters`, and `evidence`. Reuse answered questions and their recorded dispositions; never reopen one merely to save the report. An unrelated open question does not satisfy a missing reference. Historical unclassified entries remain readable, but blocked report reassembly requires the writer to classify them explicitly. Enforced by `validate_blocked_answer_channel` in `scripts/okstra_ctl/phases/implementation_option_selection/validation.py`, called by report assembly and `validate-run.py`. Saving preserves `routing: blocked` and does not make a candidate valid. In `humanSummary.actions`, `verdictCard.nextStep`, and selection guidance, distinguish pending user decisions from remaining technical verification; after the pilot route is answered, carry the selected route and remove the old answer-waiting instruction.
30
+ - Classify every `unresolvedFeasibilityFacts` entry with `resolutionKind`: `user-decision` requires nonempty `clarificationRefs` naming the actual C-NNN records for that fact; `technical-verification` requires source review, design evidence, or experiments and does not require a user question. `deferred-gate` marks a fact that no technical-verification run can close because closing it needs something that phase forbids, such as a deployed environment (for example, a cold-start measurement of a deployed Lambda); it requires `gate`, naming where the fact closes (a brief `External Gates` entry, or final-verification user-owned external QA), and it does not make the candidate invalid. A fact that a probe, a source read, or a disposable local service can close is `technical-verification`, not `deferred-gate`. The selected direction snapshot carries every remaining fact of the chosen option as `deferredFeasibilityFacts`, so planning schedules its gate. Enforced by `blocking_feasibility_facts` in `scripts/okstra_ctl/phases/implementation_option_selection/validation.py`. Preserve `fact`, `whyItMatters`, and `evidence`. Reuse answered questions and their recorded dispositions; never reopen one merely to save the report. An unrelated open question does not satisfy a missing reference. Historical unclassified entries remain readable, but blocked report reassembly requires the writer to classify them explicitly. Enforced by `validate_blocked_answer_channel` in `scripts/okstra_ctl/phases/implementation_option_selection/validation.py`, called by report assembly and `validate-run.py`. Saving preserves `routing: blocked` and does not make a candidate valid. In `humanSummary.actions`, `verdictCard.nextStep`, and selection guidance, distinguish pending user decisions from remaining technical verification; after the pilot route is answered, carry the selected route and remove the old answer-waiting instruction.
31
+ - **A split vote ends in a user decision, not another run.** When no candidate is valid only because its votes split (at least one `feasible`, the others `uncertain` or `not-feasible`, no `safetyBlockers`), another option-selection or technical-verification run asks the same designers the same question and returns the same split (fontradar dev-11562: three runs, `feasible` against `uncertain` each time). Before you route `blocked` or `technical-verification` on such a candidate, open one approval decision for it with `okstra approval-decision open --ledger <approvalDecisionsPath>` (classification `noncritical-dissent`): the recommended option is `accept-risk` (proceed with this direction and carry the dissent and the remaining facts into planning), the alternative is `request-revision` (keep the run blocked). Ask the user in-session; record the answer with `okstra approval-decision resolve`. On `accept-risk`, the writer sets `splitAcceptance.clarificationRef` on that candidate, which makes it valid with one `feasible` vote; it then follows the ordinary routing (`implementation-planning` for a validated preselected direction, `pending-direction-selection` in `candidate-comparison`). Leave the votes and facts on the row. When the user is not present, publish the report `blocked` with that approval row open; the next run carries the answer. Enforced by `validate_split_acceptance` in `scripts/okstra_ctl/phases/implementation_option_selection/validation.py`, called by report assembly and `validate-run.py`.
30
32
  {{INCLUDE:_clarification-recommendation.md}}
31
33
  - Cross-verification mode:
32
34
  - Phase 5.5 convergence runs in adversarial mode (`convergence.adversarial=true`).
@@ -33,6 +33,7 @@
33
33
  <table data-report-field="implementationOptionSelection.rankedOptions.feasibilityVotes"><thead><tr><th>{{ t('tasks.implementation-option-selection.worker') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th><th>{{ t('tasks.implementation-option-selection.counterevidence') }}</th></tr></thead><tbody>{% for vote in row.feasibilityVotes %}<tr>{{ row_key(pairs=[(t('tasks.implementation-option-selection.worker'), vote.worker)], status_name=t('tasks.implementation-option-selection.verdict'), status_raw=vote.verdict, status_text=vote.verdict | enum_label('feasibilityVerdict')) }}<td>{{ vote.rationale | inline_code(extra) }}</td><td>{{ vote.counterevidence | inline_code(extra) }}</td></tr>{% endfor %}</tbody></table>
34
34
  {% if row.safetyBlockers %}<p><strong>{{ t('tasks.implementation-option-selection.safety-blockers') }}</strong></p>
35
35
  <ul>{% for blocker in row.safetyBlockers %}<li><span class="status status-risk">{{ blocker.category | enum_label('safetyCategory') }}</span> {{ blocker.statement | inline_code(extra) }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ blocker.evidence | evidence_refs(extra) }}</span></li>{% endfor %}</ul>{% endif %}
36
+ {% if row.get('splitAcceptance') %}<p><strong>{{ t('tasks.implementation-option-selection.split-acceptance') }}</strong> {{ row.splitAcceptance.clarificationRef | inline_code(extra) }}</p>{% endif %}
36
37
  {{ unresolved_facts(row, extra) }}
37
38
  </div>
38
39
  <div class="option-detail">
@@ -49,7 +50,7 @@
49
50
 
50
51
  {% macro unresolved_facts(row, extra) -%}
51
52
  {% if row.unresolvedFeasibilityFacts %}<p><strong>{{ t('tasks.implementation-option-selection.unresolved-facts') }}</strong></p>
52
- <ul>{% for fact in row.unresolvedFeasibilityFacts %}<li>{% if fact.get('resolutionKind') == 'technical-verification' %}{{ t('tasks.implementation-option-selection.technical-verification') }}: {% elif fact.get('resolutionKind') == 'user-decision' %}{{ t('tasks.implementation-option-selection.user-decision') }} ({{ fact.get('clarificationRefs', []) | join(', ') | inline_code(extra) }}): {% else %}{{ t('tasks.implementation-option-selection.classification-required') }}: {% endif %}{{ fact.fact | inline_code(extra) }} — {{ t('tasks.implementation-option-selection.why-it-matters') }} {{ fact.whyItMatters | inline_code(extra) }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ fact.evidence | evidence_refs(extra) }}</span></li>{% endfor %}</ul>{% endif %}
53
+ <ul>{% for fact in row.unresolvedFeasibilityFacts %}<li>{% if fact.get('resolutionKind') == 'technical-verification' %}{{ t('tasks.implementation-option-selection.technical-verification') }}: {% elif fact.get('resolutionKind') == 'deferred-gate' %}{{ t('tasks.implementation-option-selection.deferred-gate') }} ({{ fact.get('gate', '') | inline_code(extra) }}): {% elif fact.get('resolutionKind') == 'user-decision' %}{{ t('tasks.implementation-option-selection.user-decision') }} ({{ fact.get('clarificationRefs', []) | join(', ') | inline_code(extra) }}): {% else %}{{ t('tasks.implementation-option-selection.classification-required') }}: {% endif %}{{ fact.fact | inline_code(extra) }} — {{ t('tasks.implementation-option-selection.why-it-matters') }} {{ fact.whyItMatters | inline_code(extra) }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ fact.evidence | evidence_refs(extra) }}</span></li>{% endfor %}</ul>{% endif %}
53
54
  {%- endmacro %}
54
55
 
55
56
  {% block human_content %}
@@ -41,6 +41,11 @@ MAX_RANKED_OPTIONS = 3
41
41
  MAX_RAW_CANDIDATES_PER_ANALYSER = 3
42
42
  CANDIDATE_COMPARISON_ROUTING = "pending-direction-selection"
43
43
  NO_VALID_OPTIONS_ROUTING = "blocked"
44
+ # 배포된 환경이 있어야 닫히는 사실(예: 배포된 Lambda 의 실행 시간)은
45
+ # technical-verification 이 배포를 금하므로 그 phase 의 재실행으로 닫히지 않는다.
46
+ # 그런 사실은 닫히는 게이트를 적고 후보를 무효로 만들지 않는다.
47
+ DEFERRED_GATE_KIND = "deferred-gate"
48
+ SPLIT_ACCEPTANCE_DISPOSITION = "accept-risk"
44
49
 
45
50
  _FINGERPRINT_FIELDS = (
46
51
  "goal",
@@ -233,6 +238,37 @@ def required_feasible_votes(participating_analysers: Sequence[str]) -> int:
233
238
  return min(MIN_FEASIBLE_VOTES, max(1, len(set(participating_analysers))))
234
239
 
235
240
 
241
+ def split_acceptance_ref(option: Mapping[str, object]) -> str:
242
+ """후보가 인용한 사용자 위험 감수 결정의 C-id. 없으면 빈 문자열."""
243
+ acceptance = option.get("splitAcceptance")
244
+ if not isinstance(acceptance, Mapping):
245
+ return ""
246
+ return str(acceptance.get("clarificationRef") or "").strip()
247
+
248
+
249
+ def _is_deferred_gate_fact(fact: object) -> bool:
250
+ return (
251
+ isinstance(fact, Mapping)
252
+ and fact.get("resolutionKind") == DEFERRED_GATE_KIND
253
+ and bool(str(fact.get("gate") or "").strip())
254
+ )
255
+
256
+
257
+ def blocking_feasibility_facts(option: Mapping[str, object]) -> list[object]:
258
+ """후보를 무효로 두는 미확정 사실.
259
+
260
+ 게이트를 적은 `deferred-gate` 사실은 막지 않는다. 사용자가 위험 감수로
261
+ 받아들인 후보(`splitAcceptance`)는 남은 사실을 기록으로만 들고 간다 —
262
+ 계획 phase 의 `accept-risk` 와 같은 뜻이다.
263
+ """
264
+ if split_acceptance_ref(option):
265
+ return []
266
+ return [
267
+ fact for fact in option.get("unresolvedFeasibilityFacts") or ()
268
+ if not _is_deferred_gate_fact(fact)
269
+ ]
270
+
271
+
236
272
  def _validate_option_feasibility(
237
273
  option: Mapping[str, object],
238
274
  participating_analysers: Sequence[str],
@@ -255,13 +291,36 @@ def _validate_option_feasibility(
255
291
  isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
256
292
  for vote in votes
257
293
  )
258
- required = required_feasible_votes(participating_analysers)
294
+ # 갈린 표는 같은 분석자들에게 다시 물어도 갈린다. 사용자가 위험 감수로
295
+ # 받아들인 후보는 `feasible` 한 표로 충분하다 — 방향을 낸 근거가 있어야
296
+ # 받아들일 대상이 있다.
297
+ accepted = bool(split_acceptance_ref(option))
298
+ required = 1 if accepted else required_feasible_votes(participating_analysers)
259
299
  if require_valid and feasible < required:
260
- errors.append(f"{option_id} must have at least {required} feasible vote(s)")
300
+ errors.append(
301
+ f"{option_id} must have at least {required} feasible vote(s); a split "
302
+ "vote is settled by a user accept-risk decision cited in splitAcceptance, "
303
+ "not by another run with the same analysers"
304
+ )
261
305
  if require_valid and option.get("safetyBlockers"):
262
306
  errors.append(f"{option_id} safetyBlockers must be empty")
263
- if require_valid and option.get("unresolvedFeasibilityFacts"):
264
- errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
307
+ blocking_facts = blocking_feasibility_facts(option)
308
+ if require_valid and blocking_facts:
309
+ errors.append(
310
+ f"{option_id} unresolvedFeasibilityFacts must be empty, "
311
+ f"{DEFERRED_GATE_KIND} facts that name their gate, or accepted through "
312
+ "splitAcceptance"
313
+ )
314
+ for index, fact in enumerate(option.get("unresolvedFeasibilityFacts") or ()):
315
+ if (
316
+ isinstance(fact, Mapping)
317
+ and fact.get("resolutionKind") == DEFERRED_GATE_KIND
318
+ and not _is_deferred_gate_fact(fact)
319
+ ):
320
+ errors.append(
321
+ f"{option_id} unresolvedFeasibilityFacts[{index}]: a "
322
+ f"{DEFERRED_GATE_KIND} fact must name the gate that closes it"
323
+ )
265
324
  copied = _copied_votes(votes)
266
325
  for first, second in copied:
267
326
  errors.append(
@@ -272,7 +331,7 @@ def _validate_option_feasibility(
272
331
  all_participated
273
332
  and feasible >= required
274
333
  and not option.get("safetyBlockers")
275
- and not option.get("unresolvedFeasibilityFacts")
334
+ and not blocking_facts
276
335
  and not copied
277
336
  )
278
337
 
@@ -415,9 +474,7 @@ def vote_gaps(
415
474
  for row in (selection.get(key) or ())
416
475
  if isinstance(row, Mapping)
417
476
  ):
418
- if candidate.get("safetyBlockers") or candidate.get(
419
- "unresolvedFeasibilityFacts"
420
- ):
477
+ if candidate.get("safetyBlockers") or blocking_feasibility_facts(candidate):
421
478
  continue
422
479
  votes = candidate.get("feasibilityVotes") or ()
423
480
  if _copied_votes(votes):
@@ -489,12 +546,13 @@ def validate_blocked_answer_channel(
489
546
  continue
490
547
  label += f" ({fact.get('fact')})"
491
548
  kind = fact.get("resolutionKind")
492
- if kind == "technical-verification":
549
+ if kind in {"technical-verification", DEFERRED_GATE_KIND}:
493
550
  continue
494
551
  if kind != "user-decision":
495
552
  errors.append(f"{label}: classification required — set resolutionKind to "
496
- "user-decision or technical-verification; preserve the fact "
497
- "and evidence, then reassemble; do not infer a user question")
553
+ "user-decision, technical-verification or deferred-gate; "
554
+ "preserve the fact and evidence, then reassemble; do not "
555
+ "infer a user question")
498
556
  continue
499
557
  refs = fact.get("clarificationRefs")
500
558
  if not isinstance(refs, list) or not refs or any(
@@ -507,6 +565,46 @@ def validate_blocked_answer_channel(
507
565
  return errors
508
566
 
509
567
 
568
+ def validate_split_acceptance(report_data: Mapping[str, object]) -> list[str]:
569
+ """`splitAcceptance` 가 인용한 C-id 가 사용자의 위험 감수 기록인지 검사한다.
570
+
571
+ 서술 검증(`validate_implementation_option_selection`)은 clarification 행을
572
+ 보지 못한다. 리드 원장이 합쳐진 뒤에만 이 인용이 실제 `accept-risk` 답인지
573
+ 알 수 있으므로 조립과 `validate-run.py` 가 이 함수를 따로 부른다.
574
+ """
575
+ selection = report_data.get("implementationOptionSelection")
576
+ if not isinstance(selection, Mapping):
577
+ return []
578
+ rows = {
579
+ row.get("id"): row
580
+ for row in (report_data.get("clarificationItems") or ())
581
+ if isinstance(row, Mapping)
582
+ }
583
+ errors: list[str] = []
584
+ for candidate in (
585
+ row for key in ("rankedOptions", "candidateAudit")
586
+ for row in (selection.get(key) or ()) if isinstance(row, Mapping)
587
+ ):
588
+ ref = split_acceptance_ref(candidate)
589
+ if not ref and "splitAcceptance" not in candidate:
590
+ continue
591
+ row = rows.get(ref)
592
+ if (
593
+ row is None
594
+ or str(row.get("blocks") or "").strip().lower() != "approval"
595
+ or str(row.get("status") or "").strip().lower() == "obsolete"
596
+ or clarification_disposition(row) != SPLIT_ACCEPTANCE_DISPOSITION
597
+ ):
598
+ errors.append(
599
+ f"{candidate.get('id')} splitAcceptance {ref!r} must name a "
600
+ "`blocks: approval` clarification the user answered with "
601
+ f"`{SPLIT_ACCEPTANCE_DISPOSITION}`; open it with `okstra "
602
+ "approval-decision open --ledger <approvalDecisionsPath>`, record "
603
+ "the answer with `okstra approval-decision resolve`, then reassemble"
604
+ )
605
+ return errors
606
+
607
+
510
608
  def _validate_candidate_audit(
511
609
  data: Mapping[str, object],
512
610
  options: Sequence[Mapping[str, object]],
@@ -630,18 +728,29 @@ def _validate_mode(
630
728
  if options and data.get("routing") != CANDIDATE_COMPARISON_ROUTING:
631
729
  errors.append("candidate-comparison with options must await direction selection")
632
730
  return
633
- if len(options) != 1:
634
- errors.append("preselected-validation must contain exactly one validated option")
635
- if audit:
636
- errors.append("preselected-validation candidateAudit must be empty")
731
+ # A direction that failed validation cannot be displayed (displayed options must be
732
+ # valid), so it is the one audit row; `_validate_option_count_and_routing` then
733
+ # requires `blocked` or `technical-verification`.
734
+ validated = bool(options)
735
+ if validated:
736
+ if len(options) != 1:
737
+ errors.append("preselected-validation must contain exactly one validated option")
738
+ if audit:
739
+ errors.append("preselected-validation candidateAudit must be empty")
740
+ elif len(audit) != 1:
741
+ errors.append(
742
+ "preselected-validation must contain exactly one validated option, "
743
+ "or the one unvalidated direction as its only candidateAudit row"
744
+ )
637
745
  if not isinstance(direction, Mapping):
638
746
  errors.append("preselected-validation requires preselectedDirection")
639
747
  return
640
- if options and direction.get("optionId") != options[0].get("id"):
641
- errors.append("preselectedDirection optionId must name the validated option")
748
+ target = options[0] if validated else (audit[0] if len(audit) == 1 else None)
749
+ if target is not None and direction.get("optionId") != target.get("id"):
750
+ errors.append("preselectedDirection optionId must name the preselected direction")
642
751
  if not _CITATION_RE.search(str(direction.get("citation") or "")):
643
752
  errors.append("preselectedDirection citation must be citable")
644
- if data.get("routing") != "implementation-planning":
753
+ if validated and data.get("routing") != "implementation-planning":
645
754
  errors.append("validated preselected direction must route to implementation-planning")
646
755
 
647
756
 
@@ -19,6 +19,7 @@ Plan for the actual worktree layout before approval. Shared documentation direct
19
19
  - **Selected-direction planning procedure** — perform these steps in order and no others:
20
20
  1. Read `selected-direction.json` and the original requirements ledger end-to-end. The ledger is not a separate file: it is the brief's `Expected Behavior` / `Preserved Behavior` / `Expected Outcome` sections (`EB-` / `PB-` / `EO-` ids), carried verbatim in the analysis packet's `## Task-Specific Brief Extract`.
21
21
  1a. Fill `selectedDirectionRef` from that snapshot: copy `sourceReport`, `sourceDataSha256`, and `optionId` verbatim from the file; write `snapshotPath` as the snapshot's path relative to the **task root** (`instruction-set/selected-direction.json`, never project-relative and never absolute) and `snapshotSha256` as the SHA-256 of its exact bytes. Those last two are the only ref fields the snapshot does not carry.
22
+ 1b. When the snapshot carries `deferredFeasibilityFacts`, the direction was validated with those facts still open. A `deferred-gate` fact names the `gate` that closes it; carry it into the plan as that gate (an `External Gates` dependency or final-verification user-owned external QA), not as a stage validation row, because a stage row the implementation run cannot execute blocks the stage. When the snapshot also carries `splitAcceptance`, the user accepted the direction over a split feasibility vote; the remaining facts are that accepted risk, so record each in `dependencyMigrationRisk` or the validation that would expose it, and do not reopen the direction choice.
22
23
  2. Concretize the selected direction into files, interfaces, stages, validation, and rollback without changing its mechanism or boundaries.
23
24
  3. Link every planned file and stage bidirectionally to the original requirements.
24
25
  4. If current evidence requires changing the selected direction, emit `direction-invalidated` and stop planning.
@@ -147,7 +148,7 @@ Plan for the actual worktree layout before approval. Shared documentation direct
147
148
  **Pick the cases by distinct outcome, not by line coverage.** When the stage writes or reconciles state, its meaningfully different outcomes are usually more than three — normal success, target already in the desired state (resume), existing data reused rather than created, a conflicting concurrent state, target absent, and mid-way failure with rollback. Enumerate the ones this stage actually implements and route them across the three lines (the `boundary` line is where resume / already-done / reuse belongs; `failure` carries conflict, absence, and rollback), naming each in the cell rather than collapsing them into "edge input". An implemented outcome with no declared case is a coverage gap the executor will not backfill.
148
149
  - **Per-stage subsections** (`## 5.5.<i> Stage <i>: <title>` for each `i`), each containing the four required subsections:
149
150
  - `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
150
- - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | outcome | expected`. `outcome` is one word — `PASS` or `FAIL` — and `expected` is the sentence saying what that looks like here; a verdict written inside the sentence is not read as one. The `files` cell lists each touched path in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...`), which does not resolve and is rejected by plan-body verification as a kind-b path mismatch. **The narrative row additionally carries `plannedPaths`: the paths this step may write as an array, one repository-relative path per entry, with no globs, exclusions, counts or commentary. Read-only checks use `plannedPaths: []`; their working directory belongs in the command, not the write ledger. Never declare the repository or worktree root. Planning assembly and correction preflight enforce this through `write_policy.planned_path_declaration_errors`.** **File size:** the implementation run aims for 300 physical lines per source and test file and enforces 400 — a new file over 400, an existing file taken past 400, and a file already over 400 that grows are blocking findings the executor must split in a correction round; the same conditions at 300 are advisory. When a step would add code or tests to a file near or over 300 lines (`wc -l`), plan a new module or test file for that responsibility in `plannedPaths` instead of appending to the existing one. **Enforced at implementation, not here:** `okstra correction file-size` (`scripts/okstra_ctl/file_size_cap.py`), re-run by `okstra stage-done`. `files` is the sentence a reader sees; `plannedPaths` is the ledger report assembly preserves and the implementer write policy enforces. When a step legitimately covers a set too large to enumerate, split it or name the directory the set lives under. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`outcome` = `FAIL`); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`outcome` = `PASS`); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section. A stage that is truthfully none of those three declares `TDD exemption: user-bypass — <the user's words>`, which holds only while the user has granted it for that stage with `okstra prepare --tdd-bypass "<stage>:<reason>"` — never file the nearest of the three instead. Validator S10c enforces RED-first + GREEN; the `outcome` cell agreeing with its prefix is a schema conditional. S10e rejects an unsupported exemption reason and a `user-bypass` with no user grant (`validators/validate-implementation-plan-stages.py`).
151
+ - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | outcome | expected`. `outcome` is one word — `PASS` or `FAIL` — and `expected` is the sentence saying what that looks like here; a verdict written inside the sentence is not read as one. The `files` cell lists each touched path in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...`), which does not resolve and is rejected by plan-body verification as a kind-b path mismatch. **The narrative row additionally carries `plannedPaths`: the paths this step may write as an array, one repository-relative path per entry, with no globs, exclusions, counts or commentary. Read-only checks use `plannedPaths: []`; their working directory belongs in the command, not the write ledger. Never declare the repository or worktree root. Planning assembly and correction preflight enforce this through `write_policy.planned_path_declaration_errors`.** **File size:** the implementation run aims for 300 physical lines per source and test file and enforces 400 — a new file over 400, an existing file taken past 400, and a file already over 400 that grows are blocking findings the executor must split in a correction round; the same conditions at 300 are advisory. When a step would add code or tests to a file near or over 300 lines (`wc -l`), plan a new module or test file for that responsibility in `plannedPaths` instead of appending to the existing one. **Enforced at implementation, not here:** `okstra correction file-size` (`scripts/okstra_ctl/file_size_cap.py`), re-run by `okstra stage-done`. `files` is the sentence a reader sees; `plannedPaths` is the ledger report assembly preserves and the implementer write policy enforces. The design-surface detector maps every selected-option `fileStructure` path to the stage whose `files` or `plannedPaths` names it; a path neither names fails the draft check that correction preflight and `plan-items prepare` share (`scripts/okstra_ctl/report_assembly.py` `validate_plan_draft`). When a step legitimately covers a set too large to enumerate, split it or name the directory the set lives under. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`outcome` = `FAIL`); at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`outcome` = `PASS`); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section. A stage that is truthfully none of those three declares `TDD exemption: user-bypass — <the user's words>`, which holds only while the user has granted it for that stage with `okstra prepare --tdd-bypass "<stage>:<reason>"` — never file the nearest of the three instead. Validator S10c enforces RED-first + GREEN; the `outcome` cell agreeing with its prefix is a schema conditional. S10e rejects an unsupported exemption reason and a `user-bypass` with no user grant (`validators/validate-implementation-plan-stages.py`).
151
152
  - **Real-IO tests stay out of the plan's committed steps (BLOCKING).** A step never creates, edits, or runs a test that opens a real database connection, makes a real network call, or talks to a real external service inside the project source test tree (`src/**`, `test/**`, `tests/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, `*.e2e-spec.*`), and no step's `PASS` depends on a reachable database or service. The implementation executor refuses such a step before its first edit (`_implementation-executor.md` "Real-IO test isolation"), so a plan that contains one stops the stage with zero changes (observed 2026-10-18, dev-11374: a live-MySQL `test/font-pairing.e2e-spec.ts` planned in steps 1, 4 and 5). The committed RED/GREEN steps test the adapter with its injected collaborator stubbed — the exact query text, its replacements, and the returned rows — and run without any backend. Real-backend verification is declared only as this stage's `Conformance tests:` line below (`requires=[db|http|external]`): the implementation run writes that script under `<task_root>/qa/scripts/`, runs it against a local or replica backend when one is reachable, never commits it, and records a non-PASS or unreachable backend as advisory. Read a brief line such as `verify: integration test against a test database` as that conformance declaration, not as a committed test. Do not plan a shared real-DB guard, a test DDL fixture, or an edit to an existing real-IO suite in source to support it. **Enforced:** every `P-Step-*` item of the §5.5.9 plan-body round carries this rule to its verifiers (`okstra_ctl.phases.implementation_planning.authoring._REAL_IO_STEP_NOTE`), which vote `DISAGREE(b)` on a step that breaks it; no path pattern tells a mocked test from a live one, so no validator checks it.
152
153
  - **The `command` cell runs inside an okstra task worktree, not a bare checkout (BLOCKING).** okstra provisions `.okstra`, the configured sync entries (`.project-docs`, `.claude`, …), and — for `implementation` — a nested `stage-<N>/` worktree into the tree the step executes in. Two consequences bind every command you write:
153
154
  - **Clean-tree assertions use `okstra worktree-status --check-clean`.** A bare `git status --porcelain` is never empty there, so an assertion built on one fails on okstra's scaffolding rather than on the stage's work. The okstra command asks the same question over source paths only and exits 1 when dirty, so it stands alone as a step's assertion: `okstra worktree-status --check-clean`. Validator S13 rejects the bare form. Do not add a `git tag stage-<N>-exit` to the step. Stage completion records the commit in the consumer ledger without creating or moving git tags.
@@ -8,8 +8,8 @@
8
8
  1. Read the frozen technical input linked in the analysis packet. Preserve the source hash, candidate IDs and fact identities. `resolve_technical_verification_input` enforces same-task source ownership, schema validity, eligible technical facts and unresolved user-decision gates.
9
9
  2. Before executing a probe, write its plan under `experiments/<seq>/<worker-id>/`: hypothesis, procedure, commands, confirming and rejecting signals, environment, and inconclusive criteria. Retain the plan with the evidence.
10
10
  3. Create a separate source copy in that directory. Record the source commit and any uncommitted input differences. Read the project and task worktree as inputs; perform source edits, installations and builds only in your own copy. Do not share writable dependency directories or use real credentials. Use synthetic local accounts for request-boundary experiments.
11
- 4. Phase 7 removes every `node_modules` and `.next` directory under the run directory, so evidence must not live inside them. Preserve the baseline lockfile. When the hypothesis needs a deliberate dependency change, retain the resulting lockfile and diff, then use a frozen install against that experimental lockfile. Record command, working directory, stdout/stderr and exit code in run-local logs. Distinguish infrastructure failure from a falsified compatibility claim.
12
- 5. Classify each fact as `supported`, `refuted`, `inconclusive` or `not-run` using the declared signals. A zero exit code alone does not establish compatibility. Record limitations and all failed or unavailable probes.
11
+ 4. Phase 7 removes every `node_modules` and `.next` directory under the run directory, so evidence must not live inside them. Preserve the baseline lockfile. When the hypothesis needs a deliberate dependency change, retain the resulting lockfile and diff, then use a frozen install against that experimental lockfile. Record command, working directory, stdout/stderr and exit code in run-local logs. End every log with an `exit_code=<n>` line so a command that prints nothing on success, such as `tsc --noEmit`, still leaves a non-empty log; the report validator rejects an empty log. Record as `cwd` the project-relative directory the command ran in: when the command starts with `cd <dir> &&`, that is `<dir>`, not the shell's starting directory. Run every cited command inside `experiments/<seq>/`; a probe run from the project root is not admissible command evidence. Distinguish infrastructure failure from a falsified compatibility claim.
12
+ 5. Classify each fact as `supported`, `refuted`, `inconclusive` or `not-run` using the declared signals. A zero exit code alone does not establish compatibility. A `supported` check cites only commands that exited 0. When a command fails for a reason outside the hypothesis, such as a baseline failure that repeats identically on both sides of the comparison, do not cite it under a supported check: cite a command whose exit code expresses the confirming signal, such as `cmp` of the two logs, and name the failing logs in `limitations`. If no such command separates the outcomes, classify the fact `inconclusive`. Record limitations and all failed or unavailable probes.
13
13
  6. During convergence, inspect plans and logs; rerun disputed probes in a separate copy when feasible. An independent report is not a substitute for command evidence.
14
14
 
15
15
  Copy boundaries and pre-execution plans are worker instructions reviewed during convergence. The report validator checks fact completeness, run-local log files, experiment working directories and zero exit codes for supported results. It does not sandbox arbitrary shell commands or prove that a declared command produced its log.
@@ -27,12 +27,16 @@ from .report_synthesis_packet import (
27
27
  )
28
28
  from .conformance import strip_stage_declaration_label
29
29
  from .coverage_census import census_warning_rows, read_census_state
30
+ from .design_surfaces import DesignSurfaceError, detect_design_surfaces
30
31
  from .design_prep import (
31
32
  DesignPrepError,
32
33
  canonicalize_request_paths,
33
34
  materialize_design_prep_requests,
34
35
  )
35
- from .phases.implementation_option_selection.validation import validate_blocked_answer_channel
36
+ from .phases.implementation_option_selection.validation import (
37
+ validate_blocked_answer_channel,
38
+ validate_split_acceptance,
39
+ )
36
40
  from .phases.discussion.report import discussion_report_block, discussion_report_errors
37
41
  from .phases.discussion.store import DiscussionStore, DiscussionStoreError
38
42
  from .phases.technical_verification.validation import validate_technical_verification_report
@@ -522,7 +526,7 @@ def _compose(
522
526
  data["crossVerification"]["correctionRounds"] = rounds
523
527
  data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
524
528
  # 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
525
- for reason in validate_blocked_answer_channel(data):
529
+ for reason in [*validate_blocked_answer_channel(data), *validate_split_acceptance(data)]:
526
530
  _fail(
527
531
  "lead",
528
532
  inputs["approval-decisions"].path,
@@ -688,9 +692,26 @@ def validate_plan_draft(
688
692
  f"implementationPlanning: {error}"
689
693
  for error in selected_direction_plan_errors(draft, project_root, manifest)
690
694
  ],
695
+ *_design_surface_errors(draft),
691
696
  ]
692
697
 
693
698
 
699
+ def _design_surface_errors(draft: Mapping[str, Any]) -> list[str]:
700
+ """`plan-items prepare` 가 돌릴 설계 표면 탐지기를 초안 단계에서 미리 돌린다.
701
+
702
+ 탐지 실패는 서사를 고쳐야 풀리는데, 교정 원장 대조가 이를 몰라 ok 를 낸 뒤
703
+ prepare 에서야 드러났다.
704
+ """
705
+ planning = draft.get("implementationPlanning")
706
+ if not isinstance(planning, Mapping) or not planning.get("stages"):
707
+ return []
708
+ try:
709
+ detect_design_surfaces(planning)
710
+ except (DesignSurfaceError, TypeError, AttributeError) as exc:
711
+ return [f"implementationPlanning: design-surface detection failed: {exc}"]
712
+ return []
713
+
714
+
694
715
  def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
695
716
  task_key = str(manifest.get("taskKey") or "")
696
717
  parts = task_key.split(":")
@@ -87,6 +87,7 @@ from .clarification_items import (
87
87
  section_1_present_but_unparsed,
88
88
  )
89
89
  from .md_table import is_separator_row as _is_separator_row
90
+ from .phases.implementation_option_selection.validation import blocking_feasibility_facts
90
91
 
91
92
 
92
93
  # --------------------------------------------------------------------------- #
@@ -1175,7 +1176,7 @@ def direction_candidate_ineligibility(option: Mapping[str, Any]) -> str:
1175
1176
  coverage = option.get("coverageSummary")
1176
1177
  if not isinstance(coverage, Mapping) or coverage.get("coverageVerdict") != "exact":
1177
1178
  return "selected candidate must have exact coverage"
1178
- if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
1179
+ if option.get("safetyBlockers") or blocking_feasibility_facts(option):
1179
1180
  return "selected candidate has a safety blocker"
1180
1181
  return ""
1181
1182