okstra 0.38.1 → 0.40.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 (80) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/kr/architecture.md +18 -2
  4. package/docs/kr/cli.md +1 -1
  5. package/docs/project-structure-overview.md +2 -3
  6. package/docs/superpowers/plans/2026-06-02-final-verification-protocol-hardening.md +326 -0
  7. package/docs/superpowers/plans/2026-06-02-okstra-run-branch-confirm-step.md +337 -0
  8. package/docs/superpowers/plans/2026-06-02-okstra-run-phase-pane-cleanup.md +410 -0
  9. package/docs/superpowers/plans/2026-06-02-requirements-discovery-fanout.md +728 -0
  10. package/docs/superpowers/specs/2026-06-02-okstra-run-branch-confirm-step-design.md +113 -0
  11. package/docs/superpowers/specs/2026-06-02-okstra-run-phase-pane-cleanup-design.md +173 -0
  12. package/docs/superpowers/specs/2026-06-02-requirements-discovery-fanout-design.md +154 -0
  13. package/docs/task-process/requirements-discovery.md +1 -1
  14. package/package.json +3 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/{python → bin}/lib/okstra/usage.sh +3 -2
  17. package/runtime/bin/okstra-codex-exec.sh +3 -3
  18. package/runtime/bin/okstra-trace-cleanup.sh +64 -26
  19. package/runtime/prompts/profiles/_common-contract.md +9 -5
  20. package/runtime/prompts/profiles/final-verification.md +18 -16
  21. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  22. package/runtime/prompts/profiles/requirements-discovery.md +18 -1
  23. package/runtime/prompts/wizard/prompts.ko.json +11 -0
  24. package/runtime/python/okstra_ctl/consumers.py +1 -1
  25. package/runtime/python/okstra_ctl/fanout.py +35 -0
  26. package/runtime/python/okstra_ctl/migrate.py +21 -42
  27. package/runtime/python/okstra_ctl/reconcile.py +2 -2
  28. package/runtime/python/okstra_ctl/render_final_report.py +0 -1
  29. package/runtime/python/okstra_ctl/run.py +0 -29
  30. package/runtime/python/okstra_ctl/run_context.py +9 -12
  31. package/runtime/python/okstra_ctl/seeding.py +0 -192
  32. package/runtime/python/okstra_ctl/wizard.py +70 -5
  33. package/runtime/python/okstra_ctl/work_categories.py +21 -0
  34. package/runtime/python/okstra_ctl/worktree.py +74 -77
  35. package/runtime/python/okstra_project/__init__.py +0 -6
  36. package/runtime/python/okstra_project/dirs.py +0 -8
  37. package/runtime/schemas/final-report-v1.0.schema.json +34 -27
  38. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  39. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  40. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  41. package/runtime/skills/okstra-run/SKILL.md +2 -0
  42. package/runtime/templates/prd/brief.template.md +1 -1
  43. package/runtime/templates/reports/fan-out-unit.template.md +25 -0
  44. package/runtime/templates/reports/final-report.template.md +24 -13
  45. package/runtime/templates/reports/final-verification-input.template.md +16 -5
  46. package/runtime/templates/reports/i18n/en.json +6 -3
  47. package/runtime/templates/reports/i18n/ko.json +6 -3
  48. package/runtime/templates/worker-prompt-preamble.md +7 -0
  49. package/runtime/validators/lib/fixtures.sh +2 -2
  50. package/runtime/validators/lib/validate-assets.sh +9 -0
  51. package/runtime/validators/validate-implementation-plan-stages.py +19 -11
  52. package/runtime/validators/validate-run.py +114 -0
  53. package/runtime/validators/validate-schedule.py +4 -4
  54. package/runtime/validators/validate_fanout.py +99 -0
  55. package/src/_proc.mjs +31 -0
  56. package/src/check-project.mjs +1 -25
  57. package/src/config.mjs +7 -31
  58. package/src/doctor.mjs +10 -29
  59. package/src/install.mjs +8 -36
  60. package/src/migrate.mjs +1 -18
  61. package/src/okstra-dirs.mjs +0 -11
  62. package/src/setup.mjs +1 -154
  63. package/src/uninstall.mjs +6 -13
  64. package/runtime/templates/okstra.CLAUDE.md +0 -104
  65. /package/runtime/{python → bin}/lib/okstra/cli.sh +0 -0
  66. /package/runtime/{python → bin}/lib/okstra/globals.sh +0 -0
  67. /package/runtime/{python → bin}/lib/okstra/interactive.sh +0 -0
  68. /package/runtime/{python → bin}/lib/okstra/project-resolver.sh +0 -0
  69. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-batch.sh +0 -0
  70. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-list.sh +0 -0
  71. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-open.sh +0 -0
  72. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-projects.sh +0 -0
  73. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reconcile.sh +0 -0
  74. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reindex.sh +0 -0
  75. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-rerun.sh +0 -0
  76. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-show.sh +0 -0
  77. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-tail.sh +0 -0
  78. /package/runtime/{python → bin}/lib/okstra-ctl/main.sh +0 -0
  79. /package/runtime/{python → bin}/lib/okstra-ctl/prepare.sh +0 -0
  80. /package/runtime/{python → bin}/lib/okstra-ctl/usage.sh +0 -0
