okstra 0.101.1 → 0.102.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,568 @@
1
+ # Decision Drafts 렌더 갭 해소 구현 계획
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:** implementation-planning 의 `Decision Drafts` 부록을 cross-project 와 동일하게 구조 필드(`decisionDrafts`/`skippedAdrCandidates`)로 승격해 결정론적 렌더 파이프라인에 실제로 출력시키고, line 116 의 materialization step MUST 를 validator 로 강제한다.
6
+
7
+ **Architecture:** `data.json`(SSOT) → Jinja 템플릿 결정론 렌더. 스키마에 `$defs/DecisionDraft`·`$defs/SkippedAdrCandidate` 추가 → `implementationPlanning` 배열 2개 → 템플릿 §5.4 `### Decision Drafts` 블록 → i18n → validate-run 헤딩 contract + 자기완결 교차 필드 검사 → 프로파일/report-writer prose 정합 → 빌드 동기화. cross-project 직전 사례([2026-06-24-cross-project-precondition.md](2026-06-24-cross-project-precondition.md))와 동일 패턴.
8
+
9
+ **Tech Stack:** JSON Schema(Draft 2020-12 스타일, `okstra_ctl.final_report_schema`), Jinja2 템플릿, Python `validate-run.py`, pytest, Node `tools/build.mjs`.
10
+
11
+ ## Global Constraints
12
+
13
+ - `runtime/` 직접 수정 금지 — 소스(`schemas/`, `templates/`, `prompts/`, `validators/`, `agents/`) 수정 후 `npm run build` 로 동기화 (CLAUDE.md).
14
+ - 완료 게이트: `npm run check` 가 그대로 exit 0 (test:js 실패 시 test:py 안 돔 — 통합 게이트로 확인).
15
+ - 동시 세션 환경 — `git commit --amend` 금지, 항상 새 커밋([project_concurrent_sessions_no_amend]). main 직접 커밋이 이 레포 관례.
16
+ - 사용자-대면 prose(스펙/플랜/프로파일 한국어 본문)는 한국어, 코드 식별자/경로/CLI 인자는 영어.
17
+ - i18n: `en.json` 과 `ko.json` 의 키는 **정확히 동일**해야 하며 같은 커밋에 추가([en.json:4](../../../templates/reports/i18n/en.json:4)).
18
+ - 헤딩 substring `Decision Drafts` 는 **세 곳**에 동시 존재해야 정합: 프로파일 line 57, `validate-run.py` `PLANNING_REQUIRED_SECTIONS`, 테스트 `_PLANNING_REQUIRED`.
19
+
20
+ ---
21
+
22
+ ### Task 1: 스키마 데이터 모델 + 픽스처
23
+
24
+ **Files:**
25
+ - Modify: `schemas/final-report-v1.0.schema.json` (`$defs` 2개 신설, `implementationPlanning` properties+required)
26
+ - Modify: `tests/fixtures/final-report-data/implementation-planning-001.data.json` (빈 배열 2개 추가)
27
+ - Create: `tests/contract/test_decision_drafts.py` (스키마 모양 테스트)
28
+
29
+ **Interfaces:**
30
+ - Produces: `$defs/DecisionDraft` (필드 `number`/`slug`/`status`/`context`/`decision`/`consequences`/`alternativesConsidered`), `$defs/SkippedAdrCandidate` (`topic`/`reason`), `implementationPlanning.decisionDrafts[]`, `implementationPlanning.skippedAdrCandidates[]`. Task 2(렌더)·Task 3(validator)·Task 4(prose)가 이 필드명을 그대로 참조.
31
+
32
+ - [ ] **Step 1: 실패 테스트 작성** — `tests/contract/test_decision_drafts.py`
33
+
34
+ ```python
35
+ """decisionDrafts / skippedAdrCandidates 스키마 모양 + validate-run 교차 필드 게이트 단위 테스트.
36
+
37
+ 스키마는 각 draft 의 7필드 비-빈 + status enum 을 보장하고(모양),
38
+ validate-run 은 decisionDrafts 가 비어있지 않으면 어느 stage stepwise 가
39
+ `.okstra/decisions/` materialization step 을 포함하는지(존재)를 본다.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import copy
44
+ import importlib.util
45
+ import json
46
+ import sys
47
+
48
+ from _paths import REPO_ROOT
49
+
50
+ import pytest
51
+
52
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
53
+
54
+ from okstra_ctl.final_report_schema import load_schema, validate as schema_validate # noqa: E402
55
+
56
+ FIXTURE = (
57
+ REPO_ROOT / "tests" / "fixtures" / "final-report-data"
58
+ / "implementation-planning-001.data.json"
59
+ )
60
+
61
+ _SAMPLE_DRAFT = {
62
+ "number": "0007",
63
+ "slug": "classifier-table-naming",
64
+ "status": "Proposed",
65
+ "context": "classifier 스키마의 5개 테이블 명명 규칙을 정해야 한다.",
66
+ "decision": "snake_case + 도메인 접두어(classifier_) 를 사용한다.",
67
+ "consequences": "기존 마이그레이션과 일관되며 조인 가독성이 오른다.",
68
+ "alternativesConsidered": "camelCase(기각: 식별자 대소문자 이슈), 접두어 없음(기각: 충돌 위험).",
69
+ }
70
+ _SAMPLE_SKIPPED = {"topic": "ORM 도입 여부", "reason": "되돌리기 어렵지 않음 — 기준 ① 미달."}
71
+
72
+
73
+ @pytest.fixture(scope="module")
74
+ def schema():
75
+ return load_schema()
76
+
77
+
78
+ @pytest.fixture()
79
+ def data():
80
+ return json.loads(FIXTURE.read_text(encoding="utf-8"))
81
+
82
+
83
+ def test_fixture_with_empty_decision_arrays_validates(schema, data):
84
+ assert schema_validate(data, schema) == []
85
+
86
+
87
+ def test_valid_draft_and_skipped_validate(schema, data):
88
+ d = copy.deepcopy(data)
89
+ d["implementationPlanning"]["decisionDrafts"] = [copy.deepcopy(_SAMPLE_DRAFT)]
90
+ d["implementationPlanning"]["skippedAdrCandidates"] = [copy.deepcopy(_SAMPLE_SKIPPED)]
91
+ assert schema_validate(d, schema) == []
92
+
93
+
94
+ def test_empty_context_is_rejected(schema, data):
95
+ d = copy.deepcopy(data)
96
+ bad = copy.deepcopy(_SAMPLE_DRAFT)
97
+ bad["context"] = ""
98
+ d["implementationPlanning"]["decisionDrafts"] = [bad]
99
+ assert schema_validate(d, schema) != []
100
+
101
+
102
+ def test_bad_slug_is_rejected(schema, data):
103
+ d = copy.deepcopy(data)
104
+ bad = copy.deepcopy(_SAMPLE_DRAFT)
105
+ bad["slug"] = "Classifier Table Naming" # 공백/대문자 — kebab 위반
106
+ d["implementationPlanning"]["decisionDrafts"] = [bad]
107
+ assert schema_validate(d, schema) != []
108
+
109
+
110
+ def test_non_proposed_status_is_rejected(schema, data):
111
+ d = copy.deepcopy(data)
112
+ bad = copy.deepcopy(_SAMPLE_DRAFT)
113
+ bad["status"] = "Accepted"
114
+ d["implementationPlanning"]["decisionDrafts"] = [bad]
115
+ assert schema_validate(d, schema) != []
116
+
117
+
118
+ def test_short_number_is_rejected(schema, data):
119
+ d = copy.deepcopy(data)
120
+ bad = copy.deepcopy(_SAMPLE_DRAFT)
121
+ bad["number"] = "7" # zero-pad ≥4 위반
122
+ d["implementationPlanning"]["decisionDrafts"] = [bad]
123
+ assert schema_validate(d, schema) != []
124
+
125
+
126
+ def test_empty_skipped_topic_is_rejected(schema, data):
127
+ d = copy.deepcopy(data)
128
+ bad = copy.deepcopy(_SAMPLE_SKIPPED)
129
+ bad["topic"] = ""
130
+ d["implementationPlanning"]["skippedAdrCandidates"] = [bad]
131
+ assert schema_validate(d, schema) != []
132
+ ```
133
+
134
+ - [ ] **Step 2: 테스트 실패 확인**
135
+
136
+ Run: `python3 -m pytest tests/contract/test_decision_drafts.py -v`
137
+ Expected: FAIL — `test_valid_draft_and_skipped_validate` 등이 실패. 현재 `implementationPlanning` 은 `additionalProperties: false` 라 `decisionDrafts`/`skippedAdrCandidates` 키 자체가 거부되고, `test_fixture_with_empty_decision_arrays_validates` 도 픽스처에 키가 없어 (required 추가 전이므로) 통과하거나 무관 — 핵심은 inject 테스트가 FAIL 하는 것.
138
+
139
+ - [ ] **Step 3: 스키마에 `$defs` 2개 추가** — `schemas/final-report-v1.0.schema.json` `CrossProjectDependency` 정의([:1382](../../../schemas/final-report-v1.0.schema.json:1382)) **바로 뒤**(`ValidationCheckRow` 앞)에 삽입:
140
+
141
+ ```json
142
+ "DecisionDraft": {
143
+ "type": "object",
144
+ "required": ["number", "slug", "status", "context", "decision", "consequences", "alternativesConsidered"],
145
+ "additionalProperties": false,
146
+ "properties": {
147
+ "number": { "type": "string", "pattern": "^\\d{4,}$" },
148
+ "slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
149
+ "status": { "enum": ["Proposed"] },
150
+ "context": { "type": "string", "minLength": 1 },
151
+ "decision": { "type": "string", "minLength": 1 },
152
+ "consequences": { "type": "string", "minLength": 1 },
153
+ "alternativesConsidered": { "type": "string", "minLength": 1 }
154
+ }
155
+ },
156
+
157
+ "SkippedAdrCandidate": {
158
+ "type": "object",
159
+ "required": ["topic", "reason"],
160
+ "additionalProperties": false,
161
+ "properties": {
162
+ "topic": { "type": "string", "minLength": 1 },
163
+ "reason": { "type": "string", "minLength": 1 }
164
+ }
165
+ },
166
+ ```
167
+
168
+ - [ ] **Step 4: `implementationPlanning` 에 properties + required 추가** — `schemas/final-report-v1.0.schema.json`
169
+
170
+ `required` 배열([:353-365](../../../schemas/final-report-v1.0.schema.json:353))의 `"crossProjectDependencies"` 다음에 두 항목 추가:
171
+
172
+ ```json
173
+ "planBodyVerification",
174
+ "crossProjectDependencies",
175
+ "decisionDrafts",
176
+ "skippedAdrCandidates"
177
+ ```
178
+
179
+ `properties` 의 `crossProjectDependencies` 블록([:415-418](../../../schemas/final-report-v1.0.schema.json:415)) 다음에 추가:
180
+
181
+ ```json
182
+ "crossProjectDependencies": {
183
+ "type": "array",
184
+ "items": { "$ref": "#/$defs/CrossProjectDependency" }
185
+ },
186
+ "decisionDrafts": {
187
+ "type": "array",
188
+ "items": { "$ref": "#/$defs/DecisionDraft" }
189
+ },
190
+ "skippedAdrCandidates": {
191
+ "type": "array",
192
+ "items": { "$ref": "#/$defs/SkippedAdrCandidate" }
193
+ }
194
+ ```
195
+
196
+ - [ ] **Step 5: 픽스처에 빈 배열 2개 추가** — `tests/fixtures/final-report-data/implementation-planning-001.data.json`
197
+
198
+ `implementationPlanning.crossProjectDependencies` 배열 다음에 추가(required 충족, 가장 단순):
199
+
200
+ ```json
201
+ "crossProjectDependencies": [ ...기존 그대로... ],
202
+ "decisionDrafts": [],
203
+ "skippedAdrCandidates": []
204
+ ```
205
+
206
+ - [ ] **Step 6: 테스트 통과 확인 (신규 + 회귀)**
207
+
208
+ Run: `python3 -m pytest tests/contract/test_decision_drafts.py tests/report/test_schema_excerpt.py tests/contract/test_cross_project_precondition.py -v`
209
+ Expected: PASS — 신규 모양 테스트 7개 통과; `test_excerpt_validates_real_fixture_data`(픽스처가 full schema 검증)·`test_no_dangling_refs`(새 `$defs` 가 `$ref` 클로저로 자동 포함) 통과.
210
+
211
+ - [ ] **Step 7: 커밋**
212
+
213
+ ```bash
214
+ git add schemas/final-report-v1.0.schema.json tests/fixtures/final-report-data/implementation-planning-001.data.json tests/contract/test_decision_drafts.py
215
+ git commit -m "feat(report-schema): add DecisionDraft + skippedAdrCandidates fields"
216
+ ```
217
+
218
+ ---
219
+
220
+ ### Task 2: 템플릿 렌더 + i18n
221
+
222
+ **Files:**
223
+ - Modify: `templates/reports/final-report.template.md` (§5.4 에 `### Decision Drafts` 블록)
224
+ - Modify: `templates/reports/i18n/en.json`, `templates/reports/i18n/ko.json` (키 추가)
225
+ - Modify: `tests/report/test_render_final_report.py` (테스트 2개 추가 + `_PLANNING_REQUIRED` 갱신)
226
+
227
+ **Interfaces:**
228
+ - Consumes: Task 1 의 `implementationPlanning.decisionDrafts[]`(필드 number/slug/status/context/decision/consequences/alternativesConsidered), `skippedAdrCandidates[]`(topic/reason).
229
+ - Produces: 렌더된 `### Decision Drafts` 섹션 헤딩(substring) — Task 3 의 `PLANNING_REQUIRED_SECTIONS` 가 의존.
230
+
231
+ - [ ] **Step 1: 실패 테스트 작성** — `tests/report/test_render_final_report.py` 끝부분(`test_cross_project_dependencies_empty_state` 인근)에 추가:
232
+
233
+ ```python
234
+ _SAMPLE_DRAFT = {
235
+ "number": "0007",
236
+ "slug": "classifier-table-naming",
237
+ "status": "Proposed",
238
+ "context": "테이블 명명 규칙을 정해야 한다.",
239
+ "decision": "snake_case + classifier_ 접두어.",
240
+ "consequences": "조인 가독성이 오른다.",
241
+ "alternativesConsidered": "camelCase(기각), 접두어 없음(기각).",
242
+ }
243
+ _SAMPLE_SKIPPED = {"topic": "ORM 도입", "reason": "되돌리기 어렵지 않음 — 기준 ① 미달."}
244
+
245
+
246
+ def test_decision_drafts_render():
247
+ data = _load("implementation-planning-001.data.json")
248
+ data["implementationPlanning"]["decisionDrafts"] = [dict(_SAMPLE_DRAFT)]
249
+ data["implementationPlanning"]["skippedAdrCandidates"] = [dict(_SAMPLE_SKIPPED)]
250
+ rendered = render(data, template_path=find_default_template())
251
+ assert "### Decision Drafts" in rendered
252
+ assert "#### 0007-classifier-table-naming" in rendered
253
+ assert "Proposed" in rendered
254
+ assert "skipped adr-candidate: ORM 도입 — reason:" in rendered
255
+
256
+
257
+ def test_decision_drafts_empty_state():
258
+ rendered = _render_fixture("implementation-planning-001.data.json") # 픽스처는 빈 배열
259
+ assert "### Decision Drafts" in rendered
260
+ section = rendered[
261
+ rendered.index("### Decision Drafts"):
262
+ rendered.index("## 5.5 Stage Map")
263
+ ]
264
+ assert "####" not in section # draft 서브섹션 없음
265
+ assert "skipped adr-candidate:" not in section
266
+ ```
267
+
268
+ - [ ] **Step 2: 테스트 실패 확인**
269
+
270
+ Run: `python3 -m pytest tests/report/test_render_final_report.py::test_decision_drafts_render tests/report/test_render_final_report.py::test_decision_drafts_empty_state -v`
271
+ Expected: FAIL — 템플릿에 `### Decision Drafts` 블록이 없어 헤딩 부재 + `t()` 키 미정의.
272
+
273
+ - [ ] **Step 3: 템플릿 블록 추가** — `templates/reports/final-report.template.md` 의 `### Requirement Coverage` 표 끝([:258](../../../templates/reports/final-report.template.md:258))과 `## 5.5 Stage Map`([:260](../../../templates/reports/final-report.template.md:260)) **사이**에 삽입:
274
+
275
+ ```jinja
276
+ ### Decision Drafts{% if t("sectionAside.decisionDrafts") != "Decision Drafts" %} ({{ t("sectionAside.decisionDrafts") }}){% endif %}
277
+
278
+ {% if (implementationPlanning.decisionDrafts | length == 0) and (implementationPlanning.skippedAdrCandidates | length == 0) -%}
279
+ {{ t("emptyState.decisionDrafts") }}
280
+ {%- else %}
281
+ {% for d in implementationPlanning.decisionDrafts %}
282
+ #### {{ d.number }}-{{ d.slug }}
283
+
284
+ - **{{ t("implementationPlanning.decisionDraftFields.status") }}:** {{ d.status }}
285
+ - **{{ t("implementationPlanning.decisionDraftFields.context") }}:** {{ d.context }}
286
+ - **{{ t("implementationPlanning.decisionDraftFields.decision") }}:** {{ d.decision }}
287
+ - **{{ t("implementationPlanning.decisionDraftFields.consequences") }}:** {{ d.consequences }}
288
+ - **{{ t("implementationPlanning.decisionDraftFields.alternativesConsidered") }}:** {{ d.alternativesConsidered }}
289
+ {% endfor %}
290
+ {% for s in implementationPlanning.skippedAdrCandidates %}
291
+ - skipped adr-candidate: {{ s.topic }} — reason: {{ s.reason }}
292
+ {% endfor %}
293
+ {%- endif %}
294
+
295
+ ```
296
+
297
+ - [ ] **Step 4: i18n 키 추가 (en.json)** — `templates/reports/i18n/en.json`
298
+
299
+ `emptyState`([:6](../../../templates/reports/i18n/en.json:6))에 추가: `"decisionDrafts": "- No decision drafts or skipped ADR candidates."`
300
+ `sectionAside`([:32](../../../templates/reports/i18n/en.json:32))에 추가: `"decisionDrafts": "Decision Drafts"`
301
+ `implementationPlanning`([:91](../../../templates/reports/i18n/en.json:91))의 `crossProjectRecognitionNote` 다음에 추가:
302
+
303
+ ```json
304
+ "decisionDraftFields": {
305
+ "status": "Status",
306
+ "context": "Context",
307
+ "decision": "Decision",
308
+ "consequences": "Consequences",
309
+ "alternativesConsidered": "Alternatives Considered"
310
+ }
311
+ ```
312
+
313
+ - [ ] **Step 5: i18n 키 추가 (ko.json — en.json 과 동일 키)** — `templates/reports/i18n/ko.json`
314
+
315
+ `emptyState` 에 추가: `"decisionDrafts": "- 결정 기록 초안이나 스킵된 ADR 후보 없음."`
316
+ `sectionAside` 에 추가: `"decisionDrafts": "결정 기록 초안"`
317
+ `implementationPlanning` 의 `crossProjectRecognitionNote` 다음에 추가:
318
+
319
+ ```json
320
+ "decisionDraftFields": {
321
+ "status": "상태",
322
+ "context": "맥락",
323
+ "decision": "결정",
324
+ "consequences": "결과",
325
+ "alternativesConsidered": "검토한 대안"
326
+ }
327
+ ```
328
+
329
+ - [ ] **Step 6: `_PLANNING_REQUIRED` 에 헤딩 추가** — `tests/report/test_render_final_report.py:43`
330
+
331
+ `_PLANNING_REQUIRED` 튜플([:43-56](../../../tests/report/test_render_final_report.py:43))의 `"Cross-Project Dependencies",` 다음에 `"Decision Drafts",` 추가.
332
+
333
+ - [ ] **Step 7: 렌더 테스트 통과 확인**
334
+
335
+ Run: `python3 -m pytest tests/report/test_render_final_report.py -v`
336
+ Expected: PASS — 신규 2개 + `test_implementation_planning_fixture_contains_all_required_substrings`(이제 `Decision Drafts` 포함, 빈 배열 픽스처가 empty-state 로 헤딩 렌더) 통과.
337
+
338
+ - [ ] **Step 8: 커밋**
339
+
340
+ ```bash
341
+ git add templates/reports/final-report.template.md templates/reports/i18n/en.json templates/reports/i18n/ko.json tests/report/test_render_final_report.py
342
+ git commit -m "feat(report): render Decision Drafts section in implementation-planning"
343
+ ```
344
+
345
+ ---
346
+
347
+ ### Task 3: validator 자기완결 trigger + 헤딩 contract
348
+
349
+ **Files:**
350
+ - Modify: `validators/validate-run.py` (`PLANNING_REQUIRED_SECTIONS` + 신규 함수 + dispatch)
351
+ - Modify: `tests/contract/test_decision_drafts.py` (validator 테스트 추가)
352
+
353
+ **Interfaces:**
354
+ - Consumes: Task 1 의 `decisionDrafts[]`, `implementationPlanning.stages[].stepwiseExecution[].{files,action}`.
355
+ - Produces: `_validate_implementation_planning_decision_drafts(data: dict, failures: list[str]) -> None`.
356
+
357
+ - [ ] **Step 1: validator 실패 테스트 추가** — `tests/contract/test_decision_drafts.py` 끝에 추가:
358
+
359
+ ```python
360
+ VALIDATOR_PATH = REPO_ROOT / "validators" / "validate-run.py"
361
+
362
+
363
+ def _load_validator():
364
+ spec = importlib.util.spec_from_file_location("validate_run", VALIDATOR_PATH)
365
+ module = importlib.util.module_from_spec(spec)
366
+ assert spec.loader is not None
367
+ spec.loader.exec_module(module)
368
+ return module
369
+
370
+
371
+ @pytest.fixture(scope="module")
372
+ def vr():
373
+ return _load_validator()
374
+
375
+
376
+ def _planning_data(drafts, stages):
377
+ return {
378
+ "header": {"taskType": "implementation-planning"},
379
+ "implementationPlanning": {"decisionDrafts": drafts, "stages": stages},
380
+ }
381
+
382
+
383
+ def test_drafts_without_materialization_step_fails(vr):
384
+ failures: list[str] = []
385
+ data = _planning_data(
386
+ [dict(_SAMPLE_DRAFT)],
387
+ [{"stepwiseExecution": [
388
+ {"step": 1, "action": "GREEN: add classifier table", "files": "migrations/x.sql"},
389
+ ]}],
390
+ )
391
+ vr._validate_implementation_planning_decision_drafts(data, failures)
392
+ assert failures != []
393
+
394
+
395
+ def test_drafts_with_materialization_step_passes(vr):
396
+ failures: list[str] = []
397
+ data = _planning_data(
398
+ [dict(_SAMPLE_DRAFT)],
399
+ [{"stepwiseExecution": [
400
+ {"step": 1, "action": "GREEN: add classifier table", "files": "migrations/x.sql"},
401
+ {"step": 2,
402
+ "action": "create decision record from §5.4 Decision Drafts 0007-classifier-table-naming",
403
+ "files": ".okstra/decisions/0007-classifier-table-naming.md"},
404
+ ]}],
405
+ )
406
+ vr._validate_implementation_planning_decision_drafts(data, failures)
407
+ assert failures == []
408
+
409
+
410
+ def test_empty_drafts_is_noop(vr):
411
+ failures: list[str] = []
412
+ data = _planning_data([], [{"stepwiseExecution": [{"step": 1, "action": "x", "files": "y"}]}])
413
+ vr._validate_implementation_planning_decision_drafts(data, failures)
414
+ assert failures == []
415
+ ```
416
+
417
+ `_SAMPLE_DRAFT` 는 Task 1 에서 이미 이 파일 상단에 정의됨 — 재사용.
418
+
419
+ - [ ] **Step 2: 테스트 실패 확인**
420
+
421
+ Run: `python3 -m pytest tests/contract/test_decision_drafts.py -k "materialization or noop" -v`
422
+ Expected: FAIL — `AttributeError: module 'validate_run' has no attribute '_validate_implementation_planning_decision_drafts'`.
423
+
424
+ - [ ] **Step 3: validator 함수 추가** — `validators/validate-run.py` 의 `_validate_implementation_planning_cross_project` 함수([:1534-1562](../../../validators/validate-run.py:1534)) **바로 뒤**에 삽입:
425
+
426
+ ```python
427
+ def _validate_implementation_planning_decision_drafts(data: dict, failures: list[str]) -> None:
428
+ """`decisionDrafts` 가 비어있지 않으면 어느 stage 의 stepwiseExecution step 이
429
+ `.okstra/decisions/` 파일을 생성하는 materialization step 을 포함해야 한다.
430
+ 프로파일 Decision-record evaluation 의 "approved plan stepwise MUST include
431
+ Create .okstra/decisions/<NNNN>-<slug>.md" 를 실제 강제한다. draft 존재 자체가
432
+ trigger 이므로 self-contained 하다.
433
+ """
434
+ ip = data.get("implementationPlanning")
435
+ if not isinstance(ip, dict):
436
+ return
437
+ if not (ip.get("decisionDrafts") or []):
438
+ return
439
+ has_materialization = any(
440
+ ".okstra/decisions/" in (step.get("files") or "")
441
+ or ".okstra/decisions/" in (step.get("action") or "")
442
+ for stage in (ip.get("stages") or [])
443
+ if isinstance(stage, dict)
444
+ for step in (stage.get("stepwiseExecution") or [])
445
+ if isinstance(step, dict)
446
+ )
447
+ if not has_materialization:
448
+ failures.append(
449
+ "final-report data.json: implementationPlanning.decisionDrafts is "
450
+ "non-empty but no stage's stepwiseExecution creates a "
451
+ "`.okstra/decisions/<NNNN>-<slug>.md` file. The approved plan must "
452
+ "materialize each decision draft via a stepwise step (profile "
453
+ "Decision-record evaluation)."
454
+ )
455
+ ```
456
+
457
+ - [ ] **Step 4: dispatch 배선** — `validators/validate-run.py:1492-1493`
458
+
459
+ ```python
460
+ elif task_type == "implementation-planning":
461
+ _validate_implementation_planning_cross_project(data, failures)
462
+ _validate_implementation_planning_decision_drafts(data, failures)
463
+ ```
464
+
465
+ - [ ] **Step 5: 헤딩 contract 추가** — `validators/validate-run.py:1132`
466
+
467
+ `PLANNING_REQUIRED_SECTIONS` 튜플([:1132-1144](../../../validators/validate-run.py:1132))의 `"Cross-Project Dependencies",` 다음에 `"Decision Drafts",` 추가.
468
+
469
+ - [ ] **Step 6: validator 테스트 통과 확인**
470
+
471
+ Run: `python3 -m pytest tests/contract/test_decision_drafts.py -v`
472
+ Expected: PASS — 모양 7개 + validator 3개 모두 통과.
473
+
474
+ - [ ] **Step 7: 커밋**
475
+
476
+ ```bash
477
+ git add validators/validate-run.py tests/contract/test_decision_drafts.py
478
+ git commit -m "feat(validate-run): gate decisionDrafts on materialization step + heading"
479
+ ```
480
+
481
+ ---
482
+
483
+ ### Task 4: 프로파일 / report-writer prose 정합
484
+
485
+ **Files:**
486
+ - Modify: `prompts/profiles/implementation-planning.md` (heading contract line 57; Decision-record evaluation 111-116; self-review)
487
+ - Modify: `agents/workers/report-writer-worker.md` (populate 안내 1줄)
488
+
489
+ **Interfaces:**
490
+ - Consumes: Task 1-3 의 필드명·검증 규칙. prose 만 변경, 코드 무변경.
491
+
492
+ - [ ] **Step 1: heading contract 갱신** — `prompts/profiles/implementation-planning.md:57`
493
+
494
+ `Cross-Project Dependencies`, 다음(혹은 `Requirement Coverage` 인근)에 `Decision Drafts` 를 필수 substring 목록에 추가. 즉 `... \`Cross-Project Dependencies\`, \`Decision Drafts\`, \`Validation Checklist\`, ...` 형태.
495
+
496
+ - [ ] **Step 2: Decision-record evaluation 블록 재작성** — `prompts/profiles/implementation-planning.md:115-116`
497
+
498
+ 기존 두 단락(line 115 "If **all three** hold, attach a decision draft as a report appendix section ..." + line 116 "The drafts are NOT written by this phase. ...")을 다음으로 교체:
499
+
500
+ ```markdown
501
+ If **all three** hold, record a decision draft as a `decisionDrafts[]` row (rendered in §5.4 `### Decision Drafts`, one `#### <number>-<slug>` subsection each). Each row carries `number` (= `(max existing in <PROJECT_ROOT>/.okstra/decisions/ + 1)` zero-padded to ≥4 digits), `slug` (kebab-case), `status: Proposed`, and the `context` / `decision` / `consequences` / `alternativesConsidered` fields (`alternativesConsidered` names the rejected alternatives and why). If any of the three criteria is missing, do NOT raise a draft — instead record a `skippedAdrCandidates[]` row (`topic` + `reason: <criterion that failed>`, rendered as a `skipped adr-candidate: … — reason: …` line under the same section) so the next reader knows the candidate was evaluated and intentionally dropped.
502
+ The decision files are NOT written by this phase. The approved plan's stepwise execution order MUST include the step `Create <PROJECT_ROOT>/.okstra/decisions/<NNNN>-<slug>.md from the §5.4 Decision Drafts subsection <number>-<slug>` (materializing the structured fields into the file's `## Status / ## Context / ## Decision / ## Consequences / ## Alternatives Considered` shape) so the `implementation` run commits the file inside okstra's subtree. `validators/validate-run.py` enforces this: a non-empty `decisionDrafts` whose stages carry no stepwise step referencing `.okstra/decisions/` is `contract-violated`.
503
+ ```
504
+
505
+ - [ ] **Step 3: self-review 패스에 점검 추가** — `prompts/profiles/implementation-planning.md` self-review([:123-132](../../../prompts/profiles/implementation-planning.md:123))의 마지막 항목(9. Cross-project) 다음에 추가:
506
+
507
+ ```markdown
508
+ 10. **Decision-draft materialization check** — `decisionDrafts` 가 비어있지 않으면, 매칭 materialization step(`.okstra/decisions/<NNNN>-<slug>.md` 생성)이 어느 stage 의 stepwise 에 존재하는지, draft 개수와 materialization step 이 1:1 로 대응하는지 reviewer 로서 확인한다. validator 는 step 의 *존재*만 보므로 `<NNNN>-<slug>` 정확성·개수 대응은 self-review 가 책임진다.
509
+ ```
510
+
511
+ - [ ] **Step 4: report-writer populate 안내 추가** — `agents/workers/report-writer-worker.md`
512
+
513
+ implementation-planning requirementCoverage 안내([:103](../../../agents/workers/report-writer-worker.md:103)) 다음에 한 줄 추가:
514
+
515
+ ```markdown
516
+ - For `implementation-planning`, also populate `implementationPlanning.decisionDrafts` (one row per decision meeting all three decision-record criteria; `[]` otherwise) and `implementationPlanning.skippedAdrCandidates` (evaluated-but-dropped adr-candidates; `[]` otherwise). The schema excerpt enumerates the row shape; the renderer emits §5.4 `### Decision Drafts`.
517
+ ```
518
+
519
+ - [ ] **Step 5: phase contract validator 확인**
520
+
521
+ Run: `bash validators/validate-workflow.sh`
522
+ Expected: exit 0 — 프로파일/템플릿 헤딩 contract 정합.
523
+
524
+ - [ ] **Step 6: 커밋**
525
+
526
+ ```bash
527
+ git add prompts/profiles/implementation-planning.md agents/workers/report-writer-worker.md
528
+ git commit -m "docs(profile): align Decision Drafts prose with structured fields"
529
+ ```
530
+
531
+ ---
532
+
533
+ ### Task 5: 빌드 동기화 + 완료 게이트
534
+
535
+ **Files:** 없음(빌드 출력 `runtime/` 은 gitignored — 커밋 대상 아님)
536
+
537
+ - [ ] **Step 1: runtime 동기화**
538
+
539
+ Run: `npm run build`
540
+ Expected: exit 0 — 소스 6개 디렉터리가 `runtime/` 으로 rsync.
541
+
542
+ - [ ] **Step 2: 완료 게이트 — 전체 스위트**
543
+
544
+ Run: `npm run check`
545
+ Expected: exit 0 — test:js 후 test:py 전부 통과. 실패 시 해당 Task 로 돌아가 수정.
546
+
547
+ - [ ] **Step 3: 빌드 산출물 정합 스모크**
548
+
549
+ Run: `node bin/okstra doctor`
550
+ Expected: 런타임/스킬 진단 정상.
551
+
552
+ ---
553
+
554
+ ## Self-Review
555
+
556
+ **1. Spec coverage** (스펙 §3 각 항목 → Task):
557
+ - §3.1 데이터 모델(`$defs` 2개, properties+required) → Task 1 Step 3-4 ✓
558
+ - §3.2 렌더(템플릿 §5.4 블록) + i18n → Task 2 Step 3-5 ✓
559
+ - §3.3(a) 모양 강제(스키마) → Task 1 ✓ / §3.3(b) 자기완결 trigger → Task 3 Step 3-4 ✓ / §3.3(c) 헤딩 contract → Task 2 Step 6 + Task 3 Step 5 + Task 4 Step 1 (세 곳) ✓
560
+ - §3.4 프로파일 prose → Task 4 Step 1-3 ✓
561
+ - §3.5 report-writer 정합 → Task 4 Step 4 ✓
562
+ - §3.6 excerpt/빌드 → Task 1 Step 6(`test_no_dangling_refs` 자동 커버) + Task 5 Step 1 ✓
563
+ - §6 검증 계획(render/validator/excerpt/픽스처 + `npm run check`) → Task 1·2·3 테스트 + Task 5 ✓
564
+ - §7 한계(완전성·1:1 정확성 watertight 불가) → Task 4 Step 3 self-review prose 로 보강 ✓
565
+
566
+ **2. Placeholder scan:** 모든 step 에 실제 코드/명령/기대출력 명시. "TBD"/"적절히"/"유사하게" 없음. ✓
567
+
568
+ **3. Type consistency:** 필드명 `number`/`slug`/`status`/`context`/`decision`/`consequences`/`alternativesConsidered`(draft), `topic`/`reason`(skipped), 함수 `_validate_implementation_planning_decision_drafts`, i18n 키 `decisionDraftFields.{status,context,decision,consequences,alternativesConsidered}` — Task 1→2→3→4 전체에서 동일하게 사용. 헤딩 substring `Decision Drafts` 는 세 곳(테스트/validator/프로파일) 일치. ✓