okstra 0.141.3 → 0.143.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 (51) hide show
  1. package/docs/architecture.md +11 -2
  2. package/docs/cli.md +15 -0
  3. package/docs/for-ai/skills/okstra-setup.md +8 -0
  4. package/docs/project-structure-overview.md +6 -0
  5. package/docs/task-process/error-analysis.md +9 -4
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/report-writer-worker.md +4 -2
  9. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +3 -3
  10. package/runtime/prompts/coding-preflight/overview.md +1 -1
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  12. package/runtime/prompts/lead/context-loader.md +2 -2
  13. package/runtime/prompts/lead/convergence.md +5 -2
  14. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  15. package/runtime/prompts/lead/plan-body-verification.md +20 -9
  16. package/runtime/prompts/lead/report-writer.md +4 -3
  17. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
  18. package/runtime/prompts/profiles/_common-contract.md +3 -1
  19. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
  20. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  21. package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
  22. package/runtime/prompts/profiles/error-analysis.md +5 -1
  23. package/runtime/prompts/profiles/forbidden-actions.json +0 -1
  24. package/runtime/prompts/profiles/implementation-planning.md +7 -2
  25. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  26. package/runtime/python/okstra_ctl/analysis_packet.py +28 -3
  27. package/runtime/python/okstra_ctl/brief_frontmatter.py +56 -0
  28. package/runtime/python/okstra_ctl/clarification_items.py +99 -5
  29. package/runtime/python/okstra_ctl/convergence_engine.py +66 -15
  30. package/runtime/python/okstra_ctl/paths.py +34 -7
  31. package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
  32. package/runtime/python/okstra_ctl/plan_items.py +38 -0
  33. package/runtime/python/okstra_ctl/run.py +81 -33
  34. package/runtime/python/okstra_ctl/schema_excerpt.py +5 -3
  35. package/runtime/python/okstra_ctl/wizard.py +18 -44
  36. package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
  37. package/runtime/python/okstra_ctl/workflow.py +1 -1
  38. package/runtime/python/okstra_project/resolver.py +25 -0
  39. package/runtime/schemas/final-report-v1.0.schema.json +162 -4
  40. package/runtime/skills/okstra-run/SKILL.md +3 -1
  41. package/runtime/skills/okstra-setup/SKILL.md +3 -0
  42. package/runtime/skills/okstra-setup/references/project-config.md +47 -0
  43. package/runtime/templates/reports/final-report.template.md +51 -0
  44. package/runtime/templates/reports/i18n/en.json +32 -3
  45. package/runtime/templates/reports/i18n/ko.json +32 -3
  46. package/runtime/templates/reports/implementation-input.template.md +1 -2
  47. package/runtime/templates/reports/task-brief.template.md +1 -1
  48. package/runtime/validators/validate-brief.py +5 -1
  49. package/runtime/validators/validate-run.py +430 -48
  50. package/src/cli-registry.mjs +10 -0
  51. package/src/commands/execute/phase-cleanup.mjs +38 -0
@@ -66,6 +66,19 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
66
66
  return max(found, key=lambda p: (p.stat().st_mtime, p.name))
67
67
 
68
68
 
69
+ def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
70
+ """한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
71
+
72
+ stage-isolated task-type(implementation / final-verification)만 stage 하위를
73
+ 갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
74
+ `latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
75
+ """
76
+ dirs = [type_dir / "reports"]
77
+ if include_stages and type_dir.name in _STAGED_TASK_TYPES:
78
+ dirs += sorted(type_dir.glob("stage-*/reports"))
79
+ return dirs
80
+
81
+
69
82
  def _project_root_of(task_root: Path) -> Optional[Path]:
70
83
  """canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
71
84
 
@@ -257,34 +270,48 @@ class RunRef:
257
270
  task_group: str,
258
271
  task_id: str,
259
272
  task_types: Optional[tuple[str, ...]] = None,
273
+ *,
274
+ include_stages: bool = False,
260
275
  ) -> Optional["RunRef"]:
261
276
  """여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
262
277
 