@@ -0,0 +1,728 @@
1
+ # requirements-discovery 도메인 fan-out 구현 계획
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** requirements-discovery 가 혼합 요청을 도메인별 packet 으로 분해해 fan-out 하고, 그 packet 들을 okstra-run 이 다시 소비하는 루프를 okstra-run 내부에서 완결한다.
6
+
7
+ **Architecture:** requirements-discovery 가 다항목/다도메인 요청을 N개 packet(`.okstra/tasks/<g>/<id>/runs/requirements-discovery/fan-out/unit-*.md`)으로 직접 생산한다. 각 packet 은 `--task-brief <경로>` 로 okstra-run 에 그대로 투입돼 새 task-key 가 된다. 검증은 improvement-discovery 패턴(SSOT 모듈 + 전용 validator + validate-run 훅)을 mirror 한다. okstra-brief 는 개입하지 않는다.
8
+
9
+ **Tech Stack:** Python 3 (pytest), 마크다운 템플릿/프로필, bash e2e.
10
+
11
+ 설계 근거: [docs/superpowers/specs/2026-06-02-requirements-discovery-fanout-design.md](../specs/2026-06-02-requirements-discovery-fanout-design.md)
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - `scripts/okstra_ctl/work_categories.py` (신설) — 5-enum domain SSOT.
18
+ - `scripts/okstra_ctl/fanout.py` (신설) — depends-on DAG 위상정렬 + 순환검출.
19
+ - `validators/validate_fanout.py` (신설) — packet + index 계약 검증.
20
+ - `validators/validate-run.py` (수정) — requirements-discovery 일 때 fan-out 훅 호출.
21
+ - `templates/reports/fan-out-unit.template.md` (신설) — packet 템플릿.
22
+ - `prompts/profiles/requirements-discovery.md` (수정) — fan-out 계약 선언 + Non-goals.
23
+ - `prompts/profiles/requirements-discovery.md` / `skills/okstra-context-loader/SKILL.md` / `skills/okstra-inspect/SKILL.md` (수정) — `ops-change` → `ops` 교정.
24
+ - `tests/test_work_categories.py`, `tests/test_fanout.py`, `tests/test_validate_fanout.py` (신설).
25
+ - `tests-e2e/scenario-<id>-requirements-discovery-fanout.sh` (신설).
26
+ - `docs/kr/architecture.md`, `CHANGES.md` (수정).
27
+
28
+ ---
29
+
30
+ ## Task 1: work-category(domain) SSOT 모듈
31
+
32
+ **Files:**
33
+ - Create: `scripts/okstra_ctl/work_categories.py`
34
+ - Test: `tests/test_work_categories.py`
35
+
36
+ - [ ] **Step 1: 실패하는 테스트 작성**
37
+
38
+ ```python
39
+ # tests/test_work_categories.py
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ REPO_ROOT = Path(__file__).resolve().parents[1]
44
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
45
+
46
+ from okstra_ctl.work_categories import WORK_CATEGORIES, is_valid_category # noqa: E402
47
+
48
+
49
+ def test_enum_is_exactly_five_canonical_categories():
50
+ assert WORK_CATEGORIES == ("bugfix", "feature", "refactor", "ops", "improvement")
51
+
52
+
53
+ def test_is_valid_category():
54
+ assert is_valid_category("bugfix")
55
+ assert not is_valid_category("ops-change") # 비표준 철자 거부
56
+ assert not is_valid_category("")
57
+
58
+
59
+ def test_ssot_no_ops_change_in_code_or_profile():
60
+ """SSOT 도입 후 코드/프로필에 'ops-change' 가 남아있으면 fail (drift 가드)."""
61
+ targets = [
62
+ REPO_ROOT / "prompts" / "profiles" / "requirements-discovery.md",
63
+ REPO_ROOT / "scripts" / "okstra_ctl" / "work_categories.py",
64
+ ]
65
+ for t in targets:
66
+ assert "ops-change" not in t.read_text(encoding="utf-8"), f"{t} still has ops-change"
67
+ ```
68
+
69
+ - [ ] **Step 2: 실패 확인**
70
+
71
+ Run: `python3 -m pytest tests/test_work_categories.py -q`
72
+ Expected: FAIL — `ModuleNotFoundError: okstra_ctl.work_categories`
73
+
74
+ - [ ] **Step 3: 모듈 구현**
75
+
76
+ ```python
77
+ # scripts/okstra_ctl/work_categories.py
78
+ """requirements-discovery work-category(domain) SSOT.
79
+
80
+ profile / validators / wizard 는 이 모듈에서 enum 을 import 해야 한다. 다른 곳에서
81
+ 재정의하면 single-reference-point 위반이며 tests/test_work_categories.py 가 거부한다.
82
+
83
+ 다섯째 값은 `ops` 가 canonical 이다(`--work-category` CLI help 및 workflow.PHASE_RULES
84
+ 와 일치). 구버전 문서 철자 `ops-change` 는 폐기된다.
85
+ """
86
+ from __future__ import annotations
87
+
88
+ WORK_CATEGORIES: tuple[str, ...] = (
89
+ "bugfix",
90
+ "feature",
91
+ "refactor",
92
+ "ops",
93
+ "improvement",
94
+ )
95
+
96
+
97
+ def is_valid_category(value: str) -> bool:
98
+ return value in WORK_CATEGORIES
99
+ ```
100
+
101
+ - [ ] **Step 4: `ops-change` → `ops` 교정** (test_ssot_no_ops_change 통과시키기)
102
+
103
+ `prompts/profiles/requirements-discovery.md:17` 수정:
104
+ - 기존: ` - classify the work as bugfix, feature, improvement, refactor, or ops-change`
105
+ - 변경: ` - classify the work as bugfix, feature, improvement, refactor, or ops`
106
+
107
+ `skills/okstra-context-loader/SKILL.md:47` 및 `skills/okstra-inspect/SKILL.md:54` 의
108
+ `workCategory` 표 행에서 `ops-change` → `ops` 로 교정 (표시 문자열).
109
+
110
+ - [ ] **Step 5: 통과 확인**
111
+
112
+ Run: `python3 -m pytest tests/test_work_categories.py -q`
113
+ Expected: PASS (3 passed)
114
+
115
+ - [ ] **Step 6: 커밋**
116
+
117
+ ```bash
118
+ git add scripts/okstra_ctl/work_categories.py tests/test_work_categories.py \
119
+ prompts/profiles/requirements-discovery.md skills/okstra-context-loader/SKILL.md skills/okstra-inspect/SKILL.md
120
+ git commit -m "feat(work-categories): domain 5-enum SSOT + ops-change→ops 교정"
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Task 2: depends-on DAG 위상정렬 + 순환검출 헬퍼
126
+
127
+ **Files:**
128
+ - Create: `scripts/okstra_ctl/fanout.py`
129
+ - Test: `tests/test_fanout.py`
130
+
131
+ - [ ] **Step 1: 실패하는 테스트 작성**
132
+
133
+ ```python
134
+ # tests/test_fanout.py
135
+ import sys
136
+ from pathlib import Path
137
+
138
+ import pytest
139
+
140
+ REPO_ROOT = Path(__file__).resolve().parents[1]
141
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
142
+
143
+ from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
144
+
145
+
146
+ def test_linear_chain():
147
+ assert topological_order({"a": [], "b": ["a"], "c": ["b"]}) == ["a", "b", "c"]
148
+
149
+
150
+ def test_independent_units_are_id_sorted():
151
+ assert topological_order({"b": [], "a": []}) == ["a", "b"]
152
+
153
+
154
+ def test_cycle_raises():
155
+ with pytest.raises(CycleError):
156
+ topological_order({"a": ["b"], "b": ["a"]})
157
+
158
+
159
+ def test_unknown_dependency_raises():
160
+ with pytest.raises(ValueError):
161
+ topological_order({"a": ["nope"]})
162
+ ```
163
+
164
+ - [ ] **Step 2: 실패 확인**
165
+
166
+ Run: `python3 -m pytest tests/test_fanout.py -q`
167
+ Expected: FAIL — `ModuleNotFoundError: okstra_ctl.fanout`
168
+
169
+ - [ ] **Step 3: 구현**
170
+
171
+ ```python
172
+ # scripts/okstra_ctl/fanout.py
173
+ """fan-out unit 의존 그래프: 위상정렬 + 순환검출 (Kahn, id 사전순 결정적)."""
174
+ from __future__ import annotations
175
+
176
+
177
+ class CycleError(ValueError):
178
+ """depends-on 그래프에 순환이 있을 때."""
179
+
180
+
181
+ def topological_order(units: dict[str, list[str]]) -> list[str]:
182
+ """unit-id 를 착수 가능한 순서(의존 먼저)로 반환.
183
+
184
+ units: unit-id -> 그 unit 이 의존하는 unit-id 목록.
185
+ 순환이면 CycleError, 알 수 없는 의존이면 ValueError.
186
+ """
187
+ indeg = {u: 0 for u in units}
188
+ for u, deps in units.items():
189
+ for d in deps:
190
+ if d not in units:
191
+ raise ValueError(f"unit {u!r} depends on unknown unit {d!r}")
192
+ indeg[u] += 1
193
+ queue = sorted(u for u, n in indeg.items() if n == 0)
194
+ order: list[str] = []
195
+ while queue:
196
+ u = queue.pop(0)
197
+ order.append(u)
198
+ for v, deps in units.items():
199
+ if u in deps:
200
+ indeg[v] -= 1
201
+ if indeg[v] == 0:
202
+ queue.append(v)
203
+ queue.sort()
204
+ if len(order) != len(units):
205
+ remaining = sorted(set(units) - set(order))
206
+ raise CycleError(f"dependency cycle among units: {remaining}")
207
+ return order
208
+ ```
209
+
210
+ - [ ] **Step 4: 통과 확인**
211
+
212
+ Run: `python3 -m pytest tests/test_fanout.py -q`
213
+ Expected: PASS (4 passed)
214
+
215
+ - [ ] **Step 5: 커밋**
216
+
217
+ ```bash
218
+ git add scripts/okstra_ctl/fanout.py tests/test_fanout.py
219
+ git commit -m "feat(fanout): depends-on DAG 위상정렬 + 순환검출 헬퍼"
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Task 3: packet 템플릿 + validate_fanout 검증기
225
+
226
+ packet/index 포맷은 **validate_fanout 테스트가 계약의 SSOT** 다. 템플릿은 그 포맷을 따른다.
227
+
228
+ **Files:**
229
+ - Create: `templates/reports/fan-out-unit.template.md`
230
+ - Create: `validators/validate_fanout.py`
231
+ - Test: `tests/test_validate_fanout.py`
232
+
233
+ packet frontmatter 계약 (inline flow list 로 파싱 단순화):
234
+
235
+ ```
236
+ ---
237
+ unit-id: unit-001 # 파일명 stem 과 동일
238
+ domain: bugfix # WORK_CATEGORIES 중 하나
239
+ depends-on: [unit-002] # 같은 fan-out 내 unit-id 들의 inline 리스트 ([] 가능)
240
+ recommended-next-phase: error-analysis # error-analysis | implementation-planning
241
+ ---
242
+ ```
243
+
244
+ - [ ] **Step 1: 실패하는 테스트 작성**
245
+
246
+ ```python
247
+ # tests/test_validate_fanout.py
248
+ import sys
249
+ from pathlib import Path
250
+
251
+ REPO_ROOT = Path(__file__).resolve().parents[1]
252
+ sys.path.insert(0, str(REPO_ROOT / "validators"))
253
+
254
+ from validate_fanout import validate_fanout # noqa: E402
255
+
256
+
257
+ def _write_unit(d: Path, uid: str, domain: str, deps: str, nxt: str = "error-analysis"):
258
+ (d / f"{uid}.md").write_text(
259
+ f"---\nunit-id: {uid}\ndomain: {domain}\ndepends-on: {deps}\n"
260
+ f"recommended-next-phase: {nxt}\n---\n\n# {uid}\n본문\n",
261
+ encoding="utf-8",
262
+ )
263
+
264
+
265
+ def _write_index(d: Path, order: list[str]):
266
+ lines = ["# Fan-out Index", "> generated — do not hand-edit", ""]
267
+ lines += [f"{i+1}. {uid}" for i, uid in enumerate(order)]
268
+ (d / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
269
+
270
+
271
+ def test_valid_fanout_passes(tmp_path: Path):
272
+ fo = tmp_path / "fan-out"; fo.mkdir()
273
+ _write_unit(fo, "unit-001", "bugfix", "[]")
274
+ _write_unit(fo, "unit-002", "feature", "[unit-001]")
275
+ _write_index(fo, ["unit-001", "unit-002"])
276
+ res = validate_fanout(tmp_path)
277
+ assert res.ok, res.errors
278
+
279
+
280
+ def test_bad_domain_fails(tmp_path: Path):
281
+ fo = tmp_path / "fan-out"; fo.mkdir()
282
+ _write_unit(fo, "unit-001", "ops-change", "[]")
283
+ _write_index(fo, ["unit-001"])
284
+ res = validate_fanout(tmp_path)
285
+ assert not res.ok
286
+ assert any("domain" in e for e in res.errors)
287
+
288
+
289
+ def test_cycle_fails(tmp_path: Path):
290
+ fo = tmp_path / "fan-out"; fo.mkdir()
291
+ _write_unit(fo, "unit-001", "bugfix", "[unit-002]")
292
+ _write_unit(fo, "unit-002", "feature", "[unit-001]")
293
+ _write_index(fo, ["unit-001", "unit-002"])
294
+ res = validate_fanout(tmp_path)
295
+ assert not res.ok
296
+ assert any("cycle" in e.lower() for e in res.errors)
297
+
298
+
299
+ def test_index_mismatch_fails(tmp_path: Path):
300
+ fo = tmp_path / "fan-out"; fo.mkdir()
301
+ _write_unit(fo, "unit-001", "bugfix", "[]")
302
+ _write_index(fo, ["unit-001", "unit-999"]) # 존재하지 않는 unit 나열
303
+ res = validate_fanout(tmp_path)
304
+ assert not res.ok
305
+ assert any("index" in e.lower() for e in res.errors)
306
+
307
+
308
+ def test_no_fanout_dir_is_ok(tmp_path: Path):
309
+ # fan-out 안 한 단일 요청: 디렉토리 없으면 통과(검증 대상 아님).
310
+ res = validate_fanout(tmp_path)
311
+ assert res.ok
312
+ ```
313
+
314
+ - [ ] **Step 2: 실패 확인**
315
+
316
+ Run: `python3 -m pytest tests/test_validate_fanout.py -q`
317
+ Expected: FAIL — `ModuleNotFoundError: validate_fanout`
318
+
319
+ - [ ] **Step 3: validate_fanout.py 구현**
320
+
321
+ ```python
322
+ # validators/validate_fanout.py
323
+ """Validator for requirements-discovery fan-out packets + index.
324
+
325
+ Called by validators/validate-run.py when task_type == "requirements-discovery"
326
+ and a `fan-out/` directory exists under the run dir.
327
+ """
328
+ from __future__ import annotations
329
+
330
+ import re
331
+ import sys
332
+ from dataclasses import dataclass, field
333
+ from pathlib import Path
334
+
335
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
336
+ if str(_SCRIPTS_DIR) not in sys.path:
337
+ sys.path.insert(0, str(_SCRIPTS_DIR))
338
+
339
+ from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
340
+ from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
341
+
342
+ _NEXT_PHASES = ("error-analysis", "implementation-planning")
343
+ _UNIT_RE = re.compile(r"unit-\d{3}")
344
+
345
+
346
+ @dataclass
347
+ class ValidationResult:
348
+ ok: bool
349
+ errors: list[str] = field(default_factory=list)
350
+
351
+
352
+ def _frontmatter(text: str) -> dict[str, str]:
353
+ if not text.startswith("---"):
354
+ return {}
355
+ end = text.find("\n---", 3)
356
+ if end == -1:
357
+ return {}
358
+ fm: dict[str, str] = {}
359
+ for line in text[3:end].splitlines():
360
+ if ":" in line:
361
+ k, _, v = line.partition(":")
362
+ fm[k.strip()] = v.strip()
363
+ return fm
364
+
365
+
366
+ def _parse_deps(raw: str) -> list[str]:
367
+ return _UNIT_RE.findall(raw or "")
368
+
369
+
370
+ def validate_fanout(run_dir: Path) -> ValidationResult:
371
+ run_dir = Path(run_dir)
372
+ fo = run_dir / "fan-out"
373
+ if not fo.is_dir():
374
+ return ValidationResult(ok=True)
375
+
376
+ errors: list[str] = []
377
+ units: dict[str, list[str]] = {}
378
+ for pkt in sorted(fo.glob("unit-*.md")):
379
+ fm = _frontmatter(pkt.read_text(encoding="utf-8"))
380
+ uid = fm.get("unit-id", "")
381
+ if uid != pkt.stem:
382
+ errors.append(f"{pkt.name}: unit-id {uid!r} != filename stem {pkt.stem!r}")
383
+ if fm.get("domain") not in WORK_CATEGORIES:
384
+ errors.append(f"{pkt.name}: domain {fm.get('domain')!r} not in {WORK_CATEGORIES}")
385
+ if fm.get("recommended-next-phase") not in _NEXT_PHASES:
386
+ errors.append(f"{pkt.name}: recommended-next-phase not in {_NEXT_PHASES}")
387
+ units[pkt.stem] = _parse_deps(fm.get("depends-on", ""))
388
+
389
+ if not units:
390
+ errors.append("fan-out/: directory exists but no unit-*.md packets found")
391
+ return ValidationResult(ok=False, errors=errors)
392
+
393
+ order: list[str] = []
394
+ try:
395
+ order = topological_order(units)
396
+ except CycleError as exc:
397
+ errors.append(f"depends-on cycle: {exc}")
398
+ except ValueError as exc:
399
+ errors.append(f"depends-on unresolved: {exc}")
400
+
401
+ index = fo / "index.md"
402
+ if not index.is_file():
403
+ errors.append("fan-out/index.md missing")
404
+ else:
405
+ listed = _UNIT_RE.findall(index.read_text(encoding="utf-8"))
406
+ if set(listed) != set(units):
407
+ errors.append(
408
+ f"index.md units {sorted(set(listed))} != packets {sorted(units)}"
409
+ )
410
+ elif order and listed != order:
411
+ errors.append(f"index.md order {listed} is not the topological order {order}")
412
+
413
+ return ValidationResult(ok=not errors, errors=errors)
414
+ ```
415
+
416
+ - [ ] **Step 4: 통과 확인**
417
+
418
+ Run: `python3 -m pytest tests/test_validate_fanout.py -q`
419
+ Expected: PASS (5 passed)
420
+
421
+ - [ ] **Step 5: packet 템플릿 작성**
422
+
423
+ ```markdown
424
+ <!-- templates/reports/fan-out-unit.template.md -->
425
+ ---
426
+ unit-id: {{UNIT_ID}}
427
+ domain: {{DOMAIN}}
428
+ depends-on: {{DEPENDS_ON}}
429
+ recommended-next-phase: {{NEXT_PHASE}}
430
+ ---
431
+
432
+ # Fan-out Unit: {{UNIT_ID}} ({{DOMAIN}})
433
+
434
+ > requirements-discovery fan-out 산출 packet. `okstra-run --task-brief <이 파일 경로>`
435
+ > 로 새 task-key 를 시작한다. 이 파일은 그 run 의 입력 packet 이다.
436
+
437
+ ## Scope
438
+
439
+ <!-- 이 단위가 다루는 작업 항목 1개를 자족적으로 서술. 다른 단위와 섞지 말 것. -->
440
+
441
+ ## Evidence
442
+
443
+ <!-- path:line 근거. requirements-discovery 가 file inspection 으로 확인한 위치. -->
444
+
445
+ ## Depends-on rationale
446
+
447
+ <!-- depends-on 에 적은 각 unit 에 왜 의존하는지 1줄씩. 없으면 _(none)_ -->
448
+ ```
449
+
450
+ - [ ] **Step 6: 커밋**
451
+
452
+ ```bash
453
+ git add validators/validate_fanout.py tests/test_validate_fanout.py templates/reports/fan-out-unit.template.md
454
+ git commit -m "feat(fanout): packet 템플릿 + validate_fanout 검증기"
455
+ ```
456
+
457
+ ---
458
+
459
+ ## Task 4: validate-run.py 에 fan-out 훅 연결
460
+
461
+ **Files:**
462
+ - Modify: `validators/validate-run.py` (improvement 디스패치부 `_validate_improvement_discovery` ~line 1254, 호출부 ~line 1628 참고)
463
+ - Test: `tests/test_validate_fanout.py` (훅 호출 통합 테스트 1개 추가)
464
+
465
+ - [ ] **Step 1: 통합 테스트 추가 (실패)**
466
+
467
+ `tests/test_validate_fanout.py` 끝에 추가:
468
+
469
+ ```python
470
+ def test_validate_run_invokes_fanout_hook(tmp_path: Path):
471
+ """validate-run 의 디스패치 함수가 requirements-discovery 에서 fan-out 을 검증한다."""
472
+ sys.path.insert(0, str(REPO_ROOT / "validators"))
473
+ import importlib.util
474
+ spec = importlib.util.spec_from_file_location(
475
+ "validate_run_mod", REPO_ROOT / "validators" / "validate-run.py"
476
+ )
477
+ mod = importlib.util.module_from_spec(spec)
478
+ spec.loader.exec_module(mod)
479
+
480
+ run_dir = tmp_path / "runs" / "requirements-discovery"
481
+ fo = run_dir / "fan-out"; fo.mkdir(parents=True)
482
+ _write_unit(fo, "unit-001", "ops-change", "[]") # 잘못된 domain
483
+ _write_index(fo, ["unit-001"])
484
+
485
+ failures: list[str] = []
486
+ mod._validate_requirements_discovery_fanout(run_dir, failures)
487
+ assert any("requirements-discovery" in f and "domain" in f for f in failures)
488
+ ```
489
+
490
+ - [ ] **Step 2: 실패 확인**
491
+
492
+ Run: `python3 -m pytest tests/test_validate_fanout.py::test_validate_run_invokes_fanout_hook -q`
493
+ Expected: FAIL — `AttributeError: module has no attribute '_validate_requirements_discovery_fanout'`
494
+
495
+ - [ ] **Step 3: 디스패치 함수 추가**
496
+
497
+ `validators/validate-run.py` 의 `_validate_improvement_discovery` 함수 바로 아래에 추가
498
+ (스타일은 sibling 그대로):
499
+
500
+ ```python
501
+ def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
502
+ """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
503
+ 실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
504
+ """
505
+ from pathlib import Path as _Path
506
+ if not (_Path(run_dir) / "fan-out").is_dir():
507
+ return
508
+ try:
509
+ from validate_fanout import validate_fanout # noqa: E402
510
+ except Exception as exc: # pragma: no cover - import guard
511
+ failures.append(
512
+ f"requirements-discovery: validate_fanout import failed — {exc}"
513
+ )
514
+ return
515
+ result = validate_fanout(_Path(run_dir))
516
+ if not result.ok:
517
+ for err in result.errors:
518
+ failures.append(f"requirements-discovery: {err}")
519
+ ```
520
+
521
+ - [ ] **Step 4: 호출부 연결**
522
+
523
+ `_validate_improvement_discovery(...)` 호출 라인(현재 1636) 바로 다음, `validate_report_views(report_path, failures)`(현재 1637) 앞에 추가. `run_dir` 은 improvement 분기와 동일하게 `report_path.parent.parent`(= `runs/<task-type>/`)로 계산한다:
524
+
525
+ ```python
526
+ if task_type == "requirements-discovery":
527
+ run_dir = report_path.parent.parent
528
+ _validate_requirements_discovery_fanout(run_dir, failures)
529
+ ```
530
+
531
+ (`report_path` 는 `runs/requirements-discovery/reports/final-report-*.md` 이므로 `parent.parent` 가 `runs/requirements-discovery/`, 그 아래 `fan-out/` 을 검증한다.)
532
+
533
+ - [ ] **Step 5: 통과 확인 + 전체 validator 테스트**
534
+
535
+ Run: `python3 -m pytest tests/test_validate_fanout.py -q`
536
+ Expected: PASS (6 passed)
537
+
538
+ - [ ] **Step 6: 커밋**
539
+
540
+ ```bash
541
+ git add validators/validate-run.py tests/test_validate_fanout.py
542
+ git commit -m "feat(validate-run): requirements-discovery fan-out 검증 훅 연결"
543
+ ```
544
+
545
+ ---
546
+
547
+ ## Task 5: requirements-discovery 프로필에 fan-out 계약 선언
548
+
549
+ **Files:**
550
+ - Modify: `prompts/profiles/requirements-discovery.md`
551
+
552
+ - [ ] **Step 1: fan-out 책임 섹션 추가**
553
+
554
+ `- Primary focus areas:` 블록 다음에 새 항목 추가:
555
+
556
+ ```markdown
557
+ - Fan-out (multi-item / multi-domain requests only):
558
+ - 단일 항목/단일 도메인 요청은 fan-out 하지 않는다(현행 단일 라우팅 보존).
559
+ - 요청이 2개 이상 도메인에 걸치거나 독립 착수 가능한 작업 항목이 2개 이상이면,
560
+ 각 항목을 `runs/requirements-discovery/fan-out/unit-<NNN>.md` packet 으로 발행한다.
561
+ packet 은 `templates/reports/fan-out-unit.template.md` 형식을 따르며 frontmatter
562
+ `domain`(work-category 5-enum), `depends-on`(같은 fan-out 내 unit-id 의 inline 리스트),
563
+ `recommended-next-phase`(error-analysis | implementation-planning)를 채운다.
564
+ - `runs/requirements-discovery/fan-out/index.md` 에 packet 을 depends-on 위상순서로
565
+ 나열한다(생성 뷰; 직접 편집 금지 명시). depends-on 그래프는 DAG 여야 한다 — 순환이면
566
+ 리포트에 경고하고 해당 의존을 끊는다.
567
+ - 최종 리포트에는 분해 결과를 중복하지 말고 "fan-out: N packets → fan-out/index.md" 한 줄
568
+ 포인터만 둔다. packet 실행은 별도다: 사용자가 `okstra-run --task-brief <packet 경로>` 로
569
+ 각 단위를 새 task-key 로 시작한다(이 phase 는 다운스트림 run 을 직접 시작하지 않는다).
570
+ ```
571
+
572
+ - [ ] **Step 2: Non-goals 갱신**
573
+
574
+ `- Non-goals:` 블록의 첫 항목 아래에 in-scope 명시 1줄 추가:
575
+
576
+ ```markdown
577
+ - 작업 단위 분해(fan-out)는 이 phase 의 in-scope 다 — 단, 각 단위의 *해법 설계*·소스
578
+ 편집·plan 작성은 여전히 non-goal 이며 다운스트림 phase 가 담당한다
579
+ ```
580
+
581
+ - [ ] **Step 3: 빌드로 runtime 동기화 + 워크플로 validator**
582
+
583
+ Run: `npm run build && bash validators/validate-workflow.sh`
584
+ Expected: build 0/오류 없음, validator PASS
585
+
586
+ - [ ] **Step 4: 커밋**
587
+
588
+ ```bash
589
+ git add prompts/profiles/requirements-discovery.md
590
+ git commit -m "feat(requirements-discovery): fan-out 분해 계약 선언 + Non-goals 갱신"
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Task 6: 문서 (architecture.md, CHANGES.md)
596
+
597
+ **Files:**
598
+ - Modify: `docs/kr/architecture.md`
599
+ - Modify: `CHANGES.md`
600
+
601
+ - [ ] **Step 1: architecture.md 에 fan-out 흐름 추가**
602
+
603
+ requirements-discovery 를 다루는 절(또는 phase 흐름 표 인근)에 추가:
604
+
605
+ ```markdown
606
+ #### requirements-discovery fan-out
607
+
608
+ 혼합/다항목 요청은 requirements-discovery 가 도메인(work-category 5-enum)별 packet 으로
609
+ 분해해 `runs/requirements-discovery/fan-out/unit-*.md` 에 발행한다. 각 packet 은
610
+ `okstra-run --task-brief <경로>` 로 새 task-key 가 된다. 순서는 `index.md` 의 depends-on
611
+ 위상정렬이 담고, task 화 이후의 통합 일정은 okstra-schedule 이 맡는다. okstra-brief 는
612
+ 이 경로에 개입하지 않는다. 검증: `validators/validate_fanout.py`(validate-run 훅).
613
+ ```
614
+
615
+ - [ ] **Step 2: CHANGES.md 항목 추가**
616
+
617
+ `## 2026-06-02` 섹션에 추가(없으면 상단에 신설):
618
+
619
+ ```markdown
620
+ ### feat(requirements-discovery): 혼합 요청 도메인 fan-out
621
+
622
+ - requirements-discovery 가 버그/개선/구현이 섞인 요청을 work-category(5-enum)별 packet 으로
623
+ 분해해 `runs/requirements-discovery/fan-out/` 에 발행하고, 각 packet 을
624
+ `okstra-run --task-brief <경로>` 로 독립 task-key 로 fan-out 할 수 있게 함. 순서는 packet 의
625
+ `depends-on` 위상정렬(`index.md`)이 담는다. 단일 요청은 현행 단일 라우팅 그대로.
626
+ - work-category 다섯째 값을 `ops` 로 SSOT 화하고 일부 문서의 `ops-change` 철자를 교정.
627
+ - 사용자 영향: 다음 release + `npx -y okstra@latest install` 후 적용. 혼합 요청 시
628
+ requirements-discovery 가 packet 들을 만들어 주며, 각각을 `okstra-run` 으로 따로 시작하면 된다.
629
+ 단일 요청 동작은 변화 없음.
630
+ ```
631
+
632
+ - [ ] **Step 3: 커밋**
633
+
634
+ ```bash
635
+ git add docs/kr/architecture.md CHANGES.md
636
+ git commit -m "docs(fanout): architecture + CHANGES 항목"
637
+ ```
638
+
639
+ ---
640
+
641
+ ## Task 7: e2e 시나리오
642
+
643
+ **Files:**
644
+ - Create: `tests-e2e/scenario-<NN>-requirements-discovery-fanout.sh` (`ls tests-e2e/` 로 다음 번호 확인)
645
+
646
+ - [ ] **Step 1: 시나리오 작성**
647
+
648
+ 기존 시나리오(`tests-e2e/scenario-01-record-start-reconcile.sh`)의 골격(`mktemp -d` OKSTRA_HOME,
649
+ `trap` 정리)을 따른다. 검증 핵심:
650
+
651
+ ```bash
652
+ #!/usr/bin/env bash
653
+ set -euo pipefail
654
+ OKSTRA_HOME="$(mktemp -d)"; export OKSTRA_HOME
655
+ trap 'rm -rf "$OKSTRA_HOME"' EXIT
656
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
657
+
658
+ # fan-out 산출물을 모사한 run 디렉토리를 만들고 validate_fanout 가 통과/거부하는지 확인.
659
+ WORK="$(mktemp -d)"; trap 'rm -rf "$OKSTRA_HOME" "$WORK"' EXIT
660
+ FO="$WORK/runs/requirements-discovery/fan-out"; mkdir -p "$FO"
661
+ cat > "$FO/unit-001.md" <<'EOF'
662
+ ---
663
+ unit-id: unit-001
664
+ domain: bugfix
665
+ depends-on: []
666
+ recommended-next-phase: error-analysis
667
+ ---
668
+ # unit-001
669
+ EOF
670
+ cat > "$FO/unit-002.md" <<'EOF'
671
+ ---
672
+ unit-id: unit-002
673
+ domain: feature
674
+ depends-on: [unit-001]
675
+ recommended-next-phase: implementation-planning
676
+ ---
677
+ # unit-002
678
+ EOF
679
+ printf '# Fan-out Index\n> generated\n1. unit-001\n2. unit-002\n' > "$FO/index.md"
680
+
681
+ python3 - "$WORK" <<'PY'
682
+ import sys
683
+ from pathlib import Path
684
+ sys.path.insert(0, str(Path("validators").resolve()))
685
+ from validate_fanout import validate_fanout
686
+ res = validate_fanout(Path(sys.argv[1]) / "runs" / "requirements-discovery")
687
+ assert res.ok, res.errors
688
+ print("fan-out validation OK")
689
+ PY
690
+ echo "PASS: scenario requirements-discovery-fanout"
691
+ ```
692
+
693
+ - [ ] **Step 2: 실행 확인**
694
+
695
+ Run: `bash tests-e2e/scenario-<NN>-requirements-discovery-fanout.sh`
696
+ Expected: `PASS: scenario requirements-discovery-fanout`
697
+
698
+ - [ ] **Step 3: 커밋**
699
+
700
+ ```bash
701
+ git add tests-e2e/scenario-<NN>-requirements-discovery-fanout.sh
702
+ git commit -m "test(e2e): requirements-discovery fan-out 검증 시나리오"
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Task 8: 전체 회귀 + 빌드
708
+
709
+ - [ ] **Step 1: 전체 유닛 스위트**
710
+
711
+ Run: `python3 -m pytest tests/ -q`
712
+ Expected: 기존 + 신규 모두 PASS (회귀 0)
713
+
714
+ - [ ] **Step 2: 빌드 + 워크플로 validator**
715
+
716
+ Run: `npm run build && bash validators/validate-workflow.sh`
717
+ Expected: build 성공, validator PASS
718
+
719
+ - [ ] **Step 3: CLI smoke**
720
+
721
+ Run: `node bin/okstra --version`
722
+ Expected: 버전 출력
723
+
724
+ - [ ] **Step 4: 최종 커밋 (남은 것 있으면)**
725
+
726
+ ```bash
727
+ git status --short # 깨끗해야 함
728
+ ```