okstra 0.102.1 → 0.102.3

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 (162) hide show
  1. package/README.kr.md +5 -6
  2. package/README.md +5 -6
  3. package/docs/kr/architecture/storage-model.md +1 -1
  4. package/docs/kr/architecture.md +8 -12
  5. package/docs/kr/cli.md +6 -6
  6. package/docs/kr/container.md +1 -1
  7. package/docs/kr/performance-improvement-plan-v2.md +2 -2
  8. package/docs/project-structure-overview.md +2 -2
  9. package/package.json +7 -2
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/okstra-antigravity-exec.sh +1 -2
  12. package/runtime/bin/okstra-codex-exec.sh +1 -2
  13. package/runtime/bin/okstra-render-final-report.py +2 -9
  14. package/runtime/bin/okstra-spawn-followups.py +4 -6
  15. package/runtime/bin/okstra-trace-cleanup.sh +24 -4
  16. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  17. package/runtime/prompts/lead/report-writer.md +1 -1
  18. package/runtime/prompts/profiles/_common-contract.md +9 -11
  19. package/runtime/python/okstra_ctl/codex_dispatch.py +4 -12
  20. package/runtime/python/okstra_ctl/consumers.py +35 -0
  21. package/runtime/python/okstra_ctl/container.py +4 -6
  22. package/runtime/python/okstra_ctl/dispatch_core.py +4 -12
  23. package/runtime/python/okstra_ctl/final_report_paths.py +24 -0
  24. package/runtime/python/okstra_ctl/handoff.py +2 -1
  25. package/runtime/python/okstra_ctl/implementation_stage.py +7 -11
  26. package/runtime/python/okstra_ctl/report_view_artifacts.py +14 -0
  27. package/runtime/python/okstra_ctl/report_views.py +7 -4
  28. package/runtime/python/okstra_ctl/run.py +3 -10
  29. package/runtime/python/okstra_ctl/stage_targets.py +18 -0
  30. package/runtime/python/okstra_ctl/wizard.py +24 -25
  31. package/runtime/python/okstra_ctl/wizard_stage_intent.py +54 -0
  32. package/runtime/python/okstra_token_usage/report.py +2 -5
  33. package/runtime/validators/validate-report-views.py +2 -1
  34. package/runtime/validators/validate-run.py +1 -11
  35. package/src/cli-registry.mjs +1 -1
  36. package/src/commands/lifecycle/install.mjs +10 -20
  37. package/src/commands/lifecycle/uninstall.mjs +7 -10
  38. package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +0 -638
  39. package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +0 -1568
  40. package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +0 -167
  41. package/docs/superpowers/plans/2026-05-20-final-report-language.md +0 -1501
  42. package/docs/superpowers/plans/2026-05-20-implementation-planning-multi-stage.md +0 -1267
  43. package/docs/superpowers/plans/2026-05-20-okstra-run-prompt-sot-b1.md +0 -1007
  44. package/docs/superpowers/plans/2026-05-20-wizard-messages-json-sot.md +0 -720
  45. package/docs/superpowers/plans/2026-05-20-wizard-prompt-json-sot-a1.md +0 -681
  46. package/docs/superpowers/plans/2026-05-21-improvement-discovery-task-type.md +0 -1691
  47. package/docs/superpowers/plans/2026-05-24-implementation-lead-context-slimming.md +0 -1700
  48. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +0 -158
  49. package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +0 -860
  50. package/docs/superpowers/plans/2026-06-02-final-verification-protocol-hardening.md +0 -326
  51. package/docs/superpowers/plans/2026-06-02-okstra-run-branch-confirm-step.md +0 -337
  52. package/docs/superpowers/plans/2026-06-02-okstra-run-phase-pane-cleanup.md +0 -410
  53. package/docs/superpowers/plans/2026-06-02-requirements-discovery-fanout.md +0 -728
  54. package/docs/superpowers/plans/2026-06-04-adversarial-implementation-planning.md +0 -294
  55. package/docs/superpowers/plans/2026-06-04-adversarial-verification.md +0 -570
  56. package/docs/superpowers/plans/2026-06-04-coverage-critic.md +0 -516
  57. package/docs/superpowers/plans/2026-06-04-stage-cohesion-planner.md +0 -351
  58. package/docs/superpowers/plans/2026-06-04-stage-run-batching.md +0 -457
  59. package/docs/superpowers/plans/2026-06-05-acceptance-critic.md +0 -251
  60. package/docs/superpowers/plans/2026-06-05-compact-markdown-report-tables.md +0 -323
  61. package/docs/superpowers/plans/2026-06-05-wizard-batch-prompts.md +0 -559
  62. package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +0 -993
  63. package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +0 -93
  64. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +0 -447
  65. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +0 -289
  66. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +0 -774
  67. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +0 -303
  68. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +0 -387
  69. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase2.md +0 -275
  70. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase3.md +0 -282
  71. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4a.md +0 -147
  72. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4b.md +0 -262
  73. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4c.md +0 -184
  74. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4d.md +0 -88
  75. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4e.md +0 -250
  76. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa.md +0 -409
  77. package/docs/superpowers/plans/2026-06-09-implementation-run-artifact-stage-isolation.md +0 -320
  78. package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +0 -456
  79. package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +0 -1408
  80. package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling-PROBE.md +0 -42
  81. package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling.md +0 -337
  82. package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +0 -1029
  83. package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +0 -1572
  84. package/docs/superpowers/plans/2026-06-11-fix-cycle.md +0 -1290
  85. package/docs/superpowers/plans/2026-06-11-wizard-whole-task-final-verification.md +0 -526
  86. package/docs/superpowers/plans/2026-06-12-html-plan-approval.md +0 -1000
  87. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +0 -493
  88. package/docs/superpowers/plans/2026-06-14-host-runtime-auto-execution.md +0 -1775
  89. package/docs/superpowers/plans/2026-06-15-coding-preflight-pack-dispatch-path.md +0 -504
  90. package/docs/superpowers/plans/2026-06-15-internal-skill-migration-final-fixups.md +0 -342
  91. package/docs/superpowers/plans/2026-06-15-internal-skill-migration-fixups.md +0 -258
  92. package/docs/superpowers/plans/2026-06-15-internal-skill-migration-remaining-fixups.md +0 -387
  93. package/docs/superpowers/plans/2026-06-15-internal-skill-resource-migration.md +0 -749
  94. package/docs/superpowers/plans/2026-06-15-worker-prompt-anchor-final-fixups.md +0 -828
  95. package/docs/superpowers/plans/2026-06-15-worker-prompt-header-error-contract.md +0 -490
  96. package/docs/superpowers/plans/2026-06-17-okstra-error-report.md +0 -724
  97. package/docs/superpowers/plans/2026-06-18-subagent-pane-reclaim.md +0 -483
  98. package/docs/superpowers/plans/2026-06-19-agent-operating-standard.md +0 -476
  99. package/docs/superpowers/plans/2026-06-19-okstra-inspect-recap-facet.md +0 -647
  100. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +0 -1019
  101. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +0 -583
  102. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +0 -819
  103. package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +0 -714
  104. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +0 -348
  105. package/docs/superpowers/plans/2026-06-23-implementation-stage-multiselect-chain.md +0 -630
  106. package/docs/superpowers/plans/2026-06-23-implementation-step-commit-granularity.md +0 -274
  107. package/docs/superpowers/plans/2026-06-23-phase-batch-cleanup-enforcement.md +0 -409
  108. package/docs/superpowers/plans/2026-06-24-cross-project-precondition.md +0 -542
  109. package/docs/superpowers/plans/2026-06-24-decision-drafts-render-gap.md +0 -568
  110. package/docs/superpowers/plans/2026-06-24-manual-user-test-section.md +0 -203
  111. package/docs/superpowers/plans/2026-06-24-okstra-container-build-rename.md +0 -525
  112. package/docs/superpowers/specs/2026-05-12-ticket-id-in-reports-design.md +0 -131
  113. package/docs/superpowers/specs/2026-05-15-implementation-plan-verification-design.md +0 -254
  114. package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +0 -383
  115. package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +0 -322
  116. package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +0 -299
  117. package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +0 -335
  118. package/docs/superpowers/specs/2026-06-02-okstra-run-branch-confirm-step-design.md +0 -113
  119. package/docs/superpowers/specs/2026-06-02-okstra-run-phase-pane-cleanup-design.md +0 -173
  120. package/docs/superpowers/specs/2026-06-02-requirements-discovery-fanout-design.md +0 -154
  121. package/docs/superpowers/specs/2026-06-04-adversarial-implementation-planning-design.md +0 -90
  122. package/docs/superpowers/specs/2026-06-04-adversarial-verification-design.md +0 -176
  123. package/docs/superpowers/specs/2026-06-04-coverage-critic-design.md +0 -99
  124. package/docs/superpowers/specs/2026-06-04-stage-splitting-cost-aware-design.md +0 -98
  125. package/docs/superpowers/specs/2026-06-05-acceptance-critic-design.md +0 -90
  126. package/docs/superpowers/specs/2026-06-05-compact-markdown-report-tables-design.md +0 -87
  127. package/docs/superpowers/specs/2026-06-05-wizard-batch-prompts-design.md +0 -121
  128. package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +0 -126
  129. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +0 -180
  130. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +0 -179
  131. package/docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md +0 -169
  132. package/docs/superpowers/specs/2026-06-09-executor-model-custom-id-cascade-design.md +0 -66
  133. package/docs/superpowers/specs/2026-06-09-implementation-run-artifact-stage-isolation-design.md +0 -87
  134. package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +0 -168
  135. package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +0 -107
  136. package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +0 -105
  137. package/docs/superpowers/specs/2026-06-10-lead-worker-completion-polling-design.md +0 -113
  138. package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +0 -156
  139. package/docs/superpowers/specs/2026-06-11-brief-entry-only-handoff-stage-entry-design.md +0 -158
  140. package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +0 -94
  141. package/docs/superpowers/specs/2026-06-11-wizard-whole-task-final-verification-design.md +0 -89
  142. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +0 -358
  143. package/docs/superpowers/specs/2026-06-12-html-plan-approval-design.md +0 -85
  144. package/docs/superpowers/specs/2026-06-12-stage-discipline-rule-design.md +0 -108
  145. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +0 -134
  146. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +0 -284
  147. package/docs/superpowers/specs/2026-06-14-host-runtime-auto-execution-design.md +0 -404
  148. package/docs/superpowers/specs/2026-06-17-okstra-error-report-design.md +0 -123
  149. package/docs/superpowers/specs/2026-06-18-subagent-pane-reclaim-design.md +0 -150
  150. package/docs/superpowers/specs/2026-06-19-agent-operating-standard-design.md +0 -120
  151. package/docs/superpowers/specs/2026-06-19-okstra-inspect-recap-facet-design.md +0 -96
  152. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +0 -171
  153. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +0 -112
  154. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +0 -145
  155. package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +0 -125
  156. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +0 -113
  157. package/docs/superpowers/specs/2026-06-23-implementation-stage-multiselect-chain-design.md +0 -138
  158. package/docs/superpowers/specs/2026-06-23-implementation-step-commit-granularity-design.md +0 -120
  159. package/docs/superpowers/specs/2026-06-24-cross-project-precondition-design.md +0 -129
  160. package/docs/superpowers/specs/2026-06-24-decision-drafts-render-gap-design.md +0 -185
  161. package/docs/superpowers/specs/2026-06-24-manual-user-test-section-design.md +0 -121
  162. package/docs/superpowers/specs/2026-06-24-okstra-container-build-rename-design.md +0 -110