263
278
  `task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
264
279
  훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
280
+
281
+ `include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
282
+ 도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
265
283
  """
266
284
  return cls.latest_under(
267
- task_dir(project_root, task_group, task_id), task_types
285
+ task_dir(project_root, task_group, task_id),
286
+ task_types,
287
+ include_stages=include_stages,
268
288
  )
269
289
 
270
290
  @classmethod
271
291
  def latest_under(
272
- cls, task_root: Path, task_types: Optional[tuple[str, ...]] = None
292
+ cls,
293
+ task_root: Path,
294
+ task_types: Optional[tuple[str, ...]] = None,
295
+ *,
296
+ include_stages: bool = False,
273
297
  ) -> Optional["RunRef"]:
274
298
  """`latest_across` 의 task_root 진입점.
275
299
 
276
300
  task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
277
- (bash resume-clarification)가 쓴다.
301
+ (bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
302
+ 넘기므로 기본값(`include_stages=False`)이 그 범위를 그대로 보존한다.
278
303
  """
279
304
  runs_dir = runs_dir_of(task_root)
280
305
  if task_types is None:
281
306
  if not runs_dir.is_dir():
282
307
  return None
283
308
  task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
284
- candidates = [
285
- found for task_type in task_types
286
- if (found := _newest_report(runs_dir / task_type / "reports")) is not None
287
- ]
309
+ candidates: list[Path] = []
310
+ for task_type in task_types:
311
+ for reports_dir in _report_dirs(runs_dir / task_type, include_stages):
312
+ found = _newest_report(reports_dir)
313
+ if found is not None:
314
+ candidates.append(found)
288
315
  if not candidates:
289
316
  return None
290
317
  return cls.from_report_path(
@@ -0,0 +1,235 @@
1
+ """Phase-transition resource cleanup orchestrator.
2
+
3
+ Reuses the existing pane-reclaim (okstra-trace-cleanup.sh) and teammate-reconcile
4
+ (okstra-team-reconcile.sh) primitives; this module only decides tmux-vs-in-process
5
+ mode, finds the prior run dir, and sequences the two scripts. It never
6
+ re-implements pane kill or completion detection.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Callable, Optional
16
+
17
+ from okstra_project import StateError, parse_task_key
18
+
19
+ from .paths import RunRef, okstra_home
20
+ from .tmux import resolve_caller_pane, tmux_available
21
+
22
+ _TRACE_SCRIPT = "okstra-trace-cleanup.sh"
23
+ _RECONCILE_SCRIPT = "okstra-team-reconcile.sh"
24
+ _MODE_TMUX = "tmux"
25
+ _MODE_IN_PROCESS = "in-process"
26
+ _RUNNER_TIMEOUT_SECONDS = 10
27
+
28
+
29
+ def _recorded_lead_pane(prev_run_dir: Optional[Path]) -> Optional[str]:
30
+ """The pane the prior run's lead recorded, or None when unrecorded.
31
+
32
+ '<run_dir>/state/lead-pane.id' is written once at run start by the lead
33
+ adapter: a non-empty pane id means that run was inside a tmux pane, an empty
34
+ (0-byte) file means the run resolved to in-process. This on-disk fact
35
+ outlives the cleanup process's own context, which -- when okstra runs as a
36
+ daemon child -- can no longer walk its ancestry back to a pane.
37
+ """
38
+ if prev_run_dir is None:
39
+ return None
40
+ try:
41
+ return (prev_run_dir / "state" / "lead-pane.id").read_text().strip()
42
+ except OSError:
43
+ return None
44
+
45
+
46
+ def resolve_mode(
47
+ *,
48
+ prev_run_dir: Optional[Path] = None,
49
+ pane_probe: Callable[[], str] = resolve_caller_pane,
50
+ tmux_probe: Callable[[], bool] = tmux_available,
51
+ ) -> str:
52
+ """'tmux' when the prior run had a pane to reclaim, else 'in-process'.
53
+
54
+ The prior run's recorded lead pane is authoritative: it captured that run's
55
+ tmux-ness at run start and survives a cleanup process that can no longer walk
56
+ its ancestry to a pane (daemon child). Only when nothing was recorded does
57
+ this fall back to the live ancestor-walk probe.
58
+ """
59
+ recorded = _recorded_lead_pane(prev_run_dir)
60
+ if recorded is not None:
61
+ return _MODE_TMUX if recorded else _MODE_IN_PROCESS
62
+ if pane_probe():
63
+ return _MODE_TMUX
64
+ if prev_run_dir is not None and tmux_probe():
65
+ # A prior run exists to reclaim from, but neither its recording nor the
66
+ # live probe could confirm tmux -- yet tmux is reachable. Surface the
67
+ # ambiguity instead of silently skipping pane reclaim.
68
+ sys.stderr.write(
69
+ "phase-cleanup: prior run has no recorded lead pane and no caller "
70
+ "pane could be resolved, but tmux is reachable; treating as "
71
+ "in-process and skipping pane reclaim\n"
72
+ )
73
+ return _MODE_IN_PROCESS
74
+
75
+
76
+ def resolve_prev_run_dir(
77
+ *,
78
+ run_dir: Optional[str],
79
+ project_root: Path,
80
+ task_group: str,
81
+ task_id: str,
82
+ ) -> Optional[Path]:
83
+ """Explicit --run-dir wins; otherwise the newest completed run across phases.
84
+
85
+ Auto-discovery walks both FLAT runs/<type>/reports/ and staged
86
+ runs/<type>/stage-N/reports/ (implementation / final-verification), so a
87
+ staged prior run is found without an explicit run_dir; --run-dir still
88
+ overrides when a specific prior run is wanted.
89
+ """
90
+ if run_dir:
91
+ return Path(run_dir)
92
+ ref = RunRef.latest_across(
93
+ Path(project_root), task_group, task_id, include_stages=True
94
+ )
95
+ return ref.run_dir if ref is not None else None
96
+
97
+
98
+ def _default_runner(cmd: list[str]) -> str:
99
+ # Cleanup must never block the next phase, so a spawn failure or a missing
100
+ # script degrades to "nothing to report" instead of propagating.
101
+ try:
102
+ proc = subprocess.run(
103
+ cmd,
104
+ capture_output=True,
105
+ text=True,
106
+ check=False,
107
+ timeout=_RUNNER_TIMEOUT_SECONDS,
108
+ )
109
+ return proc.stdout
110
+ except (OSError, subprocess.SubprocessError):
111
+ return ""
112
+
113
+
114
+ def _script_path(name: str) -> str:
115
+ # repo layout: scripts/<name>; installed layout: ~/.okstra/bin/<name>
116
+ candidates = [
117
+ Path(__file__).resolve().parent.parent / name,
118
+ okstra_home() / "bin" / name,
119
+ ]
120
+ for candidate in candidates:
121
+ if candidate.is_file():
122
+ return str(candidate)
123
+ return str(candidates[-1]) # a missing script is tolerated by _default_runner
124
+
125
+
126
+ def _reclaim_panes(prev_run_dir: Path, runner: Callable[[list[str]], str]) -> int:
127
+ listing = runner(
128
+ [
129
+ _script_path(_TRACE_SCRIPT),
130
+ "--run-dir",
131
+ str(prev_run_dir),
132
+ "--reclaim-completed",
133
+ "--list",
134
+ ]
135
+ )
136
+ count = len([ln for ln in listing.splitlines() if ln.strip()])
137
+ runner(
138
+ [_script_path(_TRACE_SCRIPT), "--run-dir", str(prev_run_dir), "--reclaim-completed"]
139
+ )
140
+ return count
141
+
142
+
143
+ def _dismissible_teammates(
144
+ project_root: Path,
145
+ runner: Callable[[list[str]], str],
146
+ fallback_team: str = "",
147
+ ) -> list[str]:
148
+ # The live team dir is keyed by the current session id, which Claude Code
149
+ # re-issues on resume/compaction; without the caller's label the resolver
150
+ # finds no live roster and reports nothing to dismiss.
151
+ cmd = [_script_path(_RECONCILE_SCRIPT), "--project-root", str(project_root)]
152
+ if fallback_team:
153
+ cmd += ["--fallback-team", fallback_team]
154
+ out = runner(cmd)
155
+ prefix = "dismissible-member:"
156
+ return [ln[len(prefix):].strip() for ln in out.splitlines() if ln.startswith(prefix)]
157
+
158
+
159
+ def run_cleanup(
160
+ *,
161
+ mode: str,
162
+ prev_run_dir: Optional[Path],
163
+ project_root: Path,
164
+ runner: Callable[[list[str]], str] = _default_runner,
165
+ fallback_team: str = "",
166
+ ) -> dict:
167
+ # Cleanup is performed here (real pane kill + reconcile), not merely logged;
168
+ # the CLI's execution IS the enforcement (a caller cannot emit a "cleaned"
169
+ # line without this actually running).
170
+ panes = 0
171
+ if mode == _MODE_TMUX and prev_run_dir is not None:
172
+ panes = _reclaim_panes(prev_run_dir, runner)
173
+ teammates = _dismissible_teammates(project_root, runner, fallback_team)
174
+ return {"mode": mode, "panesReclaimed": panes, "dismissibleTeammates": teammates}
175
+
176
+
177
+ def main(argv: list[str]) -> int:
178
+ ap = argparse.ArgumentParser(prog="okstra phase-cleanup")
179
+ ap.add_argument(
180
+ "--task-key",
181
+ help="finds the newest completed run across phases, including staged "
182
+ "(implementation / final-verification) stage-N runs",
183
+ )
184
+ ap.add_argument("--run-dir")
185
+ ap.add_argument("--project-root", required=True)
186
+ ap.add_argument(
187
+ "--fallback-team",
188
+ default="",
189
+ help="team label to resolve the roster from when the live session dir is "
190
+ "gone (session id re-issued by resume/compaction)",
191
+ )
192
+ ap.add_argument("--json", action="store_true")
193
+ args = ap.parse_args(argv)
194
+
195
+ task_group = task_id = ""
196
+ if args.task_key:
197
+ try:
198
+ _pid, task_group, task_id = parse_task_key(args.task_key)
199
+ except StateError as exc:
200
+ sys.stderr.write(f"phase-cleanup: {exc}\n")
201
+ return 0
202
+
203
+ prev = resolve_prev_run_dir(
204
+ run_dir=args.run_dir,
205
+ project_root=Path(args.project_root),
206
+ task_group=task_group,
207
+ task_id=task_id,
208
+ )
209
+ if prev is None and not args.run_dir:
210
+ sys.stderr.write(
211
+ "phase-cleanup: no prior run found for auto-discovery "
212
+ "(normal on a task's first phase); pass --run-dir to point at a "
213
+ "specific prior run\n"
214
+ )
215
+ result = run_cleanup(
216
+ mode=resolve_mode(prev_run_dir=prev),
217
+ prev_run_dir=prev,
218
+ project_root=Path(args.project_root),
219
+ fallback_team=args.fallback_team,
220
+ )
221
+ if args.json:
222
+ sys.stdout.write(json.dumps(result) + "\n")
223
+ else:
224
+ sys.stdout.write(f"mode: {result['mode']}\n")
225
+ sys.stdout.write(f"panes-reclaimed: {result['panesReclaimed']}\n")
226
+ sys.stdout.write(
227
+ "dismissible-teammates: "
228
+ + ", ".join(result["dismissibleTeammates"])
229
+ + "\n"
230
+ )
231
+ return 0 # cleanup never blocks the next phase
232
+
233
+
234
+ if __name__ == "__main__":
235
+ raise SystemExit(main(sys.argv[1:]))
@@ -189,12 +189,50 @@ def _extract_prep_items(
189
189
  )
190
190
 
191
191
 
192
+ def _extract_variation_point_items(
193
+ planning: Mapping[str, Any], items: list[dict[str, Any]]
194
+ ) -> None:
195
+ analysis = planning.get("variationPointAnalysis")
196
+ if not isinstance(analysis, Mapping):
197
+ raise PlanItemContractError("variationPointAnalysis must be an object")
198
+ points = analysis.get("points") or []
199
+ if not analysis.get("hasMultipleImplementations") or not points:
200
+ _add_item(
201
+ items,
202
+ {
203
+ "id": "P-Var-0",
204
+ "subject": "No variation point declared",
205
+ "sourceSection": "5.5.11",
206
+ "ticketId": "",
207
+ "payload": _row_payload(dict(analysis)),
208
+ },
209
+ )
210
+ return
211
+ for index, point in enumerate(points, start=1):
212
+ if not isinstance(point, Mapping):
213
+ raise PlanItemContractError(
214
+ "variationPointAnalysis.points row must be an object"
215
+ )
216
+ _add_item(
217
+ items,
218
+ {
219
+ "id": f"P-Var-{index}",
220
+ "subject": _non_empty_string(point.get("behavior"))
221
+ or f"variation point {index}",
222
+ "sourceSection": "5.5.11",
223
+ "ticketId": "",
224
+ "payload": _row_payload(dict(point)),
225
+ },
226
+ )
227
+
228
+
192
229
  def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[str, Any]]:
