okstra 0.189.4 → 0.191.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 (156) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +8 -4
  15. package/docs/cli.md +10 -8
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +7 -4
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +1 -0
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation.md +2 -0
  50. package/runtime/prompts/profiles/release-handoff.md +4 -3
  51. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  52. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  55. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  58. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +31 -3
  60. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  62. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  64. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  66. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +13 -7
  67. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -2
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +44 -13
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +10 -1
  96. package/runtime/python/okstra_ctl/report_assembly.py +7 -6
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/render.py +15 -4
  99. package/runtime/python/okstra_ctl/run.py +48 -14
  100. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  101. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  102. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  103. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  104. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  105. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  106. package/runtime/python/okstra_ctl/wizard/engine.py +384 -0
  107. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  108. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +75 -0
  109. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  110. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  111. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  112. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  113. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  114. package/runtime/python/okstra_ctl/wizard/state.py +691 -0
  115. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  116. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  117. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  118. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  119. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  120. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  121. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  122. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  123. package/runtime/python/okstra_ctl/workflow.py +2 -2
  124. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  125. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  126. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  127. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  128. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  129. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  130. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  131. package/runtime/python/okstra_token_usage/codex.py +87 -2
  132. package/runtime/python/okstra_token_usage/collect.py +224 -49
  133. package/runtime/python/okstra_token_usage/pricing.py +8 -4
  134. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  135. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  136. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  137. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  138. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  139. package/runtime/skills/okstra-run/SKILL.md +23 -9
  140. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  141. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  142. package/runtime/templates/reports/brief.template.md +15 -7
  143. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  144. package/runtime/templates/reports/group-context.template.md +7 -0
  145. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  146. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  147. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  148. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  149. package/runtime/templates/reports/schedule.template.md +13 -4
  150. package/runtime/templates/reports/task-brief.template.md +1 -1
  151. package/runtime/templates/reverify-output-contract.md +5 -0
  152. package/runtime/templates/worker-error-contract.md +2 -0
  153. package/runtime/templates/worker-prompt-preamble.md +1 -1
  154. package/runtime/validators/validate-run.py +81 -22
  155. package/runtime/validators/validate_session_conformance.py +102 -2
  156. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -0,0 +1,145 @@