@@ -1,1000 +0,0 @@
1
- # final-report HTML 승인·옵션 선택 구현 계획
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 final-report 의 HTML 뷰에 승인 체크 + 구현 옵션 선택 위젯을 추가하고, Export sidecar 의 `## APPROVAL` 블록을 implementation 마법사가 감지·확인 후 기존 승인 경로로 적용한다.
6
-
7
- **Architecture:** HTML 은 원본 md/data.json 을 절대 수정하지 않는다 — 위젯 값은 기존 user-response sidecar 에 `## APPROVAL` 블록으로 직렬화되고, 마법사 `approve_plan_confirm` 단계가 sidecar 를 감지해 기존 `_approve_plan_in_place`(data.json 플립) + `_apply_cli_implementation_option`(frontmatter 치환)을 재사용해 적용한다. 스펙: `docs/superpowers/specs/2026-06-12-html-plan-approval-design.md`.
8
-
9
- **Tech Stack:** Python 3.11 (scripts/okstra_ctl), vanilla JS (templates/reports/report.js), pytest (+ Node byte-identity 하니스).
10
-
11
- **전제 (이미 코드베이스에 있음):**
12
- - `scan_approval_gate` — `scripts/okstra_ctl/clarification_items.py` (§1 Blocks=approval 스캔, fail-closed).
13
- - `_approve_plan_in_place` / `_classify_approved_plan` — `scripts/okstra_ctl/wizard.py:538` / `:486`.
14
- - `_apply_cli_implementation_option` — `scripts/okstra_ctl/run.py:744`.
15
- - 마법사 prompt 텍스트 SOT — `prompts/wizard/prompts.ko.json` → `steps.approve_plan_confirm`.
16
- - 동시 세션 주의: `scripts/okstra_ctl/md_table.py`(escape_pipes/split_pipe_row)는 다른 세션이 작업 중인 미커밋 변경 — 그 파일을 수정하지도 커밋하지도 말 것. 커밋은 항상 이 계획의 파일만 `git add <paths>` 로 명시.
17
-
18
- ---
19
-
20
- ### Task 1: Python serializer — `## APPROVAL` 블록
21
-
22
- **Files:**
23
- - Modify: `scripts/okstra_ctl/report_views.py` (serialize_user_response, ~:726)
24
- - Test: `tests/test_report_views.py`
25
-
26
- - [ ] **Step 1: 실패 테스트 작성** — `tests/test_report_views.py` 의 serializer 섹션(`test_python_and_js_serialisers_byte_identical_nonempty` 위)에 추가:
27
-
28
- ```python
29
- def test_serialize_user_response_appends_approval_block():
30
- from okstra_ctl.report_views import UserResponseApproval
31
-
32
- meta = _meta("implementation-planning")
33
- out = serialize_user_response(
34
- run_meta=meta, entries=[], created_at="2026-06-12T00:00:00Z",
35
- approval=UserResponseApproval(approved=True, implementation_option="Option B — y"),
36
- )
37
- assert out.endswith(
38
- "# User Response\n\n## APPROVAL\n- Approved: true\n- Implementation-Option: Option B — y\n"
39
- )
40
- # 승인이 있으면 빈-응답 플레이스홀더는 나오지 않는다.
41
- assert "_(No user responses recorded.)_" not in out
42
-
43
-
44
- def test_serialize_user_response_approval_without_option_omits_line():
45
- from okstra_ctl.report_views import UserResponseApproval
46
-
47
- meta = _meta("implementation-planning")
48
- out = serialize_user_response(
49
- run_meta=meta,
50
- entries=[UserResponseEntry("C-001", "decision", "(a)", None)],
51
- created_at="2026-06-12T00:00:00Z",
52
- approval=UserResponseApproval(approved=True),
53
- )
54
- assert "\n## APPROVAL\n- Approved: true\n" in out
55
- assert "Implementation-Option" not in out
56
-
57
-
58
- def test_serialize_user_response_without_approval_is_unchanged():
59
- meta = _meta("implementation-planning")
60
- out = serialize_user_response(run_meta=meta, entries=[], created_at="2026-06-12T00:00:00Z")
61
- assert out.endswith("_(No user responses recorded.)_\n")
62
- ```
63
-
64
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_report_views.py -k approval_block -q`
65
- Expected: FAIL (`cannot import name 'UserResponseApproval'` 또는 TypeError unexpected keyword `approval`)
66
-
67
- - [ ] **Step 3: 구현** — `report_views.py` 의 `UserResponseEntry` dataclass 아래에 추가하고 `serialize_user_response` 를 다음으로 교체:
68
-
69
- ```python
70
- @dataclass(frozen=True)
71
- class UserResponseApproval:
72
- """HTML Plan Approval 위젯의 Export 결과. ``implementation_option`` 이 빈
73
- 문자열이면 라인을 생략한다 (소비 측은 Recommended Option 폴백)."""
74
- approved: bool
75
- implementation_option: str = ""
76
-
77
-
78
- def serialize_user_response(
79
- *,
80
- run_meta: RunMeta,
81
- entries: list[UserResponseEntry],
82
- created_at: str,
83
- approval: UserResponseApproval | None = None,
84
- ) -> str:
85
- """Return the canonical markdown text the HTML 'Export user
86
- response' button must produce. Used by validators to confirm that
87
- pasted sidecar files conform to the schema and that the JS
88
- serialiser stayed in sync.
89
- """
90
- head = (
91
- "---\n"
92
- f"task-key: {run_meta.task_key}\n"
93
- f"task-type: {run_meta.task_type}\n"
94
- f"seq: {run_meta.seq}\n"
95
- f"source-report: {run_meta.source_report}\n"
96
- "created-by: user\n"
97
- f"created-at: {created_at}\n"
98
- "---\n"
99
- "\n"
100
- "# User Response\n"
101
- )
102
- has_approval = approval is not None and approval.approved
103
- body_chunks: list[str] = []
104
- for e in entries:
105
- chunk = (
106
- f"\n## {e.response_id}\n"
107
- f"- Kind: {e.kind}\n"
108
- "- Value:\n"
109
- f" > {e.value.strip()}\n"
110
- )
111
- if e.rationale:
112
- chunk += f"- Rationale: {e.rationale.strip()}\n"
113
- body_chunks.append(chunk)
114
- if not entries and not has_approval:
115
- body_chunks.append("\n_(No user responses recorded.)_\n")
116
- if has_approval:
117
- chunk = "\n## APPROVAL\n- Approved: true\n"
118
- if approval.implementation_option:
119
- chunk += f"- Implementation-Option: {approval.implementation_option.strip()}\n"
120
- body_chunks.append(chunk)
121
- return head + "".join(body_chunks)
122
- ```
123
-
124
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_report_views.py -q` → 전체 PASS
125
- - [ ] **Step 5: Commit** — `git add scripts/okstra_ctl/report_views.py tests/test_report_views.py && git commit -m "feat(okstra-ctl/report-views): user-response sidecar 에 APPROVAL 블록 직렬화 추가"`
126
-
127
- ---
128
-
129
- ### Task 2: JS serializer + byte-identity
130
-
131
- **Files:**
132
- - Modify: `templates/reports/report.js` (buildUserResponseMarkdown :116, exportUserResponse :164)
133
- - Test: `tests/test_report_views.py` (`_js_serialise` :484)
134
-
135
- - [ ] **Step 1: 실패 테스트** — `_js_serialise` 를 4번째 인자를 받도록 교체하고 parity 테스트 추가:
136
-
137
- ```python
138
- def _js_serialise(
139
- meta: dict, entries: list[dict], created_at: str, approval: dict | None = None
140
- ) -> str:
141
- """Run templates/reports/report.js via Node + vm to get the JS
142
- serialiser's bytes."""
143
- assert NODE is not None
144
- script = (
145
- "const fs=require('fs');const vm=require('vm');"
146
- f"vm.runInThisContext(fs.readFileSync({str((REPO_ROOT / 'templates' / 'reports' / 'report.js'))!r},'utf8'));"
147
- "const m=globalThis.__okstraReportViewExports__;"
148
- "const meta=JSON.parse(process.argv[1]);"
149
- "const entries=JSON.parse(process.argv[2]);"
150
- "const approval=JSON.parse(process.argv[4]);"
151
- "process.stdout.write(m.buildUserResponseMarkdown(meta, entries, process.argv[3], approval));"
152
- )
153
- import json as _json
154
-
155
- result = subprocess.run(
156
- [NODE, "-e", script, _json.dumps(meta), _json.dumps(entries), created_at,
157
- _json.dumps(approval)],
158
- check=True,
159
- capture_output=True,
160
- text=True,
161
- )
162
- return result.stdout
163
-
164
-
165
- @pytest.mark.skipif(NODE is None, reason="node not available")
166
- def test_python_and_js_serialisers_byte_identical_with_approval():
167
- from okstra_ctl.report_views import UserResponseApproval
168
-
169
- meta = _meta("implementation-planning")
170
- py_out = serialize_user_response(
171
- run_meta=meta, entries=[], created_at="2026-06-12T00:00:00Z",
172
- approval=UserResponseApproval(approved=True, implementation_option="Option B — y"),
173
- )
174
- js_out = _js_serialise(
175
- {"task-key": meta.task_key, "task-type": meta.task_type,
176
- "seq": meta.seq, "source-report": meta.source_report},
177
- [], "2026-06-12T00:00:00Z",
178
- {"approved": True, "implementationOption": "Option B — y"},
179
- )
180
- assert py_out == js_out
181
- ```
182
-
183
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_report_views.py -k byte_identical -q`
184
- Expected: with_approval FAIL (JS 가 4번째 인자를 무시 → empty 플레이스홀더 출력), 기존 2종은 PASS 유지
185
- - [ ] **Step 3: 구현** — `report.js` 의 `buildUserResponseMarkdown` 을 교체:
186
-
187
- ```js
188
- function buildUserResponseMarkdown(runMeta, entries, createdAt, approval) {
189
- var head =
190
- "---\n" +
191
- "task-key: " + (runMeta["task-key"] || "") + "\n" +
192
- "task-type: " + (runMeta["task-type"] || "") + "\n" +
193
- "seq: " + (runMeta["seq"] || "") + "\n" +
194
- "source-report: " + (runMeta["source-report"] || "") + "\n" +
195
- "created-by: user\n" +
196
- "created-at: " + createdAt + "\n" +
197
- "---\n" +
198
- "\n" +
199
- "# User Response\n";
200
-
201
- entries = entries || [];
202
- var hasApproval = !!(approval && approval.approved);
203
- var chunks = "";
204
- for (var i = 0; i < entries.length; i++) {
205
- var e = entries[i];
206
- var chunk =
207
- "\n## " + e.responseId + "\n" +
208
- "- Kind: " + e.kind + "\n" +
209
- "- Value:\n" +
210
- " > " + trimMultiline(e.value) + "\n";
211
- if (e.rationale) {
212
- chunk += "- Rationale: " + trimMultiline(e.rationale) + "\n";
213
- }
214
- chunks += chunk;
215
- }
216
- if (entries.length === 0 && !hasApproval) {
217
- chunks += "\n_(No user responses recorded.)_\n";
218
- }
219
- if (hasApproval) {
220
- chunks += "\n## APPROVAL\n- Approved: true\n";
221
- if (approval.implementationOption) {
222
- chunks +=
223
- "- Implementation-Option: " + trimMultiline(approval.implementationOption) + "\n";
224
- }
225
- }
226
- return head + chunks;
227
- }
228
- ```
229
-
230
- 같은 파일의 `collectEntries` 아래에 위젯 수집 함수를 추가하고 `exportUserResponse` 를 교체:
231
-
232
- ```js
233
- // Plan Approval 위젯 (implementation-planning 보고서에만 존재). 체크박스가
234
- // disabled(§1 차단 미해소)이거나 미체크면 null. 옵션이 추천(data-recommended)
235
- // 그대로면 implementationOption 을 비워 보낸다 → 소비 측 Recommended 폴백.
236
- function collectApproval() {
237
- var box = document.getElementById("approval-checkbox");
238
- if (!box || box.disabled || !box.checked) return null;
239
- var sel = document.getElementById("approval-option");
240
- var option = "";
241
- if (sel && sel.selectedIndex >= 0) {
242
- var opt = sel.options[sel.selectedIndex];
243
- if (opt && opt.getAttribute("data-recommended") !== "true") {
244
- option = opt.value;
245
- }
246
- }
247
- return { approved: true, implementationOption: option };
248
- }
249
-
250
- function exportUserResponse() {
251
- var runMeta = readRunMeta();
252
- var entries = collectEntries();
253
- var approval = collectApproval();
254
- var md = buildUserResponseMarkdown(runMeta, entries, isoNowUtc(), approval);
255
- var out = document.getElementById("user-response-output");
256
- if (out) out.textContent = md;
257
- // Nothing answered yet — show the empty serialisation as feedback but
258
- // don't download a useless file.
259
- if (entries.length > 0 || approval) downloadUserResponse(md, runMeta);
260
- return md;
261
- }
262
- ```
263
-
264
- `window.okstraReportView` export 객체에 `collectApproval: collectApproval,` 한 줄 추가.
265
-
266
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_report_views.py -q` → 전체 PASS
267
- - [ ] **Step 5: Commit** — `git add templates/reports/report.js tests/test_report_views.py && git commit -m "feat(reports/report.js): Plan Approval 위젯 수집 + APPROVAL 블록 직렬화 (Python 과 byte-identical)"`
268
-
269
- ---
270
-
271
- ### Task 3: HTML 위젯 렌더 + 생성 조건 확장
272
-
273
- **Files:**
274
- - Modify: `scripts/okstra_ctl/report_views.py` (render_html :94, render_html_view :783)
275
- - Modify: `templates/reports/report.css`
276
- - Modify: `validators/validate-report-views.py` (:101 분기)
277
- - Test: `tests/test_report_views.py`
278
-
279
- - [ ] **Step 1: 실패 테스트 작성**:
280
-
281
- ```python
282
- def _planning_md(*, c_row: bool = True) -> str:
283
- clar = (
284
- "| 항목 | Statement | User input |\n|---|---|---|\n"
285
- "| **C-001**<br>Ticket: `T-1`<br>Kind: `decision`<br>Blocks: `none`<br>Status: open | 질문 | |\n"
286
- if c_row else "- 추가 정보 요청 없음.\n"
287
- )
288
- return (
289
- "---\napproved: false\nimplementation-option:\n---\n"
290
- "# demo/T-1 - Multi-Agent Cross Verification Final Report\n\n"
291
- "## 1. Clarification Items\n\n" + clar + "\nbody\n"
292
- )
293
-
294
-
295
- def _planning_data_json() -> str:
296
- import json as _json
297
- return _json.dumps({
298
- "frontmatter": {"approved": False},
299
- "implementationPlanning": {
300
- "optionCandidates": [{"name": "Option A — x"}, {"name": "Option B — y"}],
301
- "recommendedOption": {"name": "Option A — x"},
302
- },
303
- })
304
-
305
-
306
- def _render_planning_view(tmp_path, md_text: str, *, with_data: bool = True):
307
- from okstra_ctl.report_views import render_html_view
308
-
309
- reports = tmp_path / "runs" / "implementation-planning" / "reports"
310
- reports.mkdir(parents=True)
311
- md = reports / "final-report-implementation-planning-002.md"
312
- md.write_text(md_text, encoding="utf-8")
313
- if with_data:
314
- md.with_name(md.stem + ".data.json").write_text(_planning_data_json(), encoding="utf-8")
315
- return md, render_html_view(md, run_meta=_meta("implementation-planning"), css=CSS, js=JS)
316
-
317
-
318
- def test_html_plan_approval_widget_renders_with_recommended_default(tmp_path):
319
- md, html_path = _render_planning_view(tmp_path, _planning_md())
320
- body = html_path.read_text(encoding="utf-8")
321
- assert '<section id="plan-approval">' in body
322
- assert 'data-recommended="true" selected>Option A — x (권장)</option>' in body
323
- assert '<option value="Option B — y">Option B — y</option>' in body
324
- assert '<input type="checkbox" id="approval-checkbox">' in body # disabled 아님
325
-
326
-
327
- def test_html_plan_approval_disabled_when_blockers_open(tmp_path):
328
- blocked = _planning_md().replace("Blocks: `none`", "Blocks: `approval`")
329
- md, html_path = _render_planning_view(tmp_path, blocked)
330
- body = html_path.read_text(encoding="utf-8")
331
- assert '<section id="plan-approval">' in body
332
- assert 'id="approval-checkbox" disabled' in body
333
- assert "승인 차단 항목 1건" in body
334
-
335
-
336
- def test_html_plan_approval_absent_without_data_json(tmp_path):
337
- md, html_path = _render_planning_view(tmp_path, _planning_md(), with_data=False)
338
- assert 'id="plan-approval"' not in html_path.read_text(encoding="utf-8")
339
-
340
-
341
- def test_html_view_generated_for_approval_only_report(tmp_path):
342
- # C-행이 없어도 승인 위젯이 적용되면 html 을 생성한다 (생성 조건 확장).
343
- md, html_path = _render_planning_view(tmp_path, _planning_md(c_row=False))
344
- assert html_path is not None
345
- assert 'id="plan-approval"' in html_path.read_text(encoding="utf-8")
346
- ```
347
-
348
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_report_views.py -k plan_approval -q` → 4건 FAIL
349
- - [ ] **Step 3: 구현** — `report_views.py` 상단 import 에 `import json` 이 없으면 추가하고, `report_has_clarification_items` 아래에 추가:
350
-
351
- ```python
352
- @dataclass(frozen=True)
353
- class PlanApprovalContext:
354
- """Plan Approval 위젯 렌더 입력. ``disabled_reason`` 이 비어 있지 않으면
355
- 위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준)."""
356
- option_names: tuple[str, ...]
357
- recommended_option: str
358
- disabled_reason: str
359
-
360
-
361
- def plan_approval_context(
362
- src_md_path: Path, src_text: str
363
- ) -> PlanApprovalContext | None:
364
- """implementation-planning 보고서 + sibling data.json 의 optionCandidates 가
365
- 있을 때만 컨텍스트를 만든다. planning 여부는 task-type 문자열이 아니라
366
- data.json 의 ``implementationPlanning`` 키(SSOT)로 판정한다 — renderer 와
367
- validator 가 같은 판정을 공유한다."""
368
- data_path = src_md_path.with_name(src_md_path.stem + ".data.json")
369
- if not data_path.is_file():
370
- return None
371
- try:
372
- data = json.loads(data_path.read_text(encoding="utf-8"))
373
- except (OSError, json.JSONDecodeError):
374
- return None
375
- planning = data.get("implementationPlanning")
376
- if not isinstance(planning, dict):
377
- return None
378
- names = tuple(
379
- c.get("name", "")
380
- for c in (planning.get("optionCandidates") or [])
381
- if isinstance(c, dict) and c.get("name")
382
- )
383
- if not names:
384
- return None
385
- recommended = ""
386
- rec = planning.get("recommendedOption")
387
- if isinstance(rec, dict):
388
- recommended = rec.get("name") or ""
389
- scan = scan_approval_gate(src_text)
390
- if scan.unreadable_reason:
391
- reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
392
- elif scan.blockers:
393
- reason = (
394
- f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소 — 답변을 채워 Export 한 뒤 "
395
- "resume-clarification 으로 해소한 다음 보고서에서 승인하세요."
396
- )
397
- else:
398
- reason = ""
399
- return PlanApprovalContext(
400
- option_names=names, recommended_option=recommended, disabled_reason=reason
401
- )
402
-
403
-
404
- def _plan_approval_section(ctx: PlanApprovalContext) -> str:
405
- disabled = " disabled" if ctx.disabled_reason else ""
406
- opts: list[str] = []
407
- for name in ctx.option_names:
408
- is_rec = name == ctx.recommended_option
409
- label = f"{name} (권장)" if is_rec else name
410
- attrs = ' data-recommended="true" selected' if is_rec else ""
411
- opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
412
- reason_html = (
413
- f'\n <p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
414
- if ctx.disabled_reason else ""
415
- )
416
- return (
417
- '<section id="plan-approval">\n'
418
- " <h2>Plan Approval</h2>\n"
419
- f' <label>구현 옵션: <select id="approval-option"{disabled}>{"".join(opts)}</select></label>\n'
420
- f' <label><input type="checkbox" id="approval-checkbox"{disabled}> '
421
- "이 plan 을 승인합니다 — Export 시 sidecar 에 APPROVAL 블록으로 기록되고, "
422
- "implementation 시작 마법사가 확인 후 적용합니다.</label>"
423
- f"{reason_html}\n"
424
- "</section>\n"
425
- )
426
- ```
427
-
428
- `scan_approval_gate` 가 report_views 에 이미 import 되어 있는지 확인하고 없으면 `from okstra_ctl.clarification_items import scan_approval_gate` 형태로 기존 import 블록에 추가 (기존 import 스타일을 그대로 따른다).
429
-
430
- `render_html` 시그니처에 `approval_ctx: PlanApprovalContext | None = None` 키워드를 추가하고, 본문 f-string 의 `f"<main>{body_html}</main>\n"` 다음 줄에 삽입:
431
-
432
- ```python
433
- f"<main>{body_html}</main>\n"
434
- f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
435
- f"<footer class=\"report-footer\">\n"
436
- ```
437
-
438
- `render_html_view` 를 다음으로 교체 (생성 조건 확장 + ctx 전달):
439
-
440
- ```python
441
- def render_html_view(
442
- src_md_path: Path,
443
- *,
444
- run_meta: RunMeta,
445
- css: str,
446
- js: str,
447
- ) -> Path | None:
448
- """Write ``<stem>.html`` next to ``src_md_path`` and return its path,
449
- or return ``None`` when the report needs no interactive view — §1
450
- clarification rows 도 없고 Plan Approval 위젯 대상도 아닐 때
451
- (``report_has_clarification_items`` / ``plan_approval_context``).
452
- Idempotent — overwrites an existing html sibling, and removes a stale
453
- one when the report no longer needs a view."""
454
- src_text = src_md_path.read_text(encoding="utf-8")
455
- html_path = src_md_path.with_name(src_md_path.stem + ".html")
456
- approval_ctx = plan_approval_context(src_md_path, src_text)
457
- if not report_has_clarification_items(src_text) and approval_ctx is None:
458
- if html_path.is_file():
459
- html_path.unlink()
460
- return None
461
- html_text = render_html(
462
- src_text, run_meta=run_meta, css=css, js=js, approval_ctx=approval_ctx
463
- )
464
- html_path.write_text(html_text, encoding="utf-8")
465
- return html_path
466
- ```
467
-
468
- `templates/reports/report.css` 끝부분 `@media print` 블록 **앞**에 추가:
469
-
470
- ```css
471
- #plan-approval {
472
- max-width: 120ch;
473
- margin: 1.5rem auto;
474
- padding: 0.8em 1rem;
475
- border: 1px solid color-mix(in srgb, GrayText 40%, transparent);
476
- border-radius: 4px;
477
- }
478
- #plan-approval h2 { margin-top: 0; }
479
- #plan-approval label { display: block; margin: 0.4em 0; }
480
- #plan-approval select { font: inherit; max-width: 100%; }
481
- .approval-disabled-reason { color: GrayText; margin: 0.4em 0 0; }
482
- ```
483
-
484
- 그리고 `@media print` 블록 안에 `#plan-approval { display: none; }` 추가.
485
-
486
- `validators/validate-report-views.py` 의 html 부재 분기(:101)를 교체 — import 블록의 `from okstra_ctl.report_views import (...)` 에 `plan_approval_context` 를 추가하고:
487
-
488
- ```python
489
- if not html_path.is_file():
490
- if not md_ids and plan_approval_context(report_path, md) is None:
491
- return []
492
- return [f"missing html artifact: {html_path}"]
493
- ```
494
-
495
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_report_views.py -q` → 전체 PASS. 이어서 `python3 -m pytest tests/ -q` 로 validator 회귀 확인.
496
- - [ ] **Step 5: Commit** — `git add scripts/okstra_ctl/report_views.py templates/reports/report.css validators/validate-report-views.py tests/test_report_views.py && git commit -m "feat(okstra-ctl/report-views): Plan Approval 위젯 렌더 + 위젯 대상 보고서로 html 생성 조건 확장"`
497
-
498
- ---
499
-
500
- ### Task 4: user-responses/ 디렉토리 선생성
501
-
502
- **Files:**
503
- - Modify: `scripts/okstra_ctl/report_views.py` (render_html_view)
504
- - Test: `tests/test_report_views.py`
505
-
506
- - [ ] **Step 1: 실패 테스트**:
507
-
508
- ```python
509
- def test_html_view_precreates_user_responses_dir(tmp_path):
510
- md, html_path = _render_planning_view(tmp_path, _planning_md())
511
- assert (md.parent.parent / "user-responses").is_dir()
512
-
513
-
514
- def test_no_user_responses_dir_when_view_skipped(tmp_path):
515
- from okstra_ctl.report_views import render_html_view
516
-
517
- reports = tmp_path / "runs" / "implementation" / "reports"
518
- reports.mkdir(parents=True)
519
- md = reports / "final-report-implementation-001.md"
520
- md.write_text("# demo/T-1 - Final Report\n\nbody\n", encoding="utf-8")
521
- assert render_html_view(md, run_meta=_meta("implementation"), css=CSS, js=JS) is None
522
- assert not (md.parent.parent / "user-responses").exists()
523
- ```
524
-
525
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_report_views.py -k user_responses_dir -q` → precreates FAIL
526
- - [ ] **Step 3: 구현** — `render_html_view` 의 `html_path.write_text(...)` 바로 다음에:
527
-
528
- ```python
529
- # 사용자 확인(폼/승인)이 필요한 보고서 — Export 파일의 저장 위치를 미리
530
- # 만들어 사용자가 디렉토리를 만들 필요가 없게 한다 (reports/ 의 sibling).
531
- (src_md_path.parent.parent / "user-responses").mkdir(parents=True, exist_ok=True)
532
- ```
533
-
534
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_report_views.py -q` → PASS
535
- - [ ] **Step 5: Commit** — `git add scripts/okstra_ctl/report_views.py tests/test_report_views.py && git commit -m "feat(okstra-ctl/report-views): 인터랙티브 html 렌더 시 user-responses/ 디렉토리 선생성"`
536
-
537
- ---
538
-
539
- ### Task 5: sidecar APPROVAL 파서
540
-
541
- **Files:**
542
- - Modify: `scripts/okstra_ctl/clarification_items.py` (파일 끝에 추가)
543
- - Test: `tests/test_clarification_sidecar_approval.py` (Create)
544
-
545
- - [ ] **Step 1: 실패 테스트** — 새 파일 `tests/test_clarification_sidecar_approval.py`:
546
-
547
- ```python
548
- import sys
549
- from pathlib import Path
550
-
551
- REPO = Path(__file__).resolve().parents[1]
552
- if str(REPO / "scripts") not in sys.path:
553
- sys.path.insert(0, str(REPO / "scripts"))
554
-
555
- from okstra_ctl.clarification_items import parse_user_response_approval # noqa: E402
556
-
557
- _SIDE = (
558
- "---\n"
559
- "task-key: demo/T-1\n"
560
- "task-type: implementation-planning\n"
561
- "seq: 002\n"
562
- "source-report: runs/implementation-planning/reports/final-report-implementation-planning-002.md\n"
563
- "created-by: user\n"
564
- "created-at: 2026-06-12T00:00:00Z\n"
565
- "---\n\n# User Response\n"
566
- )
567
-
568
-
569
- def test_parses_approval_with_option():
570
- rec = parse_user_response_approval(
571
- _SIDE + "\n## APPROVAL\n- Approved: true\n- Implementation-Option: Option B — y\n"
572
- )
573
- assert rec is not None
574
- assert rec.approved is True
575
- assert rec.implementation_option == "Option B — y"
576
- assert rec.seq == "002"
577
- assert rec.source_report.endswith("final-report-implementation-planning-002.md")
578
-
579
-
580
- def test_parses_approval_without_option():
581
- rec = parse_user_response_approval(_SIDE + "\n## APPROVAL\n- Approved: true\n")
582
- assert rec is not None and rec.implementation_option == ""
583
-
584
-
585
- def test_returns_none_without_block_or_when_not_true():
586
- assert parse_user_response_approval(_SIDE + "\n## C-001\n- Kind: decision\n- Value:\n > x\n") is None
587
- assert parse_user_response_approval(_SIDE + "\n## APPROVAL\n- Approved: false\n") is None
588
-
589
-
590
- def test_option_scoped_to_approval_block_only():
591
- text = (
592
- _SIDE
593
- + "\n## APPROVAL\n- Approved: true\n"
594
- + "\n## C-009\n- Kind: decision\n- Value:\n > Implementation-Option: 가짜\n"
595
- )
596
- rec = parse_user_response_approval(text)
597
- assert rec is not None and rec.implementation_option == ""
598
- ```
599
-
600
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_clarification_sidecar_approval.py -q` → ImportError FAIL
601
- - [ ] **Step 3: 구현** — `clarification_items.py` 파일 끝에 추가:
602
-
603
- ```python
604
- # --------------------------------------------------------------------------- #
605
- # User-response sidecar APPROVAL block (HTML Plan Approval 위젯의 Export 산출)
606
- # --------------------------------------------------------------------------- #
607
-
608
- _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
609
- _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
610
-
611
-
612
- @dataclass(frozen=True)
613
- class UserResponseApprovalRecord:
614
- """sidecar 의 ``## APPROVAL`` 블록 + 매칭에 필요한 frontmatter 필드."""
615
- approved: bool
616
- implementation_option: str
617
- source_report: str
618
- seq: str
619
-
620
-
621
- def parse_user_response_approval(
622
- sidecar_text: str,
623
- ) -> Optional[UserResponseApprovalRecord]:
624
- """``## APPROVAL`` 블록이 ``- Approved: true`` 를 가질 때만 record 를
625
- 반환한다. 옵션 라인은 블록 내부에서만 읽는다 (다른 응답 본문의 우연한
626
- 동일 문구를 옵션으로 오인하지 않도록)."""
627
- m = _APPROVAL_HEADING_RE.search(sidecar_text)
628
- if not m:
629
- return None
630
- block = sidecar_text[m.end():]
631
- nxt = _NEXT_RESPONSE_HEADING_RE.search(block)
632
- if nxt:
633
- block = block[: nxt.start()]
634
- if re.search(r"^- Approved:\s*true\s*$", block, re.MULTILINE) is None:
635
- return None
636
- om = re.search(r"^- Implementation-Option:\s*(\S.*?)\s*$", block, re.MULTILINE)
637
- sm = re.search(r"^seq:\s*(\S+)\s*$", sidecar_text, re.MULTILINE)
638
- rm = re.search(r"^source-report:\s*(\S.*?)\s*$", sidecar_text, re.MULTILINE)
639
- return UserResponseApprovalRecord(
640
- approved=True,
641
- implementation_option=om.group(1) if om else "",
642
- source_report=rm.group(1) if rm else "",
643
- seq=sm.group(1) if sm else "",
644
- )
645
- ```
646
-
647
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_clarification_sidecar_approval.py tests/test_okstra_clarification_items.py -q` (후자가 없으면 `tests/ -k clarification`) → PASS
648
- - [ ] **Step 5: Commit** — `git add scripts/okstra_ctl/clarification_items.py tests/test_clarification_sidecar_approval.py && git commit -m "feat(okstra-ctl/clarification-items): user-response sidecar APPROVAL 블록 파서 추가"`
649
-
650
- ---
651
-
652
- ### Task 6: 마법사 — sidecar 감지·확인·적용
653
-
654
- **Files:**
655
- - Modify: `scripts/okstra_ctl/wizard.py`
656
- - Modify: `prompts/wizard/prompts.ko.json` (steps.approve_plan_confirm)
657
- - Test: `tests/test_wizard_approve_plan_confirm.py`
658
-
659
- - [ ] **Step 1: 실패 테스트** — `tests/test_wizard_approve_plan_confirm.py` 끝에 추가 (기존 `_load_wizard`/`_state`/`_GATE_CLEAR_BODY` 재사용):
660
-
661
- ```python
662
- def _planning_run(tmp_path, *, approved: bool, option_in_sidecar: str):
663
- """runs/implementation-planning/{reports,user-responses} 구조 + APPROVAL sidecar."""
664
- reports = tmp_path / "runs" / "implementation-planning" / "reports"
665
- reports.mkdir(parents=True)
666
- plan = reports / "final-report-implementation-planning-002.md"
667
- flag = "true" if approved else "false"
668
- plan.write_text(
669
- f"---\napproved: {flag}\nimplementation-option:\n---\n{_GATE_CLEAR_BODY}",
670
- encoding="utf-8",
671
- )
672
- plan.with_name(plan.name[:-3] + ".data.json").write_text(json.dumps({
673
- "frontmatter": {"approved": approved},
674
- "implementationPlanning": {
675
- "optionCandidates": [{"name": "Option A — x"}, {"name": "Option B — y"}],
676
- "recommendedOption": {"name": "Option A — x"},
677
- },
678
- }), encoding="utf-8")
679
- ur = reports.parent / "user-responses"
680
- ur.mkdir()
681
- option_line = (
682
- f"- Implementation-Option: {option_in_sidecar}\n" if option_in_sidecar else ""
683
- )
684
- (ur / "user-response-implementation-planning-002.md").write_text(
685
- "---\ntask-key: demo/T-1\ntask-type: implementation-planning\nseq: 002\n"
686
- f"source-report: runs/implementation-planning/reports/{plan.name}\n"
687
- "created-by: user\ncreated-at: 2026-06-12T00:00:00Z\n---\n\n"
688
- f"# User Response\n\n## APPROVAL\n- Approved: true\n{option_line}",
689
- encoding="utf-8",
690
- )
691
- return plan
692
-
693
-
694
- def test_plan_selection_detects_html_approval_sidecar(tmp_path):
695
- wizard = _load_wizard()
696
- state = _state(wizard, tmp_path)
697
- plan = _planning_run(tmp_path, approved=True, option_in_sidecar="Option B — y")
698
-
699
- wizard._submit_approved_plan(state, str(plan))
700
-
701
- assert state.approve_plan_candidate == str(plan)
702
- assert state.html_approval_sidecar.endswith("user-response-implementation-planning-002.md")
703
- assert state.html_approval_option == "Option B — y"
704
- prompt = wizard.STEP_BY_ID[wizard.S_APPROVE_PLAN_CONFIRM].build(state)
705
- assert {o.value for o in prompt.options} == {"yes_apply", "yes", "no"}
706
- assert "HTML 승인 기록" in prompt.label
707
-
708
-
709
- def test_confirm_yes_apply_sets_implementation_option(tmp_path):
710
- wizard = _load_wizard()
711
- state = _state(wizard, tmp_path)
712
- plan = _planning_run(tmp_path, approved=True, option_in_sidecar="Option B — y")
713
- wizard._submit_approved_plan(state, str(plan))
714
-
715
- echo = wizard._submit_approve_plan_confirm(state, "yes_apply")
716
-
717
- body = plan.read_text(encoding="utf-8")
718
- assert "implementation-option:" in body and "Option B — y" in body
719
- assert state.approved_plan_path == str(plan)
720
- assert state.html_approval_sidecar == "" and state.html_approval_option == ""
721
- assert "Option B — y" in echo
722
-
723
-
724
- def test_confirm_yes_ignores_sidecar_option(tmp_path):
725
- wizard = _load_wizard()
726
- state = _state(wizard, tmp_path)
727
- plan = _planning_run(tmp_path, approved=True, option_in_sidecar="Option B — y")
728
- wizard._submit_approved_plan(state, str(plan))
729
-
730
- wizard._submit_approve_plan_confirm(state, "yes")
731
-
732
- body = plan.read_text(encoding="utf-8")
733
- assert "Option B — y" not in body # 기록 무시 → frontmatter 무변경
734
- assert state.approved_plan_path == str(plan)
735
-
736
-
737
- def test_yes_apply_rejects_unknown_option(tmp_path):
738
- wizard = _load_wizard()
739
- state = _state(wizard, tmp_path)
740
- plan = _planning_run(tmp_path, approved=True, option_in_sidecar="Option Z — 없음")
741
- wizard._submit_approved_plan(state, str(plan))
742
-
743
- with pytest.raises(wizard.WizardError) as excinfo:
744
- wizard._submit_approve_plan_confirm(state, "yes_apply")
745
- msg = str(excinfo.value)
746
- assert "Option Z — 없음" in msg and "Option A — x" in msg
747
-
748
-
749
- def test_seq_mismatch_sidecar_ignored(tmp_path):
750
- wizard = _load_wizard()
751
- state = _state(wizard, tmp_path)
752
- plan = _planning_run(tmp_path, approved=True, option_in_sidecar="Option B — y")
753
- ur = plan.parent.parent / "user-responses"
754
- old = ur / "user-response-implementation-planning-002.md"
755
- old.rename(ur / "user-response-implementation-planning-001.md")
756
- text = (ur / "user-response-implementation-planning-001.md").read_text(encoding="utf-8")
757
- (ur / "user-response-implementation-planning-001.md").write_text(
758
- text.replace("seq: 002", "seq: 001").replace("-002.md", "-001.md"),
759
- encoding="utf-8",
760
- )
761
-
762
- wizard._submit_approved_plan(state, str(plan))
763
-
764
- assert state.html_approval_sidecar == ""
765
- prompt = wizard.STEP_BY_ID[wizard.S_APPROVE_PLAN_CONFIRM].build(state)
766
- assert {o.value for o in prompt.options} == {"yes", "no"}
767
- ```
768
-
769
- - [ ] **Step 2: 실패 확인** — Run: `python3 -m pytest tests/test_wizard_approve_plan_confirm.py -q` → 신규 5건 FAIL (AttributeError `html_approval_sidecar` 등), 기존 9건 PASS 유지
770
- - [ ] **Step 3: 구현** — `wizard.py`:
771
-
772
- (a) import 블록(:41 부근)의 `from .run import (...)` 에 `_apply_cli_implementation_option,` 추가. `from .clarification_items import` 블록에 `parse_user_response_approval,` 추가 (기존 import 스타일 확인 후 동일하게).
773
-
774
- (b) `WizardState` (:339 부근) 에 필드 추가:
775
-
776
- ```python
777
- # HTML Plan Approval 위젯이 남긴 sidecar 감지 결과. 선택된 plan 과
778
- # source-report·seq 가 일치하는 APPROVAL 블록이 있을 때만 채워지고,
779
- # approve-confirm 단계가 3-옵션(yes_apply/yes/no)으로 확장된다.
780
- html_approval_sidecar: str = ""
781
- html_approval_option: str = ""
782
- ```
783
-
784
- 그리고 wizard.py:3007 부근의 state 초기값 dict 에 `"html_approval_sidecar": "", "html_approval_option": "",` 추가.
785
-
786
- (c) `_stage_plan_for_confirmation` (:556) 의 `state.approve_plan_candidate = str(p)` 다음에:
787
-
788
- ```python
789
- state.html_approval_sidecar = ""
790
- state.html_approval_option = ""
791
- found = _find_html_approval_sidecar(p)
792
- if found is not None:
793
- sidecar_path, record = found
794
- state.html_approval_sidecar = str(sidecar_path)
795
- state.html_approval_option = record.implementation_option
796
- ```
797
-
798
- 같은 파일 `_approve_plan_in_place` 아래에 추가:
799
-
800
- ```python
801
- def _find_html_approval_sidecar(
802
- plan_path: Path,
803
- ) -> Optional[tuple[Path, "UserResponseApprovalRecord"]]:
804
- """plan 의 run 디렉토리 sibling ``user-responses/`` 에서 APPROVAL 블록을
805
- 가진 sidecar 를 찾는다. source-report 파일명과 seq 가 plan 과 일치해야
806
- 하며, 복수면 mtime 최신을 택한다."""
807
- responses_dir = plan_path.parent.parent / "user-responses"
808
- if not responses_dir.is_dir():
809
- return None
810
- m = re.search(r"-(\d+)\.md$", plan_path.name)
811
- plan_seq = m.group(1) if m else ""
812
- best: Optional[tuple[float, Path, "UserResponseApprovalRecord"]] = None
813
- for f in sorted(responses_dir.glob("user-response-*.md")):
814
- try:
815
- text = f.read_text(encoding="utf-8", errors="replace")
816
- except OSError:
817
- continue
818
- rec = parse_user_response_approval(text)
819
- if rec is None or rec.seq != plan_seq:
820
- continue
821
- if Path(rec.source_report).name != plan_path.name:
822
- continue
823
- mtime = f.stat().st_mtime
824
- if best is None or mtime > best[0]:
825
- best = (mtime, f, rec)
826
- return (best[1], best[2]) if best else None
827
-
828
-
829
- def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict) -> None:
830
- """sidecar 의 옵션 이름이 plan data.json 의 optionCandidates 에 있는지
831
- 검증한다. 없으면 유효 후보를 나열하며 거부한다 (fail-closed)."""
832
- data_path = plan_path.with_name(plan_path.name[: -len(".md")] + ".data.json")
833
- candidates: list[str] = []
834
- if data_path.is_file():
835
- try:
836
- data = json.loads(data_path.read_text(encoding="utf-8"))
837
- planning = data.get("implementationPlanning") or {}
838
- candidates = [
839
- c.get("name", "") for c in planning.get("optionCandidates") or []
840
- if isinstance(c, dict) and c.get("name")
841
- ]
842
- except (OSError, json.JSONDecodeError):
843
- candidates = []
844
- if option_name not in candidates:
845
- raise WizardError(
846
- errors_t["unknown_option"].format(
847
- option=option_name,
848
- candidates=", ".join(candidates) if candidates else "(없음)",
849
- )
850
- )
851
- ```
852
-
853
- (`json` / `re` 는 wizard.py 에 이미 import 되어 있는지 확인; 없으면 추가. `UserResponseApprovalRecord` 는 타입 주석용 — import 에 포함.)
854
-
855
- (d) `_build_approve_plan_confirm` (:1605) 교체:
856
-
857
- ```python
858
- def _build_approve_plan_confirm(state: WizardState) -> Prompt:
859
- t = _p(state.workspace_root, "approve_plan_confirm",
860
- path=state.approve_plan_candidate)
861
- label = t["label"]
862
- options_map = t["options"]
863
- if state.html_approval_sidecar:
864
- label += t["html_approval_note"].format(
865
- sidecar=state.html_approval_sidecar,
866
- option=state.html_approval_option or "(추천 옵션 그대로)",
867
- )
868
- options_map = t["options_html_approval"]
869
- return Prompt(
870
- step=S_APPROVE_PLAN_CONFIRM, kind="pick",
871
- label=label,
872
- options=[_opt(k, v) for k, v in options_map.items()],
873
- echo_template=t["echo_template"],
874
- )
875
- ```
876
-
877
- (e) `_submit_approve_plan_confirm` (:1616) 교체 — `yes_apply` 분기 추가:
878
-
879
- ```python
880
- def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
881
- allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
882
- if value not in allowed:
883
- raise WizardError(f"expected one of {allowed}, got: {value!r}")
884
- candidate = state.approve_plan_candidate
885
- if not candidate:
886
- raise WizardError("approve-plan: no candidate plan to approve")
887
- t = _p(state.workspace_root, "approve_plan_confirm", path=candidate)
888
- if value == "no":
889
- # Declining leaves the candidate set so the confirm step re-prompts;
890
- # implementation cannot proceed without choosing to proceed.
891
- raise WizardError(t["errors"]["declined"])
892
- apply_option = value == "yes_apply" and bool(state.html_approval_option)
893
- p = Path(candidate)
894
- if apply_option:
895
- # 승인 플립 전에 옵션 유효성부터 검증한다 — 무효 옵션으로 plan 이
896
- # 절반만(승인만) 적용되는 상태를 만들지 않는다.
897
- _validate_sidecar_option(p, state.html_approval_option, t["errors"])
898
- resolved, fully_approved = _classify_approved_plan(
899
- str(p), Path(state.project_root))
900
- if not fully_approved:
901
- # Not yet approved → flip data.json (SSOT) + re-render, then re-verify.
902
- _approve_plan_in_place(p)
903
- resolved, fully_approved = _classify_approved_plan(
904
- str(p), Path(state.project_root))
905
- if not fully_approved:
906
- raise WizardError(t["errors"]["still_unapproved"].format(path=resolved))
907
- echo = t["echo_variants"]["approved"].format(path=resolved)
908
- if apply_option:
909
- _apply_cli_implementation_option(str(resolved), state.html_approval_option)
910
- echo = t["echo_variants"]["approved_with_option"].format(
911
- path=resolved, option=state.html_approval_option)
912
- state.approved_plan_path = str(resolved)
913
- state.approve_plan_candidate = ""
914
- state.html_approval_sidecar = ""
915
- state.html_approval_option = ""
916
- return echo
917
- ```
918
-
919
- 주의: `_apply_cli_implementation_option` 은 `PrepareError` 를 던진다 — `_approve_plan_in_place` 의 기존 전파 방식과 동일하게 그대로 전파시킨다 (별도 try 불요).
920
-
921
- (f) `prompts/wizard/prompts.ko.json` 의 `steps.approve_plan_confirm` 에 키 추가 (기존 키는 무변경):
922
-
923
- ```json
924
- "html_approval_note": "\nHTML 승인 기록 발견: {sidecar}\n 선택 옵션: {option}\n· 예(기록 적용) — 승인과 함께 위 옵션을 frontmatter 에 기록합니다.",
925
- "options_html_approval": {
926
- "yes_apply": "예 — HTML 기록대로 승인 + 옵션 적용 (추천)",
927
- "yes": "예 — 승인만 (HTML 기록 무시)",
928
- "no": "아니오 — 진행하지 않음"
929
- },
930
- "echo_variants": {
931
- "selected": "(기존 값 유지)",
932
- "approved": "(기존 값 유지)",
933
- "approved_with_option": "approved-plan: {path} (승인 + 옵션 `{option}` 적용됨)"
934
- },
935
- "errors": {
936
- "declined": "(기존 값 유지)",
937
- "still_unapproved": "(기존 값 유지)",
938
- "unknown_option": "HTML 승인 기록의 옵션 `{option}` 이 plan 의 Option Candidates 에 없습니다. 유효 후보: {candidates}. 보고서 HTML 에서 다시 Export 하거나 '예 — 승인만' 을 선택하세요."
939
- }
940
- ```
941
-
942
- ("(기존 값 유지)" 는 실제 JSON 에 그 문구를 쓰라는 뜻이 아니라 **기존 값을 건드리지 말라**는 지시 — `echo_variants` / `errors` 객체에 새 키만 추가한다.)
943
-
944
- - [ ] **Step 4: 통과 확인** — Run: `python3 -m pytest tests/test_wizard_approve_plan_confirm.py tests/test_wizard_prompts.py tests/test_okstra_ctl_wizard.py -q` → PASS (prompts 스키마 테스트가 새 키를 거부하면 해당 테스트의 허용 키 목록에 추가)
945
- - [ ] **Step 5: Commit** — `git add scripts/okstra_ctl/wizard.py prompts/wizard/prompts.ko.json tests/test_wizard_approve_plan_confirm.py && git commit -m "feat(okstra-ctl/wizard): HTML APPROVAL sidecar 감지 — 확인 후 승인+옵션을 기존 경로로 적용"`
946
-
947
- ---
948
-
949
- ### Task 7: 문서·빌드·전체 검증
950
-
951
- **Files:**
952
- - Modify: `templates/reports/user-response.template.md`
953
- - Modify: `skills/okstra-report-writer/SKILL.md` (:108 HTML 뷰 설명)
954
- - Modify: `skills/okstra-run/SKILL.md` (:126 implementation sub-flow)
955
- - Modify: `CHANGES.md`
956
-
957
- - [ ] **Step 1: sidecar 템플릿 문서** — `user-response.template.md` 의 "## 호환성 규칙" 앞에 섹션 추가:
958
-
959
- ```markdown
960
- ## APPROVAL 블록 (implementation-planning 전용)
961
-
962
- Plan Approval 위젯에서 승인을 체크하고 Export 하면 본문 끝에 다음 블록이 추가됩니다:
963
-
964
- ​```markdown
965
- ## APPROVAL
966
- - Approved: true
967
- - Implementation-Option: <Option Candidates 의 name 그대로>
968
- ​```
969
-
970
- - 옵션을 추천(권장) 그대로 두면 `Implementation-Option:` 라인은 생략됩니다 — implementation 은 plan 의 Recommended Option 으로 폴백합니다.
971
- - 이 블록의 소비자는 implementation 시작 마법사의 approve-confirm 단계입니다 (`scripts/okstra_ctl/wizard.py`). 마법사가 사용자 확인 후 기존 `--approve` / `--implementation-option` 경로로 적용하며, sidecar 자체는 승인 게이트 검증을 우회하지 않습니다.
972
- - `--resume-clarification` 은 sidecar 를 verbatim 첨부하므로 APPROVAL 블록도 그대로 실립니다 — planning 재실행 입력에서는 무해한 잔류 정보입니다.
973
- ```
974
-
975
- (코드펜스 안의 ​``` 는 실제 파일에서는 일반 ``` 로.)
976
-
977
- - [ ] **Step 2: 스킬 문서 2곳** — `okstra-report-writer/SKILL.md` :108 의 HTML 뷰 설명 문장에 생성 조건 확장과 위젯을 반영: "**generated only when the report has at least one §5 `C-*` clarification row**" 를 "**generated when the report has at least one §1 `C-*` clarification row OR an implementation-planning Plan Approval widget target (sibling data.json 의 optionCandidates)**" 로 교체하고, 같은 항목에 한 문장 추가: "implementation-planning 보고서에는 Plan Approval 섹션(옵션 select + 승인 체크)이 렌더되며, Export sidecar 의 `## APPROVAL` 블록은 implementation 마법사가 확인 후 적용한다." `okstra-run/SKILL.md` :126 의 implementation sub-flow 항목에 추가: "approved-plan 선택 시 user-responses/ 의 HTML APPROVAL sidecar 를 감지하면 confirm 단계가 3-옵션(yes_apply 추천/yes/no)으로 확장된다."
978
- - [ ] **Step 3: CHANGES.md** — `## 2026-06-12` 섹션에 항목 추가 (기존 항목 위):
979
-
980
- ```markdown
981
- ### feat(okstra-ctl/report-views, wizard): final-report HTML 에서 plan 승인·구현 옵션 선택 지원
982
-
983
- - **배경**: planning 보고서 승인은 frontmatter 수동 편집 또는 `--approve` 플래그, 옵션 선택은 `--implementation-option` 플래그뿐이라 보고서를 검토하는 HTML 화면에서 곧바로 의사를 기록할 수 없었다.
984
- - **해결**: implementation-planning HTML 에 Plan Approval 섹션(옵션 select — 권장 기본값, 승인 체크; §1 Blocks=approval 미해소 시 disabled) 추가. Export sidecar 에 `## APPROVAL` 블록으로 직렬화하고, implementation 마법사 approve-confirm 단계가 감지해 사용자 확인 후 기존 `--approve`/`--implementation-option` 경로로 적용. 인터랙티브 html 렌더 시 `user-responses/` 디렉토리를 선생성. 강제 지점은 report-views/wizard 단위 테스트와 기존 승인 게이트(`_validate_approved_plan`) 무변경 유지.
985
- - 사용자 영향: 다음 release 후 planning 보고서 HTML 에서 승인·옵션을 체크해 Export 하고, implementation 시작 마법사에서 "예 — HTML 기록대로" 한 번으로 승인+옵션이 적용된다.
986
- ```
987
-
988
- - [ ] **Step 4: 빌드 + 전체 테스트** — Run: `npm run build && python3 -m pytest tests/ -q` → 전체 PASS, `bash validators/validate-workflow.sh` 도 PASS
989
- - [ ] **Step 5: 실물 검증** — dev-9186 planning 보고서로 재렌더해 위젯 확인:
990
-
991
- ```bash
992
- python3 scripts/okstra-render-report-views.py \
993
- "/Volumes/Workspaces/workspace/projects/FontsNinja/app/fontradar-v2-api/.okstra/tasks/calcule-des-prix-1-1/dev-9186/runs/implementation-planning/reports/final-report-implementation-planning-002.md"
994
- grep -c 'id="plan-approval"' .../final-report-implementation-planning-002.html # expect 1
995
- ls .../runs/implementation-planning/user-responses/ # 디렉토리 존재
996
- ```
997
-
998
- (해당 보고서는 §1 에 open Blocks=approval 행이 있으므로 위젯이 **disabled + 사유 표시** 로 렌더되는 것까지 확인.)
999
-
1000
- - [ ] **Step 6: Commit** — `git add templates/reports/user-response.template.md skills/okstra-report-writer/SKILL.md skills/okstra-run/SKILL.md CHANGES.md && git commit -m "docs(reports,skills): HTML Plan Approval 흐름 문서화 + CHANGES 기록"`