193
230
  """Return deterministic, lossless P-* items in contract order."""
194
231
  if not isinstance(implementation_planning, Mapping):
195
232
  raise PlanItemContractError("implementationPlanning must be an object")
196
233
  items = _extract_standard_items(implementation_planning)
197
234
  _extract_prep_items(implementation_planning, items)
235
+ _extract_variation_point_items(implementation_planning, items)
198
236
  return items
199
237
 
200
238
 
@@ -102,6 +102,10 @@ from .worktree import (
102
102
  WorktreeProvision,
103
103
  provision_task_worktree,
104
104
  )
105
+ from .brief_frontmatter import (
106
+ has_reporter_confirmation_contract,
107
+ read_brief_frontmatter,
108
+ )
105
109
 
106
110
  # Frontmatter approval-flag matcher.
107
111
  #
@@ -854,6 +858,7 @@ class _ResolvedAssets:
854
858
  final_report_template: Path
855
859
  lead_contract: Path
856
860
  run_validator: Path
861
+ brief_validator: Path
857
862
 
858
863
 
859
864
  def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _ResolvedAssets:
@@ -877,7 +882,14 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
877
882
  )
878
883
  lead_contract = workspace_root / "prompts" / "lead" / "okstra-lead-contract.md"
879
884
  run_validator = workspace_root / "validators" / "validate-run.py"
