okstra 0.71.2 → 0.72.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.
@@ -0,0 +1,1290 @@
1
+ # Fix Cycle 구현 계획 (완료된 task 사후 버그 핫픽스 이력)
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:** 완료(release-handoff)된 okstra task 에 재진입하는 버그 핫픽스 run 묶음을 `history/fix-cycles.jsonl` 이벤트 행으로 등록하고, analysis-packet / task-manifest·index·catalog / final-report §5.10 / okstra-brief 네 소비처가 같은 요약을 파생해 읽게 한다.
6
+
7
+ **Architecture:** SSOT 는 신규 모듈 `scripts/okstra_ctl/fix_cycles.py` 가 단독 소유하는 append-only JSONL(`opened`/`run`/`closed` 행, consumers.jsonl idiom). 진입은 okstra-run wizard 의 `fix_cycle_confirm` 단계(자동 감지 + 확인) 또는 CLI `--fix-cycle yes|no` 플래그이며, 둘 다 `prepare_task_bundle()` 의 `PrepareInputs.fix_cycle` 한 입력으로 수렴한다. 종료는 prepare 시점 lazy-close.
8
+
9
+ **Tech Stack:** Python 3 (okstra_ctl, pytest), Bash (okstra.sh/cli.sh), Jinja 템플릿 (final-report), JSON Schema (final-report-v1.0).
10
+
11
+ **Spec:** [docs/superpowers/specs/2026-06-11-fix-cycle-design.md](../specs/2026-06-11-fix-cycle-design.md)
12
+
13
+ **선행 규칙:** Python 작업 전 `okstra-coding-preflight` 스킬 참조. 커밋 메시지는 Conventional Commits, Claude trailer 금지. `runtime/` 은 절대 직접 수정하지 않는다.
14
+
15
+ ---
16
+
17
+ ### Task 1: `fix_cycles.py` 모듈 (SSOT reader/writer)
18
+
19
+ **Files:**
20
+ - Modify: `scripts/okstra_ctl/run_context.py` (flock 헬퍼 추출)
21
+ - Create: `scripts/okstra_ctl/fix_cycles.py`
22
+ - Test: `tests/test_okstra_fix_cycles.py`
23
+
24
+ - [ ] **Step 1: 실패하는 테스트 작성**
25
+
26
+ `tests/test_okstra_fix_cycles.py` 생성 (conftest 의 `_isolate_okstra_home` autouse fixture 가 자동 적용됨 — [tests/conftest.py:5](../../tests/conftest.py)):
27
+
28
+ ```python
29
+ """fix_cycles SSOT 모듈 단위 테스트."""
30
+ from pathlib import Path
31
+
32
+ from okstra_ctl import fix_cycles
33
+
34
+
35
+ def _task_root(tmp_path: Path) -> Path:
36
+ root = tmp_path / ".okstra" / "tasks" / "grp" / "tid"
37
+ root.mkdir(parents=True)
38
+ return root
39
+
40
+
41
+ def test_read_rows_empty_when_missing(tmp_path):
42
+ assert fix_cycles.read_rows(_task_root(tmp_path)) == []
43
+
44
+
45
+ def test_open_close_lifecycle(tmp_path):
46
+ root = _task_root(tmp_path)
47
+ cycle = fix_cycles.append_opened(
48
+ root, target_report="runs/final-verification/reports/r.md",
49
+ symptom="결제 금액이 0원으로 저장됨", opened_at="2026-06-11T09:00:00Z")
50
+ assert cycle == "fc-01"
51
+ rows = fix_cycles.read_rows(root)
52
+ assert fix_cycles.open_cycle(rows)["cycle"] == "fc-01"
53
+
54
+ fix_cycles.append_closed(
55
+ root, cycle="fc-01", closed_by="release-handoff",
56
+ report="runs/release-handoff/reports/r.md",
57
+ closed_at="2026-06-11T12:00:00Z")
58
+ rows = fix_cycles.read_rows(root)
59
+ assert fix_cycles.open_cycle(rows) is None
60
+
61
+
62
+ def test_single_open_cycle_append_opened_is_idempotent(tmp_path):
63
+ root = _task_root(tmp_path)
64
+ first = fix_cycles.append_opened(
65
+ root, target_report="a.md", symptom="s", opened_at="t1")
66
+ second = fix_cycles.append_opened(
67
+ root, target_report="b.md", symptom="s2", opened_at="t2")
68
+ assert first == second == "fc-01"
69
+ assert len(fix_cycles.read_rows(root)) == 1
70
+
71
+
72
+ def test_cycle_id_increments_after_close(tmp_path):
73
+ root = _task_root(tmp_path)
74
+ fix_cycles.append_opened(root, target_report="a.md", symptom="s",
75
+ opened_at="t1")
76
+ fix_cycles.append_closed(root, cycle="fc-01",
77
+ closed_by="release-handoff", report="r.md",
78
+ closed_at="t2")
79
+ assert fix_cycles.append_opened(
80
+ root, target_report="c.md", symptom="s3", opened_at="t3") == "fc-02"
81
+
82
+
83
+ def test_append_run_idempotent_on_run_manifest(tmp_path):
84
+ root = _task_root(tmp_path)
85
+ fix_cycles.append_opened(root, target_report="a.md", symptom="s",
86
+ opened_at="t1")
87
+ for _ in range(2):
88
+ fix_cycles.append_run(
89
+ root, cycle="fc-01", task_type="error-analysis", run_seq=3,
90
+ run_manifest="runs/error-analysis/manifests/m-3.json")
91
+ rows = [r for r in fix_cycles.read_rows(root) if r["event"] == "run"]
92
+ assert len(rows) == 1
93
+ assert rows[0]["task_type"] == "error-analysis"
94
+
95
+
96
+ def test_append_closed_idempotent(tmp_path):
97
+ root = _task_root(tmp_path)
98
+ fix_cycles.append_opened(root, target_report="a.md", symptom="s",
99
+ opened_at="t1")
100
+ for _ in range(2):
101
+ fix_cycles.append_closed(root, cycle="fc-01",
102
+ closed_by="release-handoff",
103
+ report="r.md", closed_at="t2")
104
+ rows = [r for r in fix_cycles.read_rows(root) if r["event"] == "closed"]
105
+ assert len(rows) == 1
106
+
107
+
108
+ def test_summarize(tmp_path):
109
+ root = _task_root(tmp_path)
110
+ assert fix_cycles.summarize([]) == {
111
+ "count": 0, "openCycleId": None, "latest": None}
112
+ fix_cycles.append_opened(root, target_report="a.md",
113
+ symptom="증상 한 줄", opened_at="t1")
114
+ fix_cycles.append_run(root, cycle="fc-01", task_type="error-analysis",
115
+ run_seq=1, run_manifest="m.json")
116
+ summary = fix_cycles.summarize(fix_cycles.read_rows(root))
117
+ assert summary["count"] == 1
118
+ assert summary["openCycleId"] == "fc-01"
119
+ assert summary["latest"]["cycle"] == "fc-01"
120
+ assert summary["latest"]["symptom"] == "증상 한 줄"
121
+ assert summary["latest"]["closedAt"] is None
122
+
123
+
124
+ def test_packet_summary_renders_markdown(tmp_path):
125
+ root = _task_root(tmp_path)
126
+ fix_cycles.append_opened(root, target_report="a.md", symptom="증상",
127
+ opened_at="t1")
128
+ fix_cycles.append_run(root, cycle="fc-01", task_type="error-analysis",
129
+ run_seq=2, run_manifest="m.json")
130
+ text = fix_cycles.packet_summary(fix_cycles.read_rows(root))
131
+ assert "fc-01" in text
132
+ assert "증상" in text
133
+ assert "error-analysis" in text
134
+ assert "a.md" in text
135
+
136
+
137
+ def test_derive_symptom_from_brief():
138
+ brief = (
139
+ "---\nid: x\n---\n\n# Brief\n\n## Identity\n\n- key: v\n\n"
140
+ "## Request Summary\n\n- 결제 모듈이 금액을 0원으로 저장한다\n"
141
+ "- 추가 설명\n\n## Current Context\n\n- ...\n"
142
+ )
143
+ assert fix_cycles.derive_symptom(brief) == "결제 모듈이 금액을 0원으로 저장한다"
144
+
145
+
146
+ def test_derive_symptom_missing_section_returns_empty():
147
+ assert fix_cycles.derive_symptom("# 아무 섹션 없음\n") == ""
148
+ ```
149
+
150
+ - [ ] **Step 2: 테스트 실패 확인**
151
+
152
+ Run: `python3 -m pytest tests/test_okstra_fix_cycles.py -v`
153
+ Expected: FAIL — `ModuleNotFoundError`/`ImportError: cannot import name 'fix_cycles'`
154
+
155
+ - [ ] **Step 3: run_context.py 의 flock 패턴을 공용 헬퍼로 추출**
156
+
157
+ [run_context.py:106](../../scripts/okstra_ctl/run_context.py) 의 `consumers_mutex` 본문을 제네릭 헬퍼로 추출하고 `consumers_mutex` 는 그 헬퍼를 호출하게 바꾼다 (호출자가 2개가 되므로 추출 — 단일 참조 규칙):
158
+
159
+ ```python
160
+ @contextmanager
161
+ def dir_flock(dir_path: Path, lock_filename: str) -> Iterator[None]:
162
+ """dir_path 아래 lock_filename 파일 기반 exclusive flock.
163
+
164
+ lock 은 보호 대상 파일과 같은 디렉토리에 두어 디렉토리마다 1:1 로
165
+ 격리한다 (마지막 세그먼트만 키로 쓰면 다른 task 의 동일 seq 가 같은
166
+ lock 을 공유하므로 금지)."""
167
+ dir_path.mkdir(parents=True, exist_ok=True)
168
+ path = dir_path / lock_filename
169
+ path.touch(exist_ok=True)
170
+ with path.open("r+") as f:
171
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
172
+ try:
173
+ yield
174
+ finally:
175
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
176
+
177
+
178
+ @contextmanager
179
+ def consumers_mutex(plan_run_root: Path) -> Iterator[None]:
180
+ """plan run-root 별 consumers.jsonl append mutex."""
181
+ with dir_flock(plan_run_root, ".consumers.lock"):
182
+ yield
183
+ ```
184
+
185
+ 기존 `consumers_mutex` 의 docstring 두 번째 문단(세그먼트 경고)은 `dir_flock` 으로 옮긴다. 기존 데코레이터가 `@contextmanager` 인지 파일 상단 import 를 확인하고 동일하게 맞춘다.
186
+
187
+ - [ ] **Step 4: `fix_cycles.py` 구현**
188
+
189
+ `scripts/okstra_ctl/fix_cycles.py` 생성:
190
+
191
+ ```python
192
+ """Append-only reader/writer for `<task_root>/history/fix-cycles.jsonl`.
193
+
194
+ 완료(release-handoff)된 task 에 재진입하는 버그 핫픽스 run 묶음(fix cycle)의
195
+ SSOT. consumers.jsonl 과 같은 idiom — append-only + dir flock + last-wins 읽기.
196
+ 이 모듈이 유일한 reader/writer 이며, 소비처(analysis-packet / manifest /
197
+ final-report / okstra-brief)는 모두 summarize()/packet_summary() 파생 뷰를 쓴다.
198
+
199
+ 행 3종 (event 필드로 구분):
200
+ {"event":"opened","cycle":"fc-01","target_report":...,"symptom":...,"opened_at":...}
201
+ {"event":"run","cycle":"fc-01","task_type":...,"run_seq":...,"run_manifest":...}
202
+ {"event":"closed","cycle":"fc-01","closed_by":...,"report":...,"closed_at":...}
203
+
204
+ idempotency 키: (cycle, event, run_manifest|None). open cycle 은 task 당 1개.
205
+ """
206
+ from __future__ import annotations
207
+
208
+ import json
209
+ import re
210
+ from pathlib import Path
211
+ from typing import Any, Dict, List, Optional
212
+
213
+ from .run_context import dir_flock
214
+
215
+ FIX_CYCLES_FILENAME = "fix-cycles.jsonl"
216
+ _LOCK_FILENAME = ".fix-cycles.lock"
217
+
218
+
219
+ def fix_cycles_path(task_root: Path) -> Path:
220
+ return Path(task_root) / "history" / FIX_CYCLES_FILENAME
221
+
222
+
223
+ def read_rows(task_root: Path) -> List[Dict[str, Any]]:
224
+ p = fix_cycles_path(task_root)
225
+ if not p.exists():
226
+ return []
227
+ out: List[Dict[str, Any]] = []
228
+ for line in p.read_text(encoding="utf-8").splitlines():
229
+ line = line.strip()
230
+ if line:
231
+ out.append(json.loads(line))
232
+ return out
233
+
234
+
235
+ def open_cycle(rows: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
236
+ """closed 행이 없는 마지막 opened 행. open 은 동시 1개가 불변식."""
237
+ closed = {r["cycle"] for r in rows if r.get("event") == "closed"}
238
+ for r in reversed(rows):
239
+ if r.get("event") == "opened" and r["cycle"] not in closed:
240
+ return r
241
+ return None
242
+
243
+
244
+ def _next_cycle_id(rows: List[Dict[str, Any]]) -> str:
245
+ n = sum(1 for r in rows if r.get("event") == "opened")
246
+ return f"fc-{n + 1:02d}"
247
+
248
+
249
+ def append_opened(task_root: Path, *, target_report: str, symptom: str,
250
+ opened_at: str) -> str:
251
+ """새 cycle 을 열고 id 를 반환. open cycle 이 이미 있으면 그 id 반환(멱등)."""
252
+ history = fix_cycles_path(task_root).parent
253
+ with dir_flock(history, _LOCK_FILENAME):
254
+ rows = read_rows(task_root)
255
+ existing = open_cycle(rows)
256
+ if existing:
257
+ return existing["cycle"]
258
+ cycle = _next_cycle_id(rows)
259
+ _append_row(task_root, {
260
+ "event": "opened", "cycle": cycle,
261
+ "target_report": target_report, "symptom": symptom,
262
+ "opened_at": opened_at,
263
+ })
264
+ return cycle
265
+
266
+
267
+ def append_run(task_root: Path, *, cycle: str, task_type: str, run_seq: int,
268
+ run_manifest: str) -> None:
269
+ history = fix_cycles_path(task_root).parent
270
+ with dir_flock(history, _LOCK_FILENAME):
271
+ for r in read_rows(task_root):
272
+ if (r.get("event") == "run" and r.get("cycle") == cycle
273
+ and r.get("run_manifest") == run_manifest):
274
+ return
275
+ _append_row(task_root, {
276
+ "event": "run", "cycle": cycle, "task_type": task_type,
277
+ "run_seq": run_seq, "run_manifest": run_manifest,
278
+ })
279
+
280
+
281
+ def append_closed(task_root: Path, *, cycle: str, closed_by: str,
282
+ report: str, closed_at: str) -> None:
283
+ history = fix_cycles_path(task_root).parent
284
+ with dir_flock(history, _LOCK_FILENAME):
285
+ for r in read_rows(task_root):
286
+ if r.get("event") == "closed" and r.get("cycle") == cycle:
287
+ return
288
+ _append_row(task_root, {
289
+ "event": "closed", "cycle": cycle, "closed_by": closed_by,
290
+ "report": report, "closed_at": closed_at,
291
+ })
292
+
293
+
294
+ def _append_row(task_root: Path, record: Dict[str, Any]) -> None:
295
+ p = fix_cycles_path(task_root)
296
+ p.parent.mkdir(parents=True, exist_ok=True)
297
+ with p.open("a", encoding="utf-8") as f:
298
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
299
+
300
+
301
+ def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
302
+ """소비처 공용 요약: {count, openCycleId, latest:{cycle,symptom,targetReport,closedAt}}."""
303
+ opened = [r for r in rows if r.get("event") == "opened"]
304
+ if not opened:
305
+ return {"count": 0, "openCycleId": None, "latest": None}
306
+ closed_at = {r["cycle"]: r.get("closed_at")
307
+ for r in rows if r.get("event") == "closed"}
308
+ open_row = open_cycle(rows)
309
+ latest = opened[-1]
310
+ return {
311
+ "count": len(opened),
312
+ "openCycleId": open_row["cycle"] if open_row else None,
313
+ "latest": {
314
+ "cycle": latest["cycle"],
315
+ "symptom": latest.get("symptom", ""),
316
+ "targetReport": latest.get("target_report", ""),
317
+ "closedAt": closed_at.get(latest["cycle"]),
318
+ },
319
+ }
320
+
321
+
322
+ def packet_summary(rows: List[Dict[str, Any]]) -> str:
323
+ """analysis-packet 의 `## Fix History` 섹션 본문 (마크다운). 행이 없으면 ''."""
324
+ opened = [r for r in rows if r.get("event") == "opened"]
325
+ if not opened:
326
+ return ""
327
+ closed = {r["cycle"]: r for r in rows if r.get("event") == "closed"}
328
+ lines: List[str] = []
329
+ for o in opened:
330
+ state = "closed" if o["cycle"] in closed else "open"
331
+ lines.append(
332
+ f"- `{o['cycle']}` ({state}) — {o.get('symptom', '')} "
333
+ f"(target: `{o.get('target_report', '')}`)")
334
+ for r in rows:
335
+ if r.get("event") == "run" and r.get("cycle") == o["cycle"]:
336
+ lines.append(
337
+ f" - run: {r.get('task_type', '')} seq {r.get('run_seq', '')}"
338
+ f" (`{r.get('run_manifest', '')}`)")
339
+ return "\n".join(lines)
340
+
341
+
342
+ _REQUEST_SUMMARY_RE = re.compile(
343
+ r"^## Request Summary\s*$", re.MULTILINE)
344
+
345
+
346
+ def derive_symptom(brief_text: str) -> str:
347
+ """brief 의 `## Request Summary` 첫 비어있지 않은 줄 (불릿 마커 제거)."""
348
+ m = _REQUEST_SUMMARY_RE.search(brief_text)
349
+ if not m:
350
+ return ""
351
+ for line in brief_text[m.end():].splitlines():
352
+ line = line.strip()
353
+ if not line:
354
+ continue
355
+ if line.startswith("#"):
356
+ return ""
357
+ return line.lstrip("-* ").strip()
358
+ return ""
359
+ ```
360
+
361
+ - [ ] **Step 5: 테스트 통과 확인**
362
+
363
+ Run: `python3 -m pytest tests/test_okstra_fix_cycles.py -v`
364
+ Expected: 전부 PASS
365
+
366
+ Run: `python3 -m pytest tests/ -x -q -k "consumers or run_context"`
367
+ Expected: PASS (consumers_mutex 리팩터 회귀 없음)
368
+
369
+ - [ ] **Step 6: 커밋**
370
+
371
+ ```bash
372
+ git add scripts/okstra_ctl/fix_cycles.py scripts/okstra_ctl/run_context.py tests/test_okstra_fix_cycles.py
373
+ git commit -m "feat(okstra-ctl): fix-cycles.jsonl SSOT 모듈 추가 (사후 핫픽스 이력)"
374
+ ```
375
+
376
+ ---
377
+
378
+ ### Task 2: prepare 배선 — `--fix-cycle` 입력, opened/run 기록, lazy-close
379
+
380
+ **Files:**
381
+ - Modify: `scripts/okstra_ctl/run.py` (PrepareInputs `:269-313`, prepare_task_bundle `:1935`, argparse `:2258` 부근, PrepareInputs 구성 `:2386`)
382
+ - Test: `tests/test_fix_cycle_prepare.py`
383
+
384
+ - [ ] **Step 1: 실패하는 테스트 작성**
385
+
386
+ 기존 in-process prepare 테스트 패턴([tests/test_e2e_release_handoff_prepare.py:17](../../tests/test_e2e_release_handoff_prepare.py))을 참고해 `tests/test_fix_cycle_prepare.py` 작성. 먼저 그 파일을 Read 해 project fixture 구성 헬퍼(git init 여부, brief 시드 방식)를 그대로 복사한다. 테스트 본문:
387
+
388
+ ```python
389
+ """fix-cycle prepare 배선 통합 테스트 (render-only in-process)."""
390
+ import json
391
+ from pathlib import Path
392
+
393
+ import pytest
394
+
395
+ from okstra_ctl import fix_cycles
396
+ from okstra_ctl.run import PrepareError, PrepareInputs, prepare_task_bundle
397
+
398
+ # REPO/프로젝트 시드 헬퍼는 tests/test_e2e_release_handoff_prepare.py 의
399
+ # 것을 차용한다 (Read 후 동일 구조로 작성 — brief 파일, project.json,
400
+ # .okstra 디렉터리, git init).
401
+
402
+ BRIEF = """## Identity\n\n- task: t\n\n## Request Summary\n\n- 결제 금액 0원 버그\n"""
403
+
404
+
405
+ def _seed_done_manifest(task_root: Path) -> None:
406
+ """release-handoff 까지 끝난 기존 task-manifest 를 시드."""
407
+ task_root.mkdir(parents=True, exist_ok=True)
408
+ (task_root / "task-manifest.json").write_text(json.dumps({
409
+ "workflow": {"lastCompletedPhase": "release-handoff"},
410
+ "latestReportPath": "runs/final-verification/reports/prev.md",
411
+ }), encoding="utf-8")
412
+
413
+
414
+ def test_fix_cycle_yes_opens_and_attaches(tmp_project):
415
+ # tmp_project: (project_root, task_root, inputs_factory) — 차용 헬퍼
416
+ project_root, task_root, make_inputs = tmp_project
417
+ _seed_done_manifest(task_root)
418
+ inp = make_inputs(task_type="error-analysis", fix_cycle="yes",
419
+ render_only=True)
420
+ prepare_task_bundle(inp)
421
+ rows = fix_cycles.read_rows(task_root)
422
+ events = [r["event"] for r in rows]
423
+ assert events == ["opened", "run"]
424
+ assert rows[0]["symptom"] == "결제 금액 0원 버그"
425
+ assert rows[0]["target_report"] == "runs/final-verification/reports/prev.md"
426
+
427
+
428
+ def test_fix_cycle_yes_on_unfinished_task_fails(tmp_project):
429
+ project_root, task_root, make_inputs = tmp_project
430
+ # manifest 없음(새 task) 또는 lastCompletedPhase != release-handoff
431
+ inp = make_inputs(task_type="error-analysis", fix_cycle="yes",
432
+ render_only=True)
433
+ with pytest.raises(PrepareError):
434
+ prepare_task_bundle(inp)
435
+
436
+
437
+ def test_open_cycle_attaches_all_subsequent_runs(tmp_project):
438
+ project_root, task_root, make_inputs = tmp_project
439
+ _seed_done_manifest(task_root)
440
+ prepare_task_bundle(make_inputs(task_type="error-analysis",
441
+ fix_cycle="yes", render_only=True))
442
+ # fix_cycle 플래그 없이도 open cycle 이면 run 행이 부착된다
443
+ prepare_task_bundle(make_inputs(task_type="error-analysis",
444
+ fix_cycle="", render_only=True))
445
+ runs = [r for r in fix_cycles.read_rows(task_root) if r["event"] == "run"]
446
+ assert len(runs) == 2
447
+
448
+
449
+ def test_lazy_close_after_release_handoff_completes(tmp_project):
450
+ project_root, task_root, make_inputs = tmp_project
451
+ _seed_done_manifest(task_root)
452
+ prepare_task_bundle(make_inputs(task_type="error-analysis",
453
+ fix_cycle="yes", render_only=True))
454
+ # release-handoff run 행을 cycle 에 수동 부착 + manifest 를 다시 done 으로
455
+ fix_cycles.append_run(task_root, cycle="fc-01",
456
+ task_type="release-handoff", run_seq=1,
457
+ run_manifest="runs/release-handoff/manifests/m.json")
458
+ _seed_done_manifest(task_root) # lastCompletedPhase 를 release-handoff 로 복원
459
+ prepare_task_bundle(make_inputs(task_type="error-analysis",
460
+ fix_cycle="", render_only=True))
461
+ rows = fix_cycles.read_rows(task_root)
462
+ assert any(r["event"] == "closed" and r["cycle"] == "fc-01" for r in rows)
463
+ # close 이후의 이번 run 은 cycle 에 부착되지 않는다
464
+ closed_idx = next(i for i, r in enumerate(rows) if r["event"] == "closed")
465
+ assert all(r["event"] != "run" for r in rows[closed_idx + 1:])
466
+ ```
467
+
468
+ 주의: `_seed_done_manifest` 는 prepare 가 manifest 를 재작성하기 전 상태를 흉내 낸다. prepare 후 manifest 의 `lastCompletedPhase` 가 기존값 보존됨은 [render.py:897](../../scripts/okstra_ctl/render.py) 의 기존 동작이므로 두 번째 시드는 안전망일 뿐이다. 실제 작성 시 차용 헬퍼의 시그니처에 맞춰 `tmp_project` fixture 를 이 파일 안에 정의한다.
469
+
470
+ - [ ] **Step 2: 테스트 실패 확인**
471
+
472
+ Run: `python3 -m pytest tests/test_fix_cycle_prepare.py -v`
473
+ Expected: FAIL — `TypeError: ... unexpected keyword argument 'fix_cycle'`
474
+
475
+ - [ ] **Step 3: PrepareInputs 필드 + argparse 추가**
476
+
477
+ [run.py:313](../../scripts/okstra_ctl/run.py) 의 `plan_verification_enabled` 아래에:
478
+
479
+ ```python
480
+ fix_cycle: str = "" # "" | "yes" | "no" — done task 재진입의 fix-cycle 기록 여부
481
+ ```
482
+
483
+ argparse([run.py:2258](../../scripts/okstra_ctl/run.py) `--directive` 부근)에:
484
+
485
+ ```python
486
+ p.add_argument("--fix-cycle", default="", choices=["", "yes", "no"])
487
+ ```
488
+
489
+ PrepareInputs 구성부([run.py:2394](../../scripts/okstra_ctl/run.py) `directive=args.directive` 부근)에:
490
+
491
+ ```python
492
+ fix_cycle=args.fix_cycle,
493
+ ```
494
+
495
+ - [ ] **Step 4: `_record_fix_cycle_events` 헬퍼 구현 + 호출 배선**
496
+
497
+ `prepare_task_bundle` 내부, ctx 가 완성된 뒤 `_finalize_status_and_render_manifests(...)` 호출([run.py:1885](../../scripts/okstra_ctl/run.py) 의 헬퍼를 부르는 지점) **직전**에 호출을 끼운다. 먼저 `grep -n "_finalize_status_and_render_manifests(inp" scripts/okstra_ctl/run.py` 로 호출 지점을, `grep -n '"RUN_SEQ"\|RUN_MANIFEST_PATH\|TASK_MANIFEST_PATH' scripts/okstra_ctl/run.py | head -20` 로 ctx 토큰의 정확한 이름을 확인한다 (run seq 토큰이 `RUN_SEQ` 가 아니면 실제 이름으로 치환).
498
+
499
+ ```python
500
+ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
501
+ """fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
502
+
503
+ - lazy-close: open cycle 에 release-handoff run 이 부착됐고 manifest 의
504
+ lastCompletedPhase 가 release-handoff 면 닫는다.
505
+ - opened: --fix-cycle yes 일 때만. 감지 조건(완료 task + entry phase)
506
+ 미충족이면 PrepareError.
507
+ - run: open cycle 이 있으면 이번 run 을 무조건 부착.
508
+ """
509
+ from datetime import datetime, timezone
510
+
511
+ from . import fix_cycles
512
+
513
+ task_root = Path(ctx["TASK_MANIFEST_PATH"]).parent
514
+ manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
515
+ existing: dict = {}
516
+ if manifest_path.exists():
517
+ try:
518
+ existing = json.loads(manifest_path.read_text(encoding="utf-8"))
519
+ except Exception:
520
+ existing = {}
521
+ workflow = existing.get("workflow") or {}
522
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
523
+
524
+ rows = fix_cycles.read_rows(task_root)
525
+ open_c = fix_cycles.open_cycle(rows)
526
+
527
+ # lazy-close
528
+ if open_c and workflow.get("lastCompletedPhase") == "release-handoff":
529
+ handoff_attached = any(
530
+ r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
531
+ and r.get("task_type") == "release-handoff" for r in rows)
532
+ if handoff_attached:
533
+ fix_cycles.append_closed(
534
+ task_root, cycle=open_c["cycle"],
535
+ closed_by="release-handoff",
536
+ report=str(existing.get("latestReportPath", "")),
537
+ closed_at=now)
538
+ open_c = None
539
+
540
+ # opened
541
+ if inp.fix_cycle == "yes" and open_c is None:
542
+ entry_phases = ("requirements-discovery", "error-analysis",
543
+ "implementation-planning")
544
+ if inp.task_type not in entry_phases:
545
+ raise PrepareError(
546
+ f"--fix-cycle yes 는 entry phase({', '.join(entry_phases)})"
547
+ f"에서만 허용됩니다: {inp.task_type}")
548
+ if workflow.get("lastCompletedPhase") != "release-handoff":
549
+ raise PrepareError(
550
+ "--fix-cycle yes 는 release-handoff 까지 완료된 task 에만 "
551
+ "허용됩니다 (workflow.lastCompletedPhase 확인)")
552
+ brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
553
+ cycle = fix_cycles.append_opened(
554
+ task_root,
555
+ target_report=str(existing.get("latestReportPath", "")),
556
+ symptom=fix_cycles.derive_symptom(brief_text), opened_at=now)
557
+ rows = fix_cycles.read_rows(task_root)
558
+ open_c = fix_cycles.open_cycle(rows)
559
+
560
+ # run 부착
561
+ if open_c is not None:
562
+ run_manifest_rel = os.path.relpath(
563
+ ctx["RUN_MANIFEST_PATH"], str(Path(inp.project_root)))
564
+ fix_cycles.append_run(
565
+ task_root, cycle=open_c["cycle"], task_type=inp.task_type,
566
+ run_seq=int(ctx.get("RUN_SEQ", 0) or 0),
567
+ run_manifest=run_manifest_rel)
568
+ return open_c["cycle"]
569
+ return ""
570
+ ```
571
+
572
+ 호출 배선 (`_finalize_status_and_render_manifests` 직전):
573
+
574
+ ```python
575
+ ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
576
+ ```
577
+
578
+ `PrepareError` 클래스는 run.py 에 이미 존재한다 (`grep -n "class PrepareError" scripts/okstra_ctl/run.py` 로 위치 확인). `import json`, `import os` 는 파일 상단에 이미 있는지 확인 후 없으면 추가.
579
+
580
+ - [ ] **Step 5: 테스트 통과 확인**
581
+
582
+ Run: `python3 -m pytest tests/test_fix_cycle_prepare.py tests/test_okstra_fix_cycles.py -v`
583
+ Expected: 전부 PASS
584
+
585
+ - [ ] **Step 6: 커밋**
586
+
587
+ ```bash
588
+ git add scripts/okstra_ctl/run.py tests/test_fix_cycle_prepare.py
589
+ git commit -m "feat(okstra-ctl): prepare 에 --fix-cycle 배선 (opened/run 기록 + lazy-close)"
590
+ ```
591
+
592
+ ---
593
+
594
+ ### Task 3: run-manifest / timeline `fixCycleId` + manifest·index·catalog 요약
595
+
596
+ **Files:**
597
+ - Modify: `scripts/okstra_ctl/render.py` (`render_run_manifest:1112`, `render_timeline:1253`, `render_task_manifest:864`, `render_task_index:1345`, `render_task_catalog_discovery:496`)
598
+ - Modify: `templates/project-docs/task-index.template.md`
599
+ - Test: `tests/test_fix_cycle_render.py`
600
+
601
+ - [ ] **Step 1: 실패하는 테스트 작성**
602
+
603
+ `tests/test_fix_cycle_render.py`. render 함수들은 `(path, ctx)` 시그니처이므로 최소 ctx 로 직접 호출한다. 기존 render 단위 테스트(`grep -rln "render_run_manifest\|render_task_manifest" tests/ | head -3` 로 찾아 Read)의 최소 ctx 구성을 차용해 다음을 단언:
604
+
605
+ ```python
606
+ """fixCycleId / fixCycles 파생 뷰 렌더 테스트."""
607
+ import json
608
+ from pathlib import Path
609
+
610
+ from okstra_ctl import fix_cycles
611
+ from okstra_ctl.render import render_run_manifest, render_task_manifest
612
+
613
+
614
+ def test_run_manifest_includes_fix_cycle_id(tmp_path, minimal_run_ctx):
615
+ ctx = dict(minimal_run_ctx)
616
+ ctx["FIX_CYCLE_ID"] = "fc-01"
617
+ out = tmp_path / "run-manifest.json"
618
+ render_run_manifest(str(out), ctx)
619
+ payload = json.loads(out.read_text(encoding="utf-8"))
620
+ assert payload["fixCycleId"] == "fc-01"
621
+
622
+
623
+ def test_run_manifest_omits_field_without_cycle(tmp_path, minimal_run_ctx):
624
+ out = tmp_path / "run-manifest.json"
625
+ render_run_manifest(str(out), dict(minimal_run_ctx))
626
+ payload = json.loads(out.read_text(encoding="utf-8"))
627
+ assert "fixCycleId" not in payload
628
+
629
+
630
+ def test_task_manifest_includes_fix_cycles_summary(tmp_path, minimal_task_ctx):
631
+ task_root = tmp_path / "task"
632
+ fix_cycles.append_opened(task_root, target_report="a.md",
633
+ symptom="s", opened_at="t1")
634
+ out = task_root / "task-manifest.json"
635
+ render_task_manifest(str(out), dict(minimal_task_ctx))
636
+ payload = json.loads(out.read_text(encoding="utf-8"))
637
+ assert payload["fixCycles"]["count"] == 1
638
+ assert payload["fixCycles"]["openCycleId"] == "fc-01"
639
+ ```
640
+
641
+ `minimal_run_ctx` / `minimal_task_ctx` fixture 는 차용한 기존 테스트의 ctx dict 를 이 파일의 `@pytest.fixture` 로 옮겨 정의한다 (기존 테스트가 fixture 없이 inline dict 면 동일 dict 복사).
642
+
643
+ - [ ] **Step 2: 테스트 실패 확인**
644
+
645
+ Run: `python3 -m pytest tests/test_fix_cycle_render.py -v`
646
+ Expected: FAIL — `KeyError: 'fixCycleId'` / `KeyError: 'fixCycles'`
647
+
648
+ - [ ] **Step 3: render.py 4곳 수정**
649
+
650
+ (a) `render_run_manifest` — payload dict 조립 직후, `_write_json` 호출 전에 ([render.py:1184](../../scripts/okstra_ctl/render.py) `concurrentRun` 블록과 같은 위치 부근):
651
+
652
+ ```python
653
+ if ctx.get("FIX_CYCLE_ID"):
654
+ payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
655
+ ```
656
+
657
+ (b) `render_timeline` — run entry dict([render.py:1290](../../scripts/okstra_ctl/render.py) `filtered.append({...})`)에 같은 패턴:
658
+
659
+ ```python
660
+ entry = { ... 기존 키 ... }
661
+ if ctx.get("FIX_CYCLE_ID"):
662
+ entry["fixCycleId"] = ctx["FIX_CYCLE_ID"]
663
+ filtered.append(entry)
664
+ ```
665
+
666
+ (c) `render_task_manifest` — payload([render.py:915-1052](../../scripts/okstra_ctl/render.py)) 에 파생 요약 추가. 파일 상단에 `from . import fix_cycles` 추가 후:
667
+
668
+ ```python
669
+ "fixCycles": fix_cycles.summarize(
670
+ fix_cycles.read_rows(Path(manifest_path).parent)),
671
+ ```
672
+
673
+ (d) `render_task_catalog_discovery` — entry dict([render.py:547-601](../../scripts/okstra_ctl/render.py))에 각 task 의 요약 추가 (manifest 를 이미 읽고 있으므로 manifest 의 `fixCycles` 필드를 그대로 복사):
674
+
675
+ ```python
676
+ "fixCycles": manifest.get("fixCycles") or {
677
+ "count": 0, "openCycleId": None, "latest": None},
678
+ ```
679
+
680
+ (e) `render_task_index` — mapping dict([render.py:1405-1504](../../scripts/okstra_ctl/render.py))에 토큰 추가:
681
+
682
+ ```python
683
+ "FIX_CYCLES_SUMMARY": _fix_cycles_index_line(manifest),
684
+ ```
685
+
686
+ 같은 파일에 헬퍼:
687
+
688
+ ```python
689
+ def _fix_cycles_index_line(manifest: dict) -> str:
690
+ fc = manifest.get("fixCycles") or {}
691
+ if not fc.get("count"):
692
+ return "none"
693
+ latest = fc.get("latest") or {}
694
+ state = f"open: {fc['openCycleId']}" if fc.get("openCycleId") else "all closed"
695
+ return f"{fc['count']} cycle(s), {state} — latest `{latest.get('cycle', '')}`: {latest.get('symptom', '')}"
696
+ ```
697
+
698
+ - [ ] **Step 4: task-index 템플릿에 토큰 추가**
699
+
700
+ `templates/project-docs/task-index.template.md` 를 Read 해 기존 `{{TOKEN}}` 나열 형식을 확인하고, 상태 요약 라인들(latest run / current phase 부근)에 한 줄 추가:
701
+
702
+ ```markdown
703
+ - Fix cycles: {{FIX_CYCLES_SUMMARY}}
704
+ ```
705
+
706
+ - [ ] **Step 5: 테스트 통과 + 회귀 확인**
707
+
708
+ Run: `python3 -m pytest tests/test_fix_cycle_render.py -v && python3 -m pytest tests/ -q -k "render or manifest or timeline or catalog"`
709
+ Expected: 전부 PASS
710
+
711
+ - [ ] **Step 6: 커밋**
712
+
713
+ ```bash
714
+ git add scripts/okstra_ctl/render.py templates/project-docs/task-index.template.md tests/test_fix_cycle_render.py
715
+ git commit -m "feat(okstra-ctl): run-manifest/timeline fixCycleId + manifest/index/catalog fixCycles 요약"
716
+ ```
717
+
718
+ ---
719
+
720
+ ### Task 4: analysis-packet `## Fix History` 주입
721
+
722
+ **Files:**
723
+ - Modify: `scripts/okstra_ctl/analysis_packet.py` (`build_analysis_packet:44`)
724
+ - Modify: `scripts/okstra_ctl/run.py` (호출부 `:1784`)
725
+ - Test: `tests/test_fix_cycle_packet.py`
726
+
727
+ - [ ] **Step 1: 실패하는 테스트 작성**
728
+
729
+ `tests/test_fix_cycle_packet.py` — 기존 packet 테스트(`grep -ln "build_analysis_packet" tests/` 로 찾아 Read, 입력 파일 시드 방식 차용):
730
+
731
+ ```python
732
+ """analysis-packet Fix History 블록 테스트."""
733
+ from okstra_ctl.analysis_packet import build_analysis_packet
734
+
735
+
736
+ def test_packet_includes_fix_history_when_text_given(packet_inputs):
737
+ packet = build_analysis_packet(
738
+ **packet_inputs,
739
+ fix_history_text="- `fc-01` (open) — 증상 (target: `a.md`)")
740
+ assert "## Fix History" in packet
741
+ assert "fc-01" in packet
742
+
743
+
744
+ def test_packet_omits_fix_history_without_text(packet_inputs):
745
+ packet = build_analysis_packet(**packet_inputs, fix_history_text="")
746
+ assert "## Fix History" not in packet
747
+ ```
748
+
749
+ `packet_inputs` fixture 는 차용한 기존 테스트의 필수 인자 dict (brief/profile/reference 파일 tmp_path 시드 포함).
750
+
751
+ - [ ] **Step 2: 테스트 실패 확인**
752
+
753
+ Run: `python3 -m pytest tests/test_fix_cycle_packet.py -v`
754
+ Expected: FAIL — `TypeError: ... unexpected keyword argument 'fix_history_text'`
755
+
756
+ - [ ] **Step 3: 구현**
757
+
758
+ [analysis_packet.py:44](../../scripts/okstra_ctl/analysis_packet.py) 시그니처에 `fix_history_text: str = ""` 키워드 인자 추가. 조립부([analysis_packet.py:73](../../scripts/okstra_ctl/analysis_packet.py) `_reference_block` 다음, `_clarification_block` 앞)에:
759
+
760
+ ```python
761
+ parts += _fix_history_block(fix_history_text)
762
+ ```
763
+
764
+ `_clarification_block`([analysis_packet.py:161-169](../../scripts/okstra_ctl/analysis_packet.py)) 과 같은 조건부 블록 패턴으로:
765
+
766
+ ```python
767
+ def _fix_history_block(fix_history_text: str) -> list[str]:
768
+ if not fix_history_text.strip():
769
+ return []
770
+ return [
771
+ "",
772
+ "## Fix History",
773
+ "",
774
+ "Past bug-fix cycles registered on this task. Treat the open cycle's",
775
+ "symptom as a prior-defect signal when analysing.",
776
+ "",
777
+ fix_history_text,
778
+ "",
779
+ ]
780
+ ```
781
+
782
+ 호출부 [run.py:1784](../../scripts/okstra_ctl/run.py) 에 인자 추가 (호출 시점에 task_root 가 스코프에 있는지 확인하고, 없으면 `Path(ctx["TASK_MANIFEST_PATH"]).parent` 사용):
783
+
784
+ ```python
785
+ fix_history_text=fix_cycles.packet_summary(
786
+ fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
787
+ ```
788
+
789
+ 주의: packet 빌드가 Task 2 의 `_record_fix_cycle_events` 호출보다 먼저면 이번 run 의 `run` 행이 packet 에 안 보이는데, 그건 의도된 동작(이력은 과거분)이다 — 다만 이번 run 에서 새로 open 되는 cycle 은 packet 에 보여야 하므로, packet 빌드 시점이 `_record_fix_cycle_events` **이후**가 되도록 호출 순서를 확인하고, 아니라면 `_record_fix_cycle_events` 호출을 packet 빌드 앞으로 이동한다 (둘 다 ctx 완성 이후라면 순서 교환은 안전).
790
+
791
+ - [ ] **Step 4: 테스트 통과 확인**
792
+
793
+ Run: `python3 -m pytest tests/test_fix_cycle_packet.py tests/test_fix_cycle_prepare.py -v`
794
+ Expected: 전부 PASS
795
+
796
+ - [ ] **Step 5: 커밋**
797
+
798
+ ```bash
799
+ git add scripts/okstra_ctl/analysis_packet.py scripts/okstra_ctl/run.py tests/test_fix_cycle_packet.py
800
+ git commit -m "feat(okstra-ctl): analysis-packet 에 Fix History 섹션 주입"
801
+ ```
802
+
803
+ ---
804
+
805
+ ### Task 5: wizard `fix_cycle_confirm` 단계
806
+
807
+ **Files:**
808
+ - Modify: `scripts/okstra_ctl/wizard.py` (상수 `:271` 부근, WizardState/_FIELD_DEFAULTS `:2962` 부근, STEPS `:2857` 부근, `render_args:3074`)
809
+ - Modify: `prompts/wizard/prompts.ko.json` (`branch_confirm:170-183` 옆)
810
+ - Test: `tests/test_wizard_fix_cycle_confirm.py`
811
+
812
+ - [ ] **Step 1: 실패하는 테스트 작성**
813
+
814
+ 기존 wizard 테스트 패턴(`tests/test_wizard_approve_plan_confirm.py` 를 Read 해 state 구성·step 진행 헬퍼 차용) 으로 `tests/test_wizard_fix_cycle_confirm.py`:
815
+
816
+ ```python
817
+ """wizard fix_cycle_confirm 단계 테스트."""
818
+ import json
819
+ from pathlib import Path
820
+
821
+ from okstra_ctl import wizard
822
+
823
+
824
+ def _seed_done_task(project_root: Path, group: str, tid: str) -> None:
825
+ task_root = project_root / ".okstra" / "tasks" / group / tid
826
+ task_root.mkdir(parents=True)
827
+ (task_root / "task-manifest.json").write_text(json.dumps({
828
+ "workflow": {"lastCompletedPhase": "release-handoff"},
829
+ }), encoding="utf-8")
830
+
831
+
832
+ def test_step_appears_for_done_task_entry_phase(wizard_state_factory, tmp_path):
833
+ # wizard_state_factory: 차용 헬퍼 — identity 단계까지 채운 WizardState
834
+ state = wizard_state_factory(task_type="error-analysis",
835
+ task_group="grp", task_id="tid")
836
+ _seed_done_task(Path(state.project_root), "grp", "tid")
837
+ prompt = wizard.next_prompt(state)
838
+ assert prompt.step == "fix_cycle_confirm"
839
+ values = [o.value for o in prompt.options]
840
+ assert values == ["yes", "no", "abort"]
841
+
842
+
843
+ def test_step_skipped_for_new_task(wizard_state_factory):
844
+ state = wizard_state_factory(task_type="error-analysis",
845
+ task_group="grp", task_id="new-tid")
846
+ prompt = wizard.next_prompt(state)
847
+ assert prompt.step != "fix_cycle_confirm"
848
+
849
+
850
+ def test_step_skipped_for_non_entry_phase(wizard_state_factory, tmp_path):
851
+ state = wizard_state_factory(task_type="implementation",
852
+ task_group="grp", task_id="tid")
853
+ _seed_done_task(Path(state.project_root), "grp", "tid")
854
+ prompt = wizard.next_prompt(state)
855
+ assert prompt.step != "fix_cycle_confirm"
856
+
857
+
858
+ def test_render_args_carries_fix_cycle(wizard_state_factory, tmp_path):
859
+ state = wizard_state_factory(task_type="error-analysis",
860
+ task_group="grp", task_id="tid")
861
+ _seed_done_task(Path(state.project_root), "grp", "tid")
862
+ state.fix_cycle = "yes"
863
+ state.confirmed = True
864
+ args = wizard.render_args(state)
865
+ assert args["fix-cycle"] == "yes"
866
+ ```
867
+
868
+ `wizard_state_factory` 는 차용한 기존 테스트의 state 구성 코드를 fixture 로 정리한 것 (project_root=tmp_path, identity 필드·brief_path·base_ref·use_defaults 등 confirm 직전까지 채움). slugify 가 적용된 segment 경로(`grp`/`tid` 는 이미 slug-safe 값이라 동일)를 쓴다.
869
+
870
+ - [ ] **Step 2: 테스트 실패 확인**
871
+
872
+ Run: `python3 -m pytest tests/test_wizard_fix_cycle_confirm.py -v`
873
+ Expected: FAIL — `AttributeError: 'WizardState' object has no attribute 'fix_cycle'` 또는 step 미출현 assert 실패
874
+
875
+ - [ ] **Step 3: prompts.ko.json 에 step 문구 추가**
876
+
877
+ [prompts.ko.json:183](../../prompts/wizard/prompts.ko.json) `branch_confirm` 블록 뒤에:
878
+
879
+ ```json
880
+ "fix_cycle_confirm": {
881
+ "label": "이 task 는 release-handoff 까지 완료된 task 입니다. 이번 재진입을 기존 산출물에 대한 버그 픽스 사이클로 기록할까요?",
882
+ "options": {
883
+ "yes": "버그 픽스 사이클로 기록 (추천)",
884
+ "no": "일반 후속 작업 (기록 안 함)",
885
+ "abort": "중단"
886
+ },
887
+ "echo_template": "fix-cycle: {value}"
888
+ },
889
+ ```
890
+
891
+ - [ ] **Step 4: wizard.py 구현**
892
+
893
+ (a) 상수([wizard.py:271](../../scripts/okstra_ctl/wizard.py) `S_BRANCH_CONFIRM` 옆):
894
+
895
+ ```python
896
+ S_FIX_CYCLE_CONFIRM = "fix_cycle_confirm"
897
+ ```
898
+
899
+ (b) `WizardState` dataclass 의 `branch_confirmed` 필드([wizard.py:379](../../scripts/okstra_ctl/wizard.py)) 옆에:
900
+
901
+ ```python
902
+ fix_cycle: str = "" # "" | "yes" | "no" — done task 재진입의 fix-cycle 기록
903
+ ```
904
+
905
+ `_FIELD_DEFAULTS`([wizard.py:2962](../../scripts/okstra_ctl/wizard.py)) 의 `"branch_confirmed": None` 옆에 `"fix_cycle": "",` 추가.
906
+
907
+ (c) 감지 술어 (`_branch_confirm_required` 부근에 배치):
908
+
909
+ ```python
910
+ _FIX_CYCLE_ENTRY_PHASES = ("requirements-discovery", "error-analysis",
911
+ "implementation-planning")
912
+
913
+
914
+ def _fix_cycle_confirm_required(state: WizardState) -> bool:
915
+ """완료(release-handoff) task 에 entry phase 로 재진입할 때만 묻는다."""
916
+ if state.task_type not in _FIX_CYCLE_ENTRY_PHASES:
917
+ return False
918
+ manifest = (Path(state.project_root) / ".okstra" / "tasks"
919
+ / slugify(state.task_group) / slugify(state.task_id)
920
+ / "task-manifest.json")
921
+ if not manifest.is_file():
922
+ return False
923
+ try:
924
+ workflow = json.loads(
925
+ manifest.read_text(encoding="utf-8")).get("workflow") or {}
926
+ except Exception:
927
+ return False
928
+ if workflow.get("lastCompletedPhase") != "release-handoff":
929
+ return False
930
+ from . import fix_cycles
931
+ return fix_cycles.open_cycle(
932
+ fix_cycles.read_rows(manifest.parent)) is None
933
+ ```
934
+
935
+ `slugify` 는 wizard.py 가 이미 import 하는지 확인(`grep -n "slugify" scripts/okstra_ctl/wizard.py`)하고 없으면 `from .ids import slugify` 추가. 단, manifest 디렉터리 세그먼트가 slugify 적용 경로인지 raw 인지 `grep -n "tasks\" /\|tasks/" scripts/okstra_ctl/paths.py` 로 paths 모듈의 실제 계산을 확인해 동일하게 맞춘다 (paths 에 task_root 계산 헬퍼가 있으면 그것을 import 해 재사용 — 경로 규칙 중복 금지).
936
+
937
+ (d) build/submit (`_build_branch_confirm:2427` 패턴):
938
+
939
+ ```python
940
+ def _build_fix_cycle_confirm(state: WizardState) -> Prompt:
941
+ raw = _load_wizard_root(state.workspace_root)["steps"]["fix_cycle_confirm"]
942
+ t = _p(state.workspace_root, "fix_cycle_confirm")
943
+ return Prompt(
944
+ step=S_FIX_CYCLE_CONFIRM, kind="pick", text=t["label"],
945
+ options=[
946
+ Option(value="yes", label=raw["options"]["yes"]),
947
+ Option(value="no", label=raw["options"]["no"]),
948
+ Option(value="abort", label=raw["options"]["abort"]),
949
+ ])
950
+
951
+
952
+ def _submit_fix_cycle_confirm(state: WizardState, value: str) -> Optional[str]:
953
+ v = value.strip().lower()
954
+ if v == "abort":
955
+ state.aborted = True
956
+ return None
957
+ if v not in ("yes", "no"):
958
+ return _msg(state.workspace_root, "errors", "invalid_option")
959
+ state.fix_cycle = v
960
+ return None
961
+ ```
962
+
963
+ `Prompt`/`Option` 생성자 시그니처와 `_p`/`_msg` 반환 형태는 `_build_branch_confirm`([wizard.py:2427-2461](../../scripts/okstra_ctl/wizard.py))을 Read 해 동일하게 맞춘다 (위 코드는 형태 골격 — label 보간 키, abort 처리 방식, invalid 메시지 키를 기존 step 과 byte-level 로 일치시킬 것).
964
+
965
+ (e) STEPS 등록 — `S_BRANCH_CONFIRM` 항목([wizard.py:2857](../../scripts/okstra_ctl/wizard.py)) **바로 앞**에:
966
+
967
+ ```python
968
+ Step(S_FIX_CYCLE_CONFIRM,
969
+ applies=lambda s: (_ready_for_confirm(s)
970
+ and _fix_cycle_confirm_required(s)
971
+ and not s.fix_cycle),
972
+ build=_build_fix_cycle_confirm, submit=_submit_fix_cycle_confirm,
973
+ owns=("fix_cycle",)),
974
+ ```
975
+
976
+ 그리고 `S_BRANCH_CONFIRM` 과 `S_CONFIRM` 의 `applies` 에 선행 조건 추가 — 두 람다 각각에 `and (not _fix_cycle_confirm_required(s) or bool(s.fix_cycle))` 을 덧붙여 fix-cycle 질문이 먼저 소진되게 한다.
977
+
978
+ (f) `render_args`([wizard.py:3074](../../scripts/okstra_ctl/wizard.py)) 반환 dict 에:
979
+
980
+ ```python
981
+ "fix-cycle": state.fix_cycle,
982
+ ```
983
+
984
+ - [ ] **Step 5: render-bundle 인자 패스스루**
985
+
986
+ `okstra wizard` 의 render-args 출력을 소비하는 쪽이 `--fix-cycle` 을 python argparse 까지 전달해야 한다. `grep -n "directive" src/render-bundle.mjs src/wizard.mjs` 로 기존 인자 전달 방식을 확인하고, `directive` 와 동일한 패턴으로 `fix-cycle` → `--fix-cycle` 매핑을 추가한다 (usage 문자열 [src/render-bundle.mjs:16](../../src/render-bundle.mjs) 포함).
987
+
988
+ - [ ] **Step 6: 테스트 통과 확인**
989
+
990
+ Run: `python3 -m pytest tests/test_wizard_fix_cycle_confirm.py tests/test_okstra_ctl_wizard.py -v`
991
+ Expected: 전부 PASS (기존 wizard 회귀 포함)
992
+
993
+ - [ ] **Step 7: 커밋**
994
+
995
+ ```bash
996
+ git add scripts/okstra_ctl/wizard.py prompts/wizard/prompts.ko.json src/render-bundle.mjs tests/test_wizard_fix_cycle_confirm.py
997
+ git commit -m "feat(wizard): done task 재진입 시 fix_cycle_confirm 단계 추가"
998
+ ```
999
+
1000
+ ---
1001
+
1002
+ ### Task 6: bash CLI 플래그 배선
1003
+
1004
+ **Files:**
1005
+ - Modify: `scripts/lib/okstra/cli.sh` (`--directive` 파싱 `:141` 옆 + usage `:207`)
1006
+ - Modify: `scripts/okstra.sh` (PY_ARGS 조립 `:110` 옆)
1007
+
1008
+ - [ ] **Step 1: cli.sh 파싱 추가**
1009
+
1010
+ [cli.sh:141](../../scripts/lib/okstra/cli.sh) `--directive` case 옆에:
1011
+
1012
+ ```bash
1013
+ --fix-cycle)
1014
+ FIX_CYCLE="$(require_option_value --fix-cycle "${2-}")"
1015
+ shift 2
1016
+ ;;
1017
+ ```
1018
+
1019
+ (shift 처리는 기존 case 들과 동일 형태로 — 실제 파일의 `--directive` case 전체를 보고 복제.) usage 문자열([cli.sh:207](../../scripts/lib/okstra/cli.sh))에 `[--fix-cycle <yes|no>]` 추가. 변수 초기화부가 있으면 `FIX_CYCLE=""` 추가.
1020
+
1021
+ - [ ] **Step 2: okstra.sh PY_ARGS 조립**
1022
+
1023
+ [okstra.sh:110](../../scripts/okstra.sh) 옆에:
1024
+
1025
+ ```bash
1026
+ [[ -n "${FIX_CYCLE-}" ]] && PY_ARGS+=(--fix-cycle "$FIX_CYCLE")
1027
+ ```
1028
+
1029
+ - [ ] **Step 3: 스모크 확인**
1030
+
1031
+ Run: `bash -n scripts/okstra.sh scripts/lib/okstra/cli.sh`
1032
+ Expected: 문법 오류 없음 (exit 0)
1033
+
1034
+ Run: `python3 -m okstra_ctl.run --help 2>&1 | grep fix-cycle` (PYTHONPATH=scripts 필요 시 `PYTHONPATH=scripts python3 -m okstra_ctl.run --help`)
1035
+ Expected: `--fix-cycle {,yes,no}` 노출
1036
+
1037
+ - [ ] **Step 4: 커밋**
1038
+
1039
+ ```bash
1040
+ git add scripts/lib/okstra/cli.sh scripts/okstra.sh
1041
+ git commit -m "feat(cli): okstra.sh 에 --fix-cycle 플래그 배선"
1042
+ ```
1043
+
1044
+ ---
1045
+
1046
+ ### Task 7: final-report `## 5.10 Fix History` — 템플릿 + schema + validator + report-writer 지시
1047
+
1048
+ **Files:**
1049
+ - Modify: `templates/reports/final-report.template.md` (`:559` §5.9 endif 뒤)
1050
+ - Modify: `schemas/final-report-v1.0.schema.json`
1051
+ - Modify: `validators/validate-run.py`
1052
+ - Modify: `skills/okstra-report-writer/SKILL.md`
1053
+ - Test: `tests/test_fix_cycle_report_contract.py`
1054
+
1055
+ - [ ] **Step 1: 실패하는 테스트 작성**
1056
+
1057
+ validator 강제 규칙: **run-manifest 에 `fixCycleId` 가 있으면 data.json 에 `fixCycle` 블록이 있어야 하고 `fixCycle.cycle == fixCycleId`**. 기존 validate-run 테스트(`grep -ln "validate-run\|validate_run" tests/ | head -3` 로 찾아 Read, fixture 구성 차용)로 `tests/test_fix_cycle_report_contract.py`:
1058
+
1059
+ ```python
1060
+ """run-manifest fixCycleId ↔ data.json fixCycle 계약 테스트."""
1061
+ # 차용한 기존 validate-run 테스트의 로딩 방식(importlib 또는 subprocess)을
1062
+ # 그대로 사용해 _validate_fix_cycle 를 호출한다.
1063
+
1064
+
1065
+ def test_missing_fix_cycle_block_fails(validator_module):
1066
+ failures = []
1067
+ validator_module._validate_fix_cycle(
1068
+ run_manifest={"fixCycleId": "fc-01"}, data={}, failures=failures)
1069
+ assert failures and "fixCycle" in failures[0]
1070
+
1071
+
1072
+ def test_mismatched_cycle_id_fails(validator_module):
1073
+ failures = []
1074
+ validator_module._validate_fix_cycle(
1075
+ run_manifest={"fixCycleId": "fc-01"},
1076
+ data={"fixCycle": {"cycle": "fc-02"}}, failures=failures)
1077
+ assert failures
1078
+
1079
+
1080
+ def test_matching_cycle_passes(validator_module):
1081
+ failures = []
1082
+ validator_module._validate_fix_cycle(
1083
+ run_manifest={"fixCycleId": "fc-01"},
1084
+ data={"fixCycle": {"cycle": "fc-01", "targetReport": "a.md",
1085
+ "symptom": "s", "runs": []}},
1086
+ failures=failures)
1087
+ assert failures == []
1088
+
1089
+
1090
+ def test_no_cycle_no_requirement(validator_module):
1091
+ failures = []
1092
+ validator_module._validate_fix_cycle(
1093
+ run_manifest={}, data={}, failures=failures)
1094
+ assert failures == []
1095
+ ```
1096
+
1097
+ - [ ] **Step 2: 테스트 실패 확인**
1098
+
1099
+ Run: `python3 -m pytest tests/test_fix_cycle_report_contract.py -v`
1100
+ Expected: FAIL — `AttributeError: ... no attribute '_validate_fix_cycle'`
1101
+
1102
+ - [ ] **Step 3: validate-run.py 에 검사 추가**
1103
+
1104
+ `_validate_improvement_discovery`([validate-run.py:1716](../../validators/validate-run.py)) 스타일로:
1105
+
1106
+ ```python
1107
+ def _validate_fix_cycle(run_manifest: dict, data: dict, failures: list) -> None:
1108
+ """run-manifest 의 fixCycleId 가 있으면 data.json 의 fixCycle 블록을 강제."""
1109
+ cycle_id = (run_manifest or {}).get("fixCycleId", "")
1110
+ if not cycle_id:
1111
+ return
1112
+ block = (data or {}).get("fixCycle")
1113
+ if not isinstance(block, dict):
1114
+ failures.append(
1115
+ f"fix-cycle: run-manifest fixCycleId={cycle_id} 인데 data.json 에 "
1116
+ "fixCycle 블록이 없습니다")
1117
+ return
1118
+ if block.get("cycle") != cycle_id:
1119
+ failures.append(
1120
+ f"fix-cycle: data.json fixCycle.cycle={block.get('cycle')!r} 가 "
1121
+ f"run-manifest fixCycleId={cycle_id!r} 와 다릅니다")
1122
+ ```
1123
+
1124
+ 호출 지점: task_type 분기([validate-run.py:2184](../../validators/validate-run.py)) **바깥**(task-type 무관 공통 검사 자리)에 — run-manifest 와 data.json 둘 다 로드된 위치를 `grep -n "run_manifest\b" validators/validate-run.py | head` 로 찾아 그 직후에 `_validate_fix_cycle(run_manifest, data, failures)` 호출을 추가한다 (data.json 변수명도 실제 이름으로 맞춤).
1125
+
1126
+ - [ ] **Step 4: schema 에 optional `fixCycle` 블록 추가**
1127
+
1128
+ `schemas/final-report-v1.0.schema.json` 의 최상위 `properties` 에 (taskType 무관 optional — `allOf` required 강제는 두지 않는다, 강제는 Step 3 의 validator 가 담당):
1129
+
1130
+ ```json
1131
+ "fixCycle": {
1132
+ "type": "object",
1133
+ "description": "RENDER_IF fixCycle present — 이 run 이 속한 사후 버그 픽스 사이클",
1134
+ "required": ["cycle", "targetReport", "symptom"],
1135
+ "properties": {
1136
+ "cycle": { "type": "string", "pattern": "^fc-[0-9]{2,}$" },
1137
+ "targetReport": { "type": "string" },
1138
+ "symptom": { "type": "string" },
1139
+ "runs": {
1140
+ "type": "array",
1141
+ "items": {
1142
+ "type": "object",
1143
+ "required": ["taskType", "runSeq"],
1144
+ "properties": {
1145
+ "taskType": { "type": "string" },
1146
+ "runSeq": { "type": "integer" },
1147
+ "runManifest": { "type": "string" }
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+ ```
1154
+
1155
+ - [ ] **Step 5: 템플릿에 §5.10 추가**
1156
+
1157
+ [final-report.template.md:559](../../templates/reports/final-report.template.md) §5.9 `{% endif %}` 바로 뒤에 (5.x 의 taskType 게이트와 달리 데이터 존재 게이트 — fix cycle 은 task-type 무관):
1158
+
1159
+ ```jinja
1160
+ {% if fixCycle %}
1161
+ ## 5.10 Fix History
1162
+
1163
+ > This run belongs to a post-release bug-fix cycle registered in `history/fix-cycles.jsonl`.
1164
+
1165
+ - Cycle: `{{ fixCycle.cycle }}` — {{ fixCycle.symptom }}
1166
+ - Target report: `{{ fixCycle.targetReport }}`
1167
+ {% if fixCycle.runs %}
1168
+ - Runs in this cycle so far:
1169
+ {% for r in fixCycle.runs %}
1170
+ - {{ r.taskType }} seq {{ r.runSeq }}{% if r.runManifest %} (`{{ r.runManifest }}`){% endif %}
1171
+ {% endfor %}
1172
+ {% endif %}
1173
+ {% endif %}
1174
+ ```
1175
+
1176
+ 렌더 데이터 전달 확인: `grep -n "fixCycle\|def render" scripts/okstra_ctl/render_final_report.py | head` 로 data.json 루트가 템플릿 컨텍스트로 통째로 전달되는지 확인 — 통째 전달이면 추가 작업 없음, 명시 키 매핑이면 `fixCycle` 키를 매핑에 추가.
1177
+
1178
+ - [ ] **Step 6: report-writer 지시 추가**
1179
+
1180
+ `skills/okstra-report-writer/SKILL.md` 를 Read 해 data.json 작성 규칙 섹션을 찾고 한 항목 추가:
1181
+
1182
+ ```markdown
1183
+ - run-manifest 에 `fixCycleId` 가 있으면 data.json 에 `fixCycle` 블록(cycle / targetReport / symptom / runs)을 채운다. 값은 task root 의 `history/fix-cycles.jsonl` 에서 읽는다 — validator 가 누락·불일치를 거부한다.
1184
+ ```
1185
+
1186
+ - [ ] **Step 7: 테스트 통과 확인**
1187
+
1188
+ Run: `python3 -m pytest tests/test_fix_cycle_report_contract.py -v && python3 -m pytest tests/ -q -k "schema or validate"`
1189
+ Expected: 전부 PASS
1190
+
1191
+ - [ ] **Step 8: 커밋**
1192
+
1193
+ ```bash
1194
+ git add templates/reports/final-report.template.md schemas/final-report-v1.0.schema.json validators/validate-run.py skills/okstra-report-writer/SKILL.md tests/test_fix_cycle_report_contract.py
1195
+ git commit -m "feat(report): final-report 5.10 Fix History 섹션 + fixCycle 계약 강제"
1196
+ ```
1197
+
1198
+ ---
1199
+
1200
+ ### Task 8: okstra-brief 인용 지시
1201
+
1202
+ **Files:**
1203
+ - Modify: `skills/okstra-brief/SKILL.md` (Step 3a `:426-438`, Step 4)
1204
+
1205
+ - [ ] **Step 1: Step 3a 자산 목록에 추가**
1206
+
1207
+ [SKILL.md:431-433](../../skills/okstra-brief/SKILL.md) okstra-internal 자산 목록(glossary/decisions)에 항목 추가:
1208
+
1209
+ ```markdown
1210
+ 3. Fix history (when the brief targets an existing task): `<PROJECT_ROOT>/.okstra/tasks/<task-group>/<task-id>/history/fix-cycles.jsonl` — read every `opened`/`closed` row.
1211
+ ```
1212
+
1213
+ - [ ] **Step 2: Step 4 의 Task Continuity Notes 채움 규칙에 추가**
1214
+
1215
+ Step 4([SKILL.md:485](../../skills/okstra-brief/SKILL.md)) 의 REQUIRED 섹션 채움 규칙 본문을 Read 해 형식을 확인하고, Task Continuity Notes 관련 지시에 추가:
1216
+
1217
+ ```markdown
1218
+ - If the target task's `history/fix-cycles.jsonl` exists, cite each cycle in `Task Continuity Notes` as one line — `fix-cycle fc-NN (open|closed): <symptom> (target: <target_report>)`. An open cycle means the new brief continues a bug-fix in progress; say so explicitly.
1219
+ ```
1220
+
1221
+ - [ ] **Step 3: 커밋**
1222
+
1223
+ ```bash
1224
+ git add skills/okstra-brief/SKILL.md
1225
+ git commit -m "feat(skills/okstra-brief): brief 생성 시 fix-cycles 이력 인용 지시"
1226
+ ```
1227
+
1228
+ ---
1229
+
1230
+ ### Task 9: 문서 + 빌드 + 전체 검증
1231
+
1232
+ **Files:**
1233
+ - Modify: `docs/kr/architecture.md` (Phase 간 정보 전달 `:362` 뒤, Storage model 트리 `:428`, Task manifest contract `:541`, Run manifest `:614`, Timeline `:640`)
1234
+ - Modify: `docs/kr/cli.md`
1235
+ - Modify: `CHANGES.md`
1236
+
1237
+ - [ ] **Step 1: architecture.md 갱신 (전부 한국어)**
1238
+
1239
+ (a) `### Phase 간 정보 전달`([architecture.md:362](../../docs/kr/architecture.md)) 뒤에 새 절:
1240
+
1241
+ ```markdown
1242
+ ### Fix cycle (사후 버그 핫픽스 이력)
1243
+
1244
+ release-handoff 까지 완료된 task 의 산출물에서 버그가 발견되면, 같은 task-id 에 entry phase (`requirements-discovery` / `error-analysis` / `implementation-planning`) 로 재진입해 고친다 — 전용 hotfix task-type 은 없고 phase gate 는 그대로다. 이 재진입 run 묶음이 **fix cycle** 이며, SSOT 는 `<task_root>/history/fix-cycles.jsonl` 의 append-only 이벤트 행 (`opened` / `run` / `closed`, 모듈 `scripts/okstra_ctl/fix_cycles.py` 단독 소유) 이다.
1245
+
1246
+ - **진입**: okstra-run wizard 가 완료 task + entry phase 재진입을 감지해 `fix_cycle_confirm` 단계로 확인한다. CLI 는 `--fix-cycle <yes|no>` (미지정 시 기록하지 않음). open cycle 은 task 당 동시 1개.
1247
+ - **부착**: cycle 이 open 인 동안 같은 task 의 모든 run 이 `run` 행으로 부착되고, run-manifest / timeline 항목에 `fixCycleId` 가 찍힌다.
1248
+ - **종료**: release-handoff run 이 cycle 에 부착된 뒤 `workflow.lastCompletedPhase` 가 `release-handoff` 가 되면 다음 prepare 가 `closed` 행을 lazy-append 한다.
1249
+ - **소비처 (전부 파생 뷰)**: ① 후속 run 의 analysis-packet `## Fix History` 섹션 ② okstra-brief 의 Task Continuity Notes 인용 ③ final-report `## 5.10 Fix History` (run-manifest 에 `fixCycleId` 가 있으면 data.json `fixCycle` 블록을 validator 가 강제) ④ task-manifest `fixCycles` 요약 + task-index / task-catalog 한 줄.
1250
+ ```
1251
+
1252
+ (b) Storage model 의 task root 트리([architecture.md:428](../../docs/kr/architecture.md))에서 `history/` 아래 `fix-cycles.jsonl` 추가 (트리에 history 가 안 보이면 `timeline.json` 이 표기된 위치를 찾아 그 옆에). (c) Task manifest contract 필드 목록에 `fixCycles` 추가. (d) Run manifest / Timeline contract 항목에 `fixCycleId` (fix cycle 부착 시) 추가.
1253
+
1254
+ - [ ] **Step 2: cli.md 에 `--fix-cycle` 추가**
1255
+
1256
+ `docs/kr/cli.md` 를 Read 해 플래그 표/목록 형식을 확인하고 동일 형식으로:
1257
+
1258
+ ```markdown
1259
+ - `--fix-cycle <yes|no>` — release-handoff 까지 완료된 task 에 entry phase 로 재진입할 때, 이번 재진입을 버그 픽스 사이클로 기록할지 여부. 미지정 시 기록하지 않음 (okstra-run wizard 경로는 `fix_cycle_confirm` 단계가 같은 입력을 받음).
1260
+ ```
1261
+
1262
+ - [ ] **Step 3: CHANGES.md 항목 추가**
1263
+
1264
+ `## 2026-06-11` 섹션(이미 존재, [CHANGES.md:5](../../CHANGES.md))에 기존 항목 형식대로:
1265
+
1266
+ ```markdown
1267
+ ### feat(okstra-ctl/wizard/report): 완료 task 사후 버그 핫픽스를 fix cycle 로 등록 — brief/final-report 가 이력 인지
1268
+
1269
+ - **배경**: release-handoff 까지 끝난 task 의 산출물에서 버그가 발견돼 같은 task-id 로 재진입해도, 그 run 들이 "기존 산출물의 버그 픽스"라는 사실이 어디에도 1급 레코드로 남지 않아 이후 brief / final-report / task 탐색에서 이력이 보이지 않았다.
1270
+ - **해결**: `<task_root>/history/fix-cycles.jsonl` (append-only `opened`/`run`/`closed` 행, `scripts/okstra_ctl/fix_cycles.py` 단독 소유) 를 SSOT 로 신설. okstra-run wizard 가 완료 task 재진입을 감지해 `fix_cycle_confirm` 으로 확인하고(CLI `--fix-cycle <yes|no>`), open cycle 동안 모든 run 이 부착되며 release-handoff 완료 후 lazy-close 된다. analysis-packet `## Fix History` / okstra-brief Task Continuity Notes 인용 / final-report `## 5.10 Fix History` (validator 강제) / task-manifest·index·catalog 요약 네 곳이 같은 파생 요약을 읽는다.
1271
+ - 사용자 영향: 다음 release + `npx -y okstra@latest install` 후, 완료된 task 에 버그 픽스로 재진입하면 wizard 가 기록 여부를 묻고, 이후 같은 task 의 워커 입력·brief·최종 보고서·task 목록에서 해당 버그 픽스 이력이 자동으로 보인다. 기존 완료 task 의 과거 이력은 소급 생성되지 않는다.
1272
+ ```
1273
+
1274
+ - [ ] **Step 4: 빌드 + 전체 테스트 + validator**
1275
+
1276
+ ```bash
1277
+ npm run build
1278
+ python3 -m pytest tests/ -q
1279
+ bash validators/validate-workflow.sh
1280
+ node bin/okstra --version
1281
+ ```
1282
+
1283
+ Expected: build 성공, 전체 suite PASS, validator PASS, 버전 출력.
1284
+
1285
+ - [ ] **Step 5: 커밋**
1286
+
1287
+ ```bash
1288
+ git add docs/kr/architecture.md docs/kr/cli.md CHANGES.md
1289
+ git commit -m "docs(kr): fix cycle 이력 계약 문서화 + CHANGES 항목"
1290
+ ```