1
+ """위저드 상태 파일 — `load_state_file`·`save_state_file` 과 v2 companion 파일 탐색."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from okstra_ctl.convergence_store import write_json_atomic
9
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
10
+
11
+ from .state import WizardError, WizardState
12
+
13
+
14
+ # ---- File I/O helpers (used by CLI) -------------------------------------
15
+
16
+ _LEGACY_SOURCE_FIELD = "legacySource"
17
+
18
+
19
+ def _legacy_v2_companion_path(path: Path) -> Path:
20
+ return path.with_name(f"{path.name}.v2.json")
21
+
22
+
23
+ def _content_addressed_v2_companion_path(
24
+ path: Path,
25
+ source_digest: str,
26
+ suffix: int | None = None,
27
+ ) -> Path:
28
+ numbered = f".{suffix}" if suffix is not None else ""
29
+ return path.with_name(f"{path.name}.v2.{source_digest}{numbered}.json")
30
+
31
+
32
+ def _content_addressed_v2_companion_paths(
33
+ path: Path,
34
+ source_digest: str,
35
+ ) -> tuple[Path, ...]:
36
+ canonical = _content_addressed_v2_companion_path(path, source_digest)
37
+ numbered_prefix = f"{path.name}.v2.{source_digest}."
38
+ numbered = tuple(sorted(
39
+ candidate
40
+ for candidate in path.parent.iterdir()
41
+ if candidate.is_file()
42
+ and candidate.name.startswith(numbered_prefix)
43
+ and candidate.name.endswith(".json")
44
+ ))
45
+ return (canonical, *numbered)
46
+
47
+
48
+ def _available_v2_companion_path(path: Path, source_digest: str) -> Path:
49
+ candidate = _content_addressed_v2_companion_path(path, source_digest)
50
+ suffix = 1
51
+ while candidate.exists():
52
+ candidate = _content_addressed_v2_companion_path(
53
+ path,
54
+ source_digest,
55
+ suffix,
56
+ )
57
+ suffix += 1
58
+ return candidate
59
+
60
+
61
+ def _legacy_source_metadata(path: Path, source: bytes) -> dict[str, str]:
62
+ return {
63
+ "path": str(path.resolve()),
64
+ "sha256": hashlib.sha256(source).hexdigest(),
65
+ }
66
+
67
+
68
+ def _matching_v2_companion(
69
+ path: Path,
70
+ source_metadata: dict[str, str],
71
+ ) -> dict[str, Any] | None:
72
+ try:
73
+ payload = load_owned_object(path, artifact="wizard state")
74
+ except (OSError, UnicodeError, JsonBoundaryError):
75
+ return None
76
+ if not isinstance(payload, dict):
77
+ return None
78
+ if payload.get("executionIdentityVersion") != 2:
79
+ return None
80
+ if payload.get(_LEGACY_SOURCE_FIELD) != source_metadata:
81
+ return None
82
+ return payload
83
+
84
+
85
+ def _load_matching_v2_state(
86
+ paths: tuple[Path, ...],
87
+ source_metadata: dict[str, str],
88
+ ) -> tuple[WizardState | None, Path | None]:
89
+ for companion_path in paths:
90
+ payload = _matching_v2_companion(companion_path, source_metadata)
91
+ if payload is None:
92
+ continue
93
+ try:
94
+ return WizardState.from_json(payload), companion_path
95
+ except WizardError:
96
+ continue
97
+ return None, None
98
+
99
+
100
+ def load_state_file(path: Path) -> WizardState:
101
+ source_path = Path(path)
102
+ source = source_path.read_bytes()
103
+ data = load_owned_object(source_path, artifact="wizard state")
104
+ source_version = data.get("executionIdentityVersion", 1)
105
+ source_metadata = _legacy_source_metadata(source_path, source)
106
+ source_digest = source_metadata["sha256"]
107
+ resumed_state = None
108
+ companion_path = None
109
+ if source_version in (None, 1):
110
+ content_paths = _content_addressed_v2_companion_paths(
111
+ source_path,
112
+ source_digest,
113
+ )
114
+ resumed_state, companion_path = _load_matching_v2_state(
115
+ content_paths,
116
+ source_metadata,
117
+ )
118
+ if resumed_state is None:
119
+ resumed_state, _ = _load_matching_v2_state(
120
+ (_legacy_v2_companion_path(source_path),),
121
+ source_metadata,
122
+ )
123
+ state = resumed_state or WizardState.from_json(data)
124
+ state._source_execution_identity_version = source_version
125
+ if source_version in (None, 1):
126
+ state._legacy_source_metadata = source_metadata
127
+ state._v2_companion_path = companion_path or _available_v2_companion_path(
128
+ source_path,
129
+ source_digest,
130
+ )
131
+ return state
132
+
133
+
134
+ def _save_resumed_state_file(path: Path, state: WizardState) -> None:
135
+ """Persist native v2 state while keeping legacy resume sources read-only."""
136
+ if getattr(state, "_source_execution_identity_version", 2) in (None, 1):
137
+ payload = state.to_json()
138
+ payload[_LEGACY_SOURCE_FIELD] = state._legacy_source_metadata
139
+ write_json_atomic(state._v2_companion_path, payload)
140
+ return
141
+ save_state_file(path, state)
142
+
143
+
144
+ def save_state_file(path: Path, state: WizardState) -> None:
145
+ write_json_atomic(Path(path), state.to_json())
@@ -0,0 +1,558 @@
1
+ """analysis 단계 — analysis target, feature/project evidence, design prep 의 build/submit."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from okstra_ctl.analysis_inputs import (
10
+ ANALYSIS_TASK_TYPES,
11
+ AnalysisInputError,
12
+ AnalysisReportCandidate,
13
+ list_evidence_candidates,
14
+ load_analysis_report_candidate,
15
+ resolve_analysis_target,
16
+ resolve_evidence_inputs,
17
+ )
18
+ from okstra_ctl.design_prep import (
19
+ DesignPrepError,
20
+ load_design_prep_items,
21
+ resolve_design_prep,
22
+ write_design_prep_input,
23
+ )
24
+
25
+ from .ids import (
26
+ PICK_SKIP,
27
+ PICK_TYPE_CUSTOM,
28
+ S_ANALYSIS_TARGET,
29
+ S_ANALYSIS_TARGET_PICK,
30
+ S_DESIGN_PREP_CONFIRM,
31
+ S_DESIGN_PREP_DECISION,
32
+ S_DESIGN_PREP_OVERRIDES,
33
+ S_FEATURE_EVIDENCE,
34
+ S_FEATURE_EVIDENCE_PICK,
35
+ S_PROJECT_EVIDENCE,
36
+ S_PROJECT_EVIDENCE_PICK,
37
+ _RESUME_REUSE_PHASES,
38
+ )
39
+ from .state import Prompt, WizardError, WizardState
40
+ from .prompts import _opt, _p, _static_options
41
+ from .sources import _analysis_current_commit, _has_prior_run_inputs, _resolve_path
42
+
43
+
44
+ def _analysis_evidence_paths(state: WizardState) -> list[Path]:
45
+ return [
46
+ Path(path)
47
+ for path in (state.feature_evidence_path, state.project_evidence_path)
48
+ if path
49
+ ]
50
+
51
+
52
+ def _resolve_analysis_evidence(state: WizardState):
53
+ try:
54
+ return resolve_evidence_inputs(
55
+ Path(state.project_root),
56
+ state.task_type,
57
+ _analysis_evidence_paths(state),
58
+ _analysis_current_commit(state),
59
+ )
60
+ except AnalysisInputError as exc:
61
+ raise WizardError(str(exc)) from exc
62
+
63
+
64
+ def _accept_analysis_evidence(
65
+ state: WizardState, raw_path: str, field_name: str
66
+ ) -> AnalysisReportCandidate:
67
+ report_path = _resolve_path(raw_path, Path(state.project_root))
68
+ try:
69
+ candidate = load_analysis_report_candidate(
70
+ Path(state.project_root), report_path
71
+ )
72
+ except AnalysisInputError as exc:
73
+ raise WizardError(str(exc)) from exc
74
+ previous = getattr(state, field_name)
75
+ setattr(state, field_name, str(candidate.report_path))
76
+ try:
77
+ _resolve_analysis_evidence(state)
78
+ except WizardError:
79
+ setattr(state, field_name, previous)
80
+ raise
81
+ return candidate
82
+
83
+
84
+ def _evidence_option_label(candidate: AnalysisReportCandidate) -> str:
85
+ return f"{candidate.task_key} · {candidate.task_type} · run {candidate.run_seq}"
86
+
87
+
88
+ def _build_analysis_evidence_pick(
89
+ state: WizardState, *, step: str, relation: str
90
+ ) -> Prompt:
91
+ t = _p(state.workspace_root, step)
92
+ candidates = list_evidence_candidates(
93
+ Path(state.project_root), state.task_type, relation
94
+ )[:2]
95
+ options = [
96
+ _opt(str(candidate.report_path), _evidence_option_label(candidate))
97
+ for candidate in candidates
98
+ ]
99
+ options.extend([
100
+ _opt(PICK_SKIP, t["options"][PICK_SKIP]),
101
+ _opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]),
102
+ ])
103
+ return Prompt(step=step, kind="pick", label=t["label"], options=options,
104
+ echo_template=t["echo_template"])
105
+
106
+
107
+ def _submit_analysis_evidence_pick(
108
+ state: WizardState,
109
+ value: str,
110
+ *,
111
+ step: str,
112
+ field_name: str,
113
+ pending_name: str,
114
+ target_after_skip: bool = False,
115
+ ) -> Optional[str]:
116
+ t = _p(state.workspace_root, step)
117
+ if value == PICK_SKIP:
118
+ setattr(state, field_name, "")
119
+ setattr(state, pending_name, False)
120
+ if target_after_skip:
121
+ state.analysis_target_pending_text = True
122
+ return t["echo_variants"]["skip"]
123
+ if value == PICK_TYPE_CUSTOM:
124
+ setattr(state, pending_name, True)
125
+ return None
126
+ candidate = _accept_analysis_evidence(state, value, field_name)
127
+ setattr(state, pending_name, False)
128
+ return t["echo_variants"]["selected"].format(path=candidate.report_path)
129
+
130
+
131
+ def _build_feature_evidence_pick(state: WizardState) -> Prompt:
132
+ return _build_analysis_evidence_pick(
133
+ state, step=S_FEATURE_EVIDENCE_PICK, relation="feature-baseline"
134
+ )
135
+
136
+
137
+ def _submit_feature_evidence_pick(state: WizardState, value: str) -> Optional[str]:
138
+ return _submit_analysis_evidence_pick(
139
+ state, value, step=S_FEATURE_EVIDENCE_PICK,
140
+ field_name="feature_evidence_path",
141
+ pending_name="feature_evidence_pending_text",
142
+ )
143
+
144
+
145
+ def _build_feature_evidence(state: WizardState) -> Prompt:
146
+ t = _p(state.workspace_root, S_FEATURE_EVIDENCE)
147
+ return Prompt(step=S_FEATURE_EVIDENCE, kind="text", label=t["label"],
148
+ echo_template=t["echo_template"])
149
+
150
+
151
+ def _submit_feature_evidence(state: WizardState, value: str) -> Optional[str]:
152
+ candidate = _accept_analysis_evidence(state, value, "feature_evidence_path")
153
+ state.feature_evidence_pending_text = False
154
+ return f"feature-evidence: {candidate.report_path}"
155
+
156
+
157
+ def _build_project_evidence_pick(state: WizardState) -> Prompt:
158
+ return _build_analysis_evidence_pick(
159
+ state, step=S_PROJECT_EVIDENCE_PICK, relation="project-context"
160
+ )
161
+
162
+
163
+ def _submit_project_evidence_pick(state: WizardState, value: str) -> Optional[str]:
164
+ return _submit_analysis_evidence_pick(
165
+ state, value, step=S_PROJECT_EVIDENCE_PICK,
166
+ field_name="project_evidence_path",
167
+ pending_name="project_evidence_pending_text",
168
+ target_after_skip=state.task_type == "feature-analysis",
169
+ )
170
+
171
+
172
+ def _build_project_evidence(state: WizardState) -> Prompt:
173
+ t = _p(state.workspace_root, S_PROJECT_EVIDENCE)
174
+ return Prompt(step=S_PROJECT_EVIDENCE, kind="text", label=t["label"],
175
+ echo_template=t["echo_template"])
176
+
177
+
178
+ def _submit_project_evidence(state: WizardState, value: str) -> Optional[str]:
179
+ candidate = _accept_analysis_evidence(state, value, "project_evidence_path")
180
+ state.project_evidence_pending_text = False
181
+ return f"project-evidence: {candidate.report_path}"
182
+
183
+
184
+ def _feature_description(feature: dict[object, object]) -> str:
185
+ name = str(feature.get("name") or "")
186
+ summary = str(feature.get("summary") or "")
187
+ entry_point = str(
188
+ feature.get("entryPoint") or feature.get("entrypoint") or ""
189
+ )
190
+ return " · ".join(value for value in (name, summary, entry_point) if value)
191
+
192
+
193
+ def _build_analysis_target_pick(state: WizardState) -> Prompt:
194
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET_PICK)
195
+ try:
196
+ candidate = load_analysis_report_candidate(
197
+ Path(state.project_root), Path(state.project_evidence_path)
198
+ )
199
+ except AnalysisInputError as exc:
200
+ raise WizardError(str(exc)) from exc
201
+ options = [
202
+ _opt(str(feature["id"]), str(feature["id"]), _feature_description(feature))
203
+ for feature in candidate.feature_index
204
+ if isinstance(feature, dict) and isinstance(feature.get("id"), str)
205
+ ][:3]
206
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
207
+ return Prompt(step=S_ANALYSIS_TARGET_PICK, kind="pick", label=t["label"],
208
+ options=options, echo_template=t["echo_template"])
209
+
210
+
211
+ def _accept_analysis_target(state: WizardState, value: str) -> str:
212
+ candidates: dict[Path, AnalysisReportCandidate] = {}
213
+ for path in _analysis_evidence_paths(state):
214
+ try:
215
+ candidate = load_analysis_report_candidate(Path(state.project_root), path)
216
+ except AnalysisInputError as exc:
217
+ raise WizardError(str(exc)) from exc
218
+ candidates[candidate.report_path] = candidate
219
+ try:
220
+ target = resolve_analysis_target(
221
+ value, _resolve_analysis_evidence(state), candidates
222
+ )
223
+ except AnalysisInputError as exc:
224
+ raise WizardError(str(exc)) from exc
225
+ requested_value = target["requestedValue"]
226
+ if not isinstance(requested_value, str):
227
+ raise WizardError("analysis target resolver returned an invalid requestedValue")
228
+ state.analysis_target = requested_value
229
+ state.analysis_target_pending_text = False
230
+ return requested_value
231
+
232
+
233
+ def _submit_analysis_target_pick(state: WizardState, value: str) -> Optional[str]:
234
+ if value == PICK_TYPE_CUSTOM:
235
+ state.analysis_target_pending_text = True
236
+ return None
237
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
238
+
239
+
240
+ def _build_analysis_target(state: WizardState) -> Prompt:
241
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET)
242
+ return Prompt(step=S_ANALYSIS_TARGET, kind="text", label=t["label"],
243
+ echo_template=t["echo_template"])
244
+
245
+
246
+ def _submit_analysis_target(state: WizardState, value: str) -> Optional[str]:
247
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
248
+
249
+
250
+ def _design_prep_items_by_id(state: WizardState) -> dict[str, dict[str, Any]]:
251
+ try:
252
+ items = load_design_prep_items(Path(state.approved_plan_path))
253
+ except DesignPrepError as exc:
254
+ raise WizardError(str(exc)) from exc
255
+ return {str(item["id"]): item for item in items}
256
+
257
+
258
+ def _selected_impl_stage_numbers(state: WizardState) -> set[int]:
259
+ numbers: set[int] = set()
260
+ for token in (state.selected_stages or state.selected_stage or "").split(","):
261
+ token = token.strip()
262
+ if token.isdigit():
263
+ numbers.add(int(token))
264
+ return numbers
265
+
266
+
267
+ def _blocking_unanswered_prep_ids(state: WizardState) -> list[str] | None:
268
+ """고른 스테이지에서 구현을 막는 unanswered blocked id. 로드 불가면 None."""
269
+ report_path = Path(state.approved_plan_path)
270
+ if not re.fullmatch(
271
+ r"final-report-implementation-planning-\d+\.data\.json",
272
+ report_path.name,
273
+ ):
274
+ return None
275
+ if not report_path.is_file():
276
+ return None
277
+ try:
278
+ items = load_design_prep_items(report_path)
279
+ effective = resolve_design_prep(report_path)
280
+ except DesignPrepError as exc:
281
+ raise WizardError(str(exc)) from exc
282
+ decisions = {
283
+ str(item["id"]): item.get("decision")
284
+ for item in effective.effective_items
285
+ }
286
+ stages = _selected_impl_stage_numbers(state)
287
+ # provisional 미응답은 구현을 막지 않는다. 작업 가정을 그대로 쓴다.
288
+ return [
289
+ str(item["id"])
290
+ for item in items
291
+ if item.get("status") == "blocked"
292
+ and decisions.get(str(item["id"])) == "unanswered"
293
+ and (
294
+ not stages
295
+ or stages.intersection(
296
+ stage for stage in (item.get("stageRefs") or [])
297
+ if isinstance(stage, int)
298
+ )
299
+ )
300
+ ]
301
+
302
+
303
+ def _prune_design_prep_session(state: WizardState, wanted: list[str]) -> None:
304
+ """이미 만든 큐에서 더 이상 막지 않는 id 를 뺀다. 새 id 는 넣지 않는다."""
305
+ wanted_set = set(wanted)
306
+ if state.design_prep_current and state.design_prep_current not in wanted_set:
307
+ state.design_prep_current = ""
308
+ state.design_prep_decision = ""
309
+ state.design_prep_overrides_json = ""
310
+ state.design_prep_notes = ""
311
+ state.design_prep_queue = [
312
+ item_id for item_id in state.design_prep_queue if item_id in wanted_set
313
+ ]
314
+ if not state.design_prep_current:
315
+ _advance_design_prep_item(state)
316
+
317
+
318
+ def _ensure_design_prep_queue(state: WizardState) -> None:
319
+ wanted = _blocking_unanswered_prep_ids(state)
320
+ if wanted is None:
321
+ return
322
+ if state.design_prep_current or state.design_prep_queue:
323
+ _prune_design_prep_session(state, wanted)
324
+ return
325
+ if S_DESIGN_PREP_DECISION in state.answered:
326
+ return
327
+ state.design_prep_queue = list(wanted)
328
+ _advance_design_prep_item(state)
329
+
330
+
331
+ def _advance_design_prep_item(state: WizardState) -> None:
332
+ state.design_prep_current = (
333
+ state.design_prep_queue.pop(0) if state.design_prep_queue else ""
334
+ )
335
+ state.design_prep_decision = ""
336
+ state.design_prep_overrides_json = ""
337
+ state.design_prep_notes = ""
338
+
339
+
340
+ def _current_design_prep_item(state: WizardState) -> dict[str, Any]:
341
+ item = _design_prep_items_by_id(state).get(state.design_prep_current)
342
+ if item is None:
343
+ raise WizardError(
344
+ f"design preparation item is missing: {state.design_prep_current}"
345
+ )
346
+ return item
347
+
348
+
349
+ def _design_prep_decision_applies(state: WizardState) -> bool:
350
+ if state.task_type != "implementation" or not state.approved_plan_path:
351
+ return False
352
+ _ensure_design_prep_queue(state)
353
+ return bool(state.design_prep_current and not state.design_prep_decision)
354
+
355
+
356
+ def _build_design_prep_decision(state: WizardState) -> Prompt:
357
+ item = _current_design_prep_item(state)
358
+ proposal = item.get("aiProposal") or {}
359
+ t = _p(
360
+ state.workspace_root,
361
+ S_DESIGN_PREP_DECISION,
362
+ item_id=str(item["id"]),
363
+ title=str(item["title"]),
364
+ stage_refs=json.dumps(item.get("stageRefs") or [], ensure_ascii=False),
365
+ proposal_summary=str(proposal.get("summary") or ""),
366
+ details=json.dumps(proposal.get("details") or [], ensure_ascii=False),
367
+ assumptions=json.dumps(proposal.get("assumptions") or [], ensure_ascii=False),
368
+ guardrails=json.dumps(item.get("guardrails") or [], ensure_ascii=False),
369
+ request_path=str(item.get("requestPath") or ""),
370
+ )
371
+ return Prompt(
372
+ step=S_DESIGN_PREP_DECISION,
373
+ kind="pick",
374
+ label=t["label"],
375
+ options=[_opt(value, label) for value, label in _static_options(t)],
376
+ echo_template=t["echo_template"],
377
+ )
378
+
379
+
380
+ def _submit_design_prep_decision(
381
+ state: WizardState,
382
+ value: str,
383
+ ) -> Optional[str]:
384
+ t = _p(state.workspace_root, S_DESIGN_PREP_DECISION,
385
+ item_id="", title="", stage_refs="", proposal_summary="",
386
+ details="", assumptions="", guardrails="", request_path="")
387
+ if value not in ("accept-draft", "modify-draft", "reject-draft", "later"):
388
+ raise WizardError(t["errors"]["invalid_decision"])
389
+ if value == "later":
390
+ item_id = state.design_prep_current
391
+ _advance_design_prep_item(state)
392
+ return t["echo_variants"]["later"].format(item_id=item_id)
393
+ state.design_prep_decision = value
394
+ state.design_prep_overrides_json = ""
395
+ state.design_prep_notes = ""
396
+ return t["echo_template"].format(value=value)
397
+
398
+
399
+ def _design_prep_overrides_applies(state: WizardState) -> bool:
400
+ if state.design_prep_decision == "modify-draft":
401
+ return not state.design_prep_overrides_json
402
+ if state.design_prep_decision == "reject-draft":
403
+ return not state.design_prep_notes
404
+ return False
405
+
406
+
407
+ def _build_design_prep_overrides(state: WizardState) -> Prompt:
408
+ t = _p(
409
+ state.workspace_root,
410
+ S_DESIGN_PREP_OVERRIDES,
411
+ item_id=state.design_prep_current,
412
+ decision=state.design_prep_decision,
413
+ )
414
+ return Prompt(
415
+ step=S_DESIGN_PREP_OVERRIDES,
416
+ kind="text",
417
+ label=t["label"],
418
+ echo_template=t["echo_template"],
419
+ )
420
+
421
+
422
+ def _submit_design_prep_overrides(
423
+ state: WizardState,
424
+ value: str,
425
+ ) -> Optional[str]:
426
+ t = _p(state.workspace_root, S_DESIGN_PREP_OVERRIDES,
427
+ item_id=state.design_prep_current,
428
+ decision=state.design_prep_decision)
429
+ if state.design_prep_decision == "reject-draft":
430
+ if not value.strip():
431
+ raise WizardError(t["errors"]["note_required"])
432
+ state.design_prep_notes = value.strip()
433
+ return t["echo_variants"]["note"]
434
+ try:
435
+ overrides = json.loads(value)
436
+ except json.JSONDecodeError as exc:
437
+ raise WizardError(t["errors"]["invalid_json"].format(error=exc)) from exc
438
+ if not isinstance(overrides, dict):
439
+ raise WizardError(t["errors"]["object_required"])
440
+ state.design_prep_overrides_json = json.dumps(
441
+ overrides, ensure_ascii=False, sort_keys=True
442
+ )
443
+ return t["echo_variants"]["overrides"]
444
+
445
+
446
+ def _design_prep_confirm_applies(state: WizardState) -> bool:
447
+ if state.task_type == "implementation" and state.approved_plan_path:
448
+ _ensure_design_prep_queue(state)
449
+ if state.design_prep_decision == "accept-draft":
450
+ return True
451
+ if state.design_prep_decision == "modify-draft":
452
+ return bool(state.design_prep_overrides_json)
453
+ if state.design_prep_decision == "reject-draft":
454
+ return bool(state.design_prep_notes)
455
+ return False
456
+
457
+
458
+ def _build_design_prep_confirm(state: WizardState) -> Prompt:
459
+ item = _current_design_prep_item(state)
460
+ t = _p(
461
+ state.workspace_root,
462
+ S_DESIGN_PREP_CONFIRM,
463
+ item_id=state.design_prep_current,
464
+ decision=state.design_prep_decision,
465
+ item_snapshot=json.dumps(item, ensure_ascii=False, indent=2, sort_keys=True),
466
+ overrides=state.design_prep_overrides_json or "{}",
467
+ notes=state.design_prep_notes or "(none)",
468
+ )
469
+ return Prompt(
470
+ step=S_DESIGN_PREP_CONFIRM,
471
+ kind="pick",
472
+ label=t["label"],
473
+ options=[_opt(value, label) for value, label in _static_options(t)],
474
+ echo_template=t["echo_template"],
475
+ )
476
+
477
+
478
+ def _submit_design_prep_confirm(
479
+ state: WizardState,
480
+ value: str,
481
+ ) -> Optional[str]:
482
+ item_id = state.design_prep_current
483
+ t = _p(state.workspace_root, S_DESIGN_PREP_CONFIRM,
484
+ item_id=item_id, decision=state.design_prep_decision,
485
+ item_snapshot="", overrides="", notes="")
486
+ if value == "no":
487
+ state.design_prep_decision = ""
488
+ state.design_prep_overrides_json = ""
489
+ state.design_prep_notes = ""
490
+ return t["echo_variants"]["revise"].format(item_id=item_id)
491
+ if value != "yes":
492
+ raise WizardError(t["errors"]["confirmation_required"])
493
+ overrides = json.loads(state.design_prep_overrides_json or "{}")
494
+ try:
495
+ path = write_design_prep_input(
496
+ Path(state.approved_plan_path),
497
+ item_id,
498
+ state.design_prep_decision,
499
+ overrides,
500
+ state.design_prep_notes,
501
+ captured_by="okstra-wizard",
502
+ )
503
+ except DesignPrepError as exc:
504
+ raise WizardError(str(exc)) from exc
505
+ match = re.search(r"-r(\d+)-([0-9a-f-]{36})\.md$", path.name)
506
+ if match is None:
507
+ raise WizardError(f"design preparation writer returned invalid path: {path}")
508
+ echo = t["echo_variants"]["written"].format(
509
+ item_id=item_id,
510
+ revision=int(match.group(1)),
511
+ input_id=match.group(2),
512
+ path=path,
513
+ )
514
+ _advance_design_prep_item(state)
515
+ return echo
516
+
517
+
518
+ def _analysis_inputs_after_reuse(state: WizardState) -> bool:
519
+ return (
520
+ state.reuse_previous is not None
521
+ or state.task_type not in _RESUME_REUSE_PHASES
522
+ or not _has_prior_run_inputs(state)
523
+ )
524
+
525
+
526
+ def _restore_analysis_evidence_inputs(
527
+ state: WizardState, evidence_raw: object
528
+ ) -> tuple[tuple[Any, ...], dict[Path, AnalysisReportCandidate]]:
529
+ if state.task_type not in ANALYSIS_TASK_TYPES or not isinstance(evidence_raw, str):
530
+ return (), {}
531
+ report_paths = [
532
+ _resolve_path(item.strip(), Path(state.project_root))
533
+ for item in evidence_raw.split(",")
534
+ if item.strip()
535
+ ]
536
+ try:
537
+ candidates = [
538
+ load_analysis_report_candidate(Path(state.project_root), path)
539
+ for path in report_paths
540
+ ]
541
+ evidence_inputs = resolve_evidence_inputs(
542
+ Path(state.project_root),
543
+ state.task_type,
544
+ [candidate.report_path for candidate in candidates],
545
+ _analysis_current_commit(state),
546
+ )
547
+ except AnalysisInputError as exc:
548
+ raise WizardError(str(exc)) from exc
549
+ state.feature_evidence_path = ""
550
+ state.project_evidence_path = ""
551
+ for evidence in evidence_inputs:
552
+ if evidence.relation == "feature-baseline":
553
+ state.feature_evidence_path = str(evidence.report_path)
554
+ elif evidence.relation == "project-context":
555
+ state.project_evidence_path = str(evidence.report_path)
556
+ return evidence_inputs, {
557
+ candidate.report_path: candidate for candidate in candidates
558
+ }