880
- for required in (task_index_template, final_report_template, run_validator, lead_contract):
885
+ brief_validator = workspace_root / "validators" / "validate-brief.py"
886
+ for required in (
887
+ task_index_template,
888
+ final_report_template,
889
+ run_validator,
890
+ brief_validator,
891
+ lead_contract,
892
+ ):
881
893
  if not required.is_file():
882
894
  raise PrepareError(
883
895
  f"required okstra template or lead contract missing: {required}.{_INSTALL_HINT}"
@@ -889,12 +901,49 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
889
901
  final_report_template=final_report_template,
890
902
  lead_contract=lead_contract,
891
903
  run_validator=run_validator,
904
+ brief_validator=brief_validator,
892
905
  )
893
906
 
894
907
 
908
+ def _validate_task_brief_preflight(
909
+ project_root: Path,
910
+ brief_path: Path,
911
+ validator_path: Path,
912
+ ) -> None:
913
+ """Validate canonical briefs before any prepare-time side effects."""
914
+ frontmatter = read_brief_frontmatter(brief_path)
915
+ if not has_reporter_confirmation_contract(frontmatter):
916
+ return
917
+
918
+ proc = _subprocess.run(
919
+ [
920
+ sys.executable,
921
+ str(validator_path),
922
+ str(brief_path),
923
+ "--briefs-root",
924
+ str(project_root / ".okstra" / "briefs"),
925
+ ],
926
+ capture_output=True,
927
+ text=True,
928
+ check=False,
929
+ )
930
+ if proc.returncode != 0:
931
+ detail = " ".join(
932
+ line.strip()
933
+ for output in (proc.stdout, proc.stderr)
934
+ for line in output.splitlines()
935
+ if line.strip()
936
+ )
937
+ raise PrepareError(f"task brief failed validation: {detail}")
938
+ if frontmatter["reporter-confirmations"] == "pending":
939
+ raise PrepareError(
940
+ "task brief reporter-confirmations is pending; rerun okstra-brief-gen "
941
+ "Step 6.5 before starting error-analysis"
942
+ )
943
+
944
+
895
945
  def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
896
- """project_root/brief 존재와 task-type 별 입력 의미(plan 승인·stage·clarification)
897
- 를 검증하고, implementation 일 때 stage map 을 파싱해 돌려준다 (그 외엔 빈 리스트)."""
946
+ """Validate pure prepare inputs and return a final-verification stage map."""
898
947
  if not project_root.is_dir():
899
948
  raise PrepareError(f"project root not found: {project_root}")
900
949
  if inp.stages and inp.task_type != "release-handoff":
@@ -911,41 +960,14 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
911
960
  raise PrepareError(f"task brief not found: {inp.brief_path}")
912
961
  ctx_stage_map: list = []
913
962
  # implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
914
- # 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope). plan-presence +
915
- # stage-map 파싱은 공유하고, frontmatter 승인/option 주입/구조 검증 같은
916
- # implementation 전용 단계만 따로 게이트한다.
963
+ # 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope).
917
964
  if inp.task_type in ("implementation", "final-verification"):
918
965
  if not inp.approved_plan_path:
919
966
  raise PrepareError(
920
967
  f"task-type {inp.task_type} requires "
921
968
  "--approved-plan <path-to-final-report.md>"
922
969
  )
923
- if inp.task_type == "implementation":
924
- # --approve / --implementation-option 은 공유 approved-plan 파일에
925
- # read-modify-write 한다. 같은 task-key 의 동시 stage run 둘이 둘 다
926
- # 이 플래그를 주면 후발 write 가 선발의 frontmatter 변경을 덮어쓴다
927
- # (lost update). 두 mutation 을 per-task-key 락으로 직렬화한다.
928
- if inp.approve_plan_ack or inp.implementation_option:
929
- with worktree_provision_mutex(
930
- okstra_home(), inp.project_id,
931
- slugify(inp.task_group), slugify(inp.task_id),
932
- ):
933
- if inp.approve_plan_ack:
934
- # 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로
935
- # 모델링한다. frontmatter approved 를 true 로 toggle 한 뒤
936
- # 동일한 검증 경로(`_validate_approved_plan`)를 통과시킨다.
937
- _apply_cli_approval(inp.approved_plan_path)
938
- if inp.implementation_option:
939
- # 유저가 고른 Option Candidate 이름을 approved-plan
940
- # frontmatter 의 `implementation-option:` 라인에 기록한다.
941
- # 빈 값이면 implementation 이 plan 의 `Recommended Option`
942
- # 으로 폴백하므로 호출하지 않는다.
943
- _apply_cli_implementation_option(
944
- inp.approved_plan_path, inp.implementation_option
945
- )
946
- _validate_approved_plan(inp.approved_plan_path)
947
- _validate_stage_structure(inp.approved_plan_path)
948
- else:
970
+ if inp.task_type == "final-verification":
949
971
  # final-verification 에서 --approve / --implementation-option 은
950
972
  # 의미가 없다 (승인은 implementation 진입 시 이미 끝났다).
951
973
  if inp.approve_plan_ack:
@@ -958,7 +980,7 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
958
980
  "--implementation-option is only meaningful with --task-type "
959
981
  "implementation and --approved-plan <path>"
960
982
  )
961
- ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
983
+ ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
962
984
  else:
963
985
  if inp.approve_plan_ack:
964
986
  # implementation 외 task-type 에서 `--approve` 는 의미가 없다. 사용자에게
@@ -988,6 +1010,24 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
988
1010
  return ctx_stage_map
989
1011
 
990
1012
 
1013
+ def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1014
+ """Apply approved-plan inputs only after canonical brief preflight succeeds."""
1015
+ if inp.approve_plan_ack or inp.implementation_option:
1016
+ with worktree_provision_mutex(
1017
+ okstra_home(), inp.project_id,
1018
+ slugify(inp.task_group), slugify(inp.task_id),
1019
+ ):
1020
+ if inp.approve_plan_ack:
1021
+ _apply_cli_approval(inp.approved_plan_path)
1022
+ if inp.implementation_option:
1023
+ _apply_cli_implementation_option(
1024
+ inp.approved_plan_path, inp.implementation_option
1025
+ )
1026
+ _validate_approved_plan(inp.approved_plan_path)
1027
+ _validate_stage_structure(inp.approved_plan_path)
1028
+ return _parse_stage_map_into_ctx(inp.approved_plan_path)
1029
+
1030
+
991
1031
  def _collect_handoff_source_report_rows(
992
1032
  rows: list, nums: list,
993
1033
  ) -> list:
@@ -2072,6 +2112,14 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2072
2112
  task_index_template = assets.task_index_template
2073
2113
  final_report_template = assets.final_report_template
2074
2114
  ctx_stage_map = _validate_prepare_inputs(project_root, inp)
2115
+ if inp.task_type != "release-handoff":
2116
+ _validate_task_brief_preflight(
2117
+ project_root,
2118
+ inp.brief_path,
2119
+ assets.brief_validator,
2120
+ )
2121
+ if inp.task_type == "implementation":
2122
+ ctx_stage_map = _prepare_implementation_approved_plan(inp)
2075
2123
 
2076
2124
  # release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
2077
2125
  # 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
@@ -1,8 +1,9 @@
1
1
  """Build a task-type-scoped excerpt of the final-report schema.
2
2
 
3
3
  The full schema (``schemas/final-report-v1.0.schema.json``) carries the
4
- deliverable property blocks for ALL task-types (``implementationPlanning``,
5
- ``releaseHandoff``, ``implementation``, ``finalVerification``) plus a
4
+ deliverable property blocks for ALL task-types (``errorAnalysis``,
5
+ ``implementationPlanning``, ``releaseHandoff``, ``implementation``,
6
+ ``finalVerification``) plus a
6
7
  ``$defs`` library (~38% of the file) shared across them. A single run only
7
8
  authors ONE task-type's data.json, so the report-writer worker only needs
8
9
  the common structure + its own task-type's block + the ``$defs`` those
@@ -24,10 +25,11 @@ import json
24
25
  import re
25
26
 
26
27
  # task-type → the per-type deliverable property key it owns. task-types
27
- # absent from this map (requirements-discovery, error-analysis,
28
+ # absent from this map (requirements-discovery,
28
29
  # improvement-discovery) have no per-type block; their excerpt keeps only
29
30
  # the common properties.
30
31
  _TASK_TYPE_PROPERTY = {
32
+ "error-analysis": "errorAnalysis",
31
33
  "implementation-planning": "implementationPlanning",
32
34
  "release-handoff": "releaseHandoff",
33
35
  "implementation": "implementation",
@@ -30,6 +30,7 @@ from pathlib import Path
30
30
  from typing import Any, Callable, Optional
31
31
 
32
32
  from okstra_ctl.ids import slugify_task_segment
33
+ from okstra_ctl.brief_frontmatter import read_brief_frontmatter
33
34
  from okstra_ctl.models import (
34
35
  PROVIDER_MAPPINGS,
35
36
  UnknownModelError,
@@ -155,49 +156,6 @@ _RECENT_PREFIX = "__recent:"
155
156
  _REPORT_PREFIX = "__report:"
156
157
  _BRIEF_PREFIX = "__brief:"
157
158
 
158
- # Lines of `key: value` we pull from a brief markdown frontmatter. The
159
- # parser is intentionally lightweight (no yaml dep) and tolerant — a
160
- # malformed brief returns an empty dict.
161
- _BRIEF_FRONTMATTER_LINE_RE = re.compile(r"^([a-zA-Z0-9_\-]+)\s*:\s*(.*)$")
162
-
163
-
164
- def _parse_brief_frontmatter(path: Path) -> dict[str, str]:
165
- """Read the YAML-style frontmatter at the top of a brief markdown file
166
- and return a flat ``{key: value}`` map.
167
-
168
- Returns ``{}`` if the file is unreadable, has no frontmatter, or the
169
- frontmatter is malformed. Comments (``# ...``) and quoted values are
170
- stripped. Placeholder values like ``<task-group>`` are kept verbatim;
171
- callers decide whether to treat them as a real suggestion.
172
- """
173
- try:
174
- text = path.read_text(encoding="utf-8")
175
- except OSError:
176
- return {}
177
- if not text.startswith("---"):
178
- return {}
179
- lines = text.splitlines()
180
- if not lines or lines[0].strip() != "---":
181
- return {}
182
- out: dict[str, str] = {}
183
- for line in lines[1:]:
184
- if line.strip() == "---":
185
- break
186
- # strip trailing inline comment
187
- comment_idx = line.find("#")
188
- if comment_idx >= 0:
189
- line = line[:comment_idx]
190
- m = _BRIEF_FRONTMATTER_LINE_RE.match(line.strip())
191
- if not m:
192
- continue
193
- key, val = m.group(1), m.group(2).strip()
194
- # strip matching quotes
195
- if (len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"')):
196
- val = val[1:-1]
197
- out[key] = val
198
- return out
199
-
200
-
201
159
  def _looks_like_template_placeholder(value: str) -> bool:
202
160
  """Treat ``<task-group>``, ``<...>``, empty strings, and ``self`` as
203
161
  non-suggestions. Anything else (a real slug-like value) is honored."""
@@ -223,7 +181,7 @@ def _brief_suggestions(path: Path) -> tuple[str, str]:
223
181
  A brief without frontmatter, or with placeholder values, yields two
224
182
  empty strings — callers fall back to plain-text input.
225
183
  """
226
- fm = _parse_brief_frontmatter(path)
184
+ fm = read_brief_frontmatter(path)
227
185
  tg_raw = fm.get("task-group", "")
228
186
  bid_raw = fm.get("brief-id", "")
229
187
  tg = "" if _looks_like_template_placeholder(tg_raw) else tg_raw
@@ -2402,6 +2360,15 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
2402
2360
  stages=state.handoff_stages)
2403
2361
 
2404
2362
 
2363
+ def _same_file(a: Path, b_str: str) -> bool:
2364
+ """Whether two paths resolve to the same file. False when either path
2365
+ cannot be resolved (missing intermediate dir, permission, etc.)."""
2366
+ try:
2367
+ return a.resolve() == Path(b_str).resolve()
2368
+ except OSError:
2369
+ return False
2370
+
2371
+
2405
2372
  def _suggest_latest_final_report(state: WizardState) -> str:
2406
2373
  """clarification carry-in 으로 추천할 직전 final-report 의 relpath.
2407
2374
 
@@ -2436,6 +2403,13 @@ def _suggest_latest_final_report(state: WizardState) -> str:
2436
2403
  best = _newest("*/reports/final-report-*.md")
2437
2404
  if best is None:
2438
2405
  return ""
2406
+ # The approved plan is already wired via --approved-plan. On the first run
2407
+ # of an approved-plan consuming phase (final-verification / implementation)
2408
+ # the newest cross-phase report IS that plan, so the fallback would
2409
+ # re-recommend it as a clarification answer — injecting it twice, read as a
2410
+ # user clarification it is not. Never carry the approved plan back in here.
2411
+ if state.approved_plan_path and _same_file(best, state.approved_plan_path):
2412
+ return ""
2439
2413
  try:
2440
2414
  return str(best.relative_to(Path(state.project_root)))
2441
2415
  except ValueError: