okstra 0.163.2 → 0.165.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -67,6 +67,21 @@
67
67
  "timeout": "시간 초과",
68
68
  "not-run": "미실행",
69
69
  "synthesis-only": "종합만 수행"
70
+ },
71
+ "replanMode": {
72
+ "full": "전부 다시 계획함",
73
+ "incremental": "일부만 다시 계획함"
74
+ },
75
+ "ledgerKind": {
76
+ "code-evidence": "코드 근거",
77
+ "hypothesis": "가설",
78
+ "confirmed-fact": "확인된 사실",
79
+ "inference": "추론",
80
+ "unknown": "미확인",
81
+ "cross-check-consensus": "작업자 간 합의",
82
+ "cross-check-dissent": "작업자 간 이견",
83
+ "missing-information": "빠진 정보",
84
+ "follow-up": "후속 작업"
70
85
  }
71
86
  },
72
87
  "enumHint": {
@@ -85,7 +100,8 @@
85
100
  "written": "작성",
86
101
  "elapsed": "소요 시간",
87
102
  "evidence-ledger": "근거 대장",
88
- "source-confidence": "출처 · 확신도",
103
+ "source": "출처",
104
+ "confidence": "확신도",
89
105
  "export-my-answers": "내 답변 내보내기",
90
106
  "copy": "복사",
91
107
  "dismiss": "닫기",
@@ -95,7 +111,7 @@
95
111
  },
96
112
  "runUsage": {
97
113
  "heading": "이번 실행에 든 비용",
98
- "intro": "이번 실행이 투입한 에이전트별로 얼마를 썼고 얼마나 오래 일했는지입니다. 에이전트는 서로 겹쳐서 돌기 때문에 각 작업 시간을 더해도 머리말의 소요 시간과 같지 않습니다. 원시 토큰은 처리한 분량이고, 과금 환산 토큰은 같은 작업을 입력 단가 기준으로 환산한 값으로 금액은 이 값에서 나옵니다.",
114
+ "intro": "이번 실행이 투입한 에이전트별로 얼마를 썼고 얼마나 오래 일했는지입니다. 에이전트는 서로 겹쳐서 돌기 때문에 각 작업 시간을 더해도 머리말의 소요 시간과 같지 않습니다. 원시 토큰은 이번 실행이 모델에 한 번씩 통과시킨 분량입니다. 캐시 재읽기는 매 턴 다시 읽어 들이는 앞선 대화 전체로, 입력 단가의 10분의 1로 매겨지기 때문에 원시 토큰 밖에 있으면서 금액 안에는 들어옵니다. 과금 환산 토큰은 이 둘을 모두 입력 단가 기준으로 환산한 값이고, 금액은 거기서 나옵니다.",
99
115
  "agent": "에이전트",
100
116
  "role": "역할",
101
117
  "model": "모델",
@@ -109,7 +125,9 @@
109
125
  "row-lead": "리드",
110
126
  "row-workers": "워커 합계",
111
127
  "row-total": "합계",
112
- "row-cli": "CLI 호출 (별도 청구)"
128
+ "row-cli": "CLI 호출 (별도 청구)",
129
+ "cache-read-tokens": "캐시 재읽기",
130
+ "row-task-cumulative": "이 태스크 누적 (run {runs}회)"
113
131
  },
114
132
  "macros": {
115
133
  "forms": {
@@ -122,7 +140,6 @@
122
140
  "why": "이유",
123
141
  "further-evidence": "추가 근거",
124
142
  "scope-change-requested": "범위 변경 요청",
125
- "approve-this-plan": "이 계획 승인하기",
126
143
  "implementation-option": "구현 방식 선택",
127
144
  "i-approve-this-plan": "이 계획을 승인합니다.",
128
145
  "approval-is-held": "승인이 보류 중입니다:",
@@ -136,7 +153,12 @@
136
153
  "recommended": "권장",
137
154
  "scope-impact": "범위",
138
155
  "added-work": "추가 작업",
139
- "direction-change": "방향 전환"
156
+ "direction-change": "방향 전환",
157
+ "decide-on-this-plan": "이 계획에 대한 결정",
158
+ "request-changes-to-this-plan": "고쳐서 다시 가져오게 합니다",
159
+ "reject-this-plan": "이 계획을 반려합니다",
160
+ "why-required-for-changes-or-rejection": "사유 — 반려하거나 다시 고치게 할 때는 반드시 적어야 합니다",
161
+ "answer-the-blockers-then-regenerate": "{ids}에 답한 뒤 답변을 내보내고, clarification resume 명령으로 리포트를 다시 만드세요."
140
162
  },
141
163
  "visualizations": {
142
164
  "component": "구성 요소",
@@ -285,7 +307,8 @@
285
307
  "statement-overturned": "뒤집힌 진술",
286
308
  "replaced-with": "대체된 내용",
287
309
  "no-answer-overturned-anything": "답변으로 뒤집힌 것이 없습니다.",
288
- "scope-restated": "다시 정리한 범위",
310
+ "scope-restated": "계획을 어디까지 다시 썼는지",
311
+ "scope-restated-lede": "이번 계획은 앞선 계획을 이어받아 시작했습니다. 답변을 반영한 뒤 그중 얼마가 살아남았는지입니다.",
289
312
  "stages-verified-again": "다시 검증한 stage",
290
313
  "stages-carried-over-as-they-were": "그대로 이어받은 stage",
291
314
  "decision-record-draft": "결정 기록 초안",
@@ -297,7 +320,9 @@
297
320
  "topic": "주제",
298
321
  "why": "이유",
299
322
  "open-decisions-before-approval": "승인 전에 남은 결정",
300
- "no-open-decision-is-holding-approval": "승인을 막는 미결 결정이 없습니다."
323
+ "no-open-decision-is-holding-approval": "승인을 막는 미결 결정이 없습니다.",
324
+ "uniform-verifier-label": "단일 판정 검증자",
325
+ "uniform-verifier-legend": "이 검증자는 자기가 투표한 모든 항목에 같은 판정을 냈습니다. 그래서 이번 라운드의 반증 신호는 나머지 검증자에게서만 나왔고, 위의 게이트 값은 실제보다 넓은 교차 검증처럼 읽힙니다. 만장일치도 정당한 결과이므로, 그 워커의 audit 사이드카에서 인용한 근거를 실제로 열어봤는지 확인하십시오."
301
326
  },
302
327
  "implementation": {
303
328
  "what-was-delivered": "무엇을 만들었나",
@@ -18,15 +18,21 @@
18
18
 
19
19
  {% macro plan_approval(state) -%}
20
20
  <section id="plan-approval" data-report-section="plan-approval">
21
- <h2>{{ t('macros.forms.approve-this-plan') }}</h2>
21
+ <h2>{{ t('macros.forms.decide-on-this-plan') }}</h2>
22
+ <fieldset id="plan-decision"><legend>{{ t('macros.forms.verdict') }}</legend>
23
+ <input id="plan-decision-approved" type="radio" name="plan-decision-status" value="approved"{% if state.disabled_reason %} disabled{% endif %}><label for="plan-decision-approved">{{ t('macros.forms.i-approve-this-plan') }}</label>
24
+ <input id="plan-decision-revision-requested" type="radio" name="plan-decision-status" value="revision-requested"><label for="plan-decision-revision-requested">{{ t('macros.forms.request-changes-to-this-plan') }}</label>
25
+ <input id="plan-decision-rejected" type="radio" name="plan-decision-status" value="rejected"><label for="plan-decision-rejected">{{ t('macros.forms.reject-this-plan') }}</label>
26
+ </fieldset>
22
27
  <label for="approval-option">{{ t('macros.forms.implementation-option') }}</label>
23
28
  <select id="approval-option"{% if state.disabled_reason %} disabled{% endif %}>
24
29
  {% for name in state.option_names %}
25
30
  {% if name == state.recommended_option %}<option data-recommended="true" selected value="{{ name }}">{{ name | inline_code }} (recommended)</option>{% else %}<option value="{{ name }}">{{ name | inline_code }}</option>{% endif %}
26
31
  {% endfor %}
27
32
  </select>
28
- <input type="checkbox" id="approval-checkbox"{% if state.disabled_reason %} disabled{% endif %}><label for="approval-checkbox">{{ t('macros.forms.i-approve-this-plan') }}</label>
29
- {% if state.disabled_reason %}<div class="approval-disabled-reason"><p>{{ t('macros.forms.approval-is-held') }} <strong>{{ state.disabled_reason }}</strong>.</p><p>Answer {{ state.blocker_ids | join(', ') }}, export your answers, then regenerate the report with the clarification resume command.</p></div>{% endif %}
33
+ <label for="plan-decision-reason">{{ t('macros.forms.why-required-for-changes-or-rejection') }}</label>
34
+ <textarea id="plan-decision-reason" rows="4"></textarea>
35
+ {% if state.disabled_reason %}<div class="approval-disabled-reason"><p>{{ t('macros.forms.approval-is-held') }} <strong>{{ state.disabled_reason }}</strong>.</p><p>{{ t('macros.forms.answer-the-blockers-then-regenerate') | replace('{ids}', state.blocker_ids | join(', ')) }}</p></div>{% endif %}
30
36
  </section>
31
37
  {%- endmacro %}
32
38
 
@@ -55,24 +55,16 @@
55
55
  </section>
56
56
  {% endif %}
57
57
 
58
- <section data-report-section="validation-and-rollback">
59
- <h2>{{ t('tasks.implementation-planning.verification-and-rollback') }}</h2>
60
- {{ render_narrative(narrative.validationAndRollback, "implementationPlanning.userNarrative.validationAndRollback") }}
61
- <h3>{{ t('tasks.implementation-planning.validation-checklist') }}</h3><table data-report-field="implementationPlanning.validationChecklist"><thead><tr><th>{{ t('tasks.implementation-planning.check') }}</th><th>{{ t('tasks.implementation-planning.check') }}</th><th>{{ t('tasks.implementation-planning.command') }}</th><th>{{ t('tasks.implementation-planning.expected') }}</th></tr></thead><tbody>{% for row in planning.validationChecklist %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Phase", row.phase)]) }}<td>{{ row.check | inline_code }}</td><td><code>{{ row.commandOrObservation }}</code></td><td>{{ row.expectedOutcome | inline_code }}</td></tr>{% endfor %}</tbody></table>
62
- <h3>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h3><table data-report-field="implementationPlanning.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation-planning.requirement') }}</th><th>{{ t('tasks.implementation-planning.body') }}</th><th>{{ t('tasks.implementation-planning.covered-by') }}</th></tr></thead><tbody>{% for row in planning.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Source", row.source)], status_name="Status", status_raw=row.status, status_text=row.status) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
63
- <h3>{{ t('tasks.implementation-planning.cross-project-dependencies') }}</h3><table data-report-field="implementationPlanning.crossProjectDependencies"><thead><tr><th>{{ t('tasks.implementation-planning.dependency') }}</th><th>{{ t('tasks.implementation-planning.work-needed') }}</th><th>{{ t('tasks.implementation-planning.signal-to-check') }}</th><th>{{ t('tasks.implementation-planning.how-to-start') }}</th></tr></thead><tbody>{% for row in planning.crossProjectDependencies %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Project", row.project), ("Direction", row.direction)]) }}<td>{{ row.requiredWork | inline_code }}</td><td>{{ row.verificationSignal | inline_code }}</td><td>{{ row.howToStart | inline_code }}</td></tr>{% endfor %}</tbody></table>
64
- <h3>{{ t('tasks.implementation-planning.dependency-and-migration-risk') }}</h3><table data-report-field="implementationPlanning.dependencyMigrationRisk"><thead><tr><th>{{ t('tasks.implementation-planning.item') }}</th><th>{{ t('tasks.implementation-planning.impact') }}</th><th>{{ t('tasks.implementation-planning.mitigation') }}</th></tr></thead><tbody>{% for row in planning.dependencyMigrationRisk %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Item", row.item), ("Kind", row.kind)]) }}<td>{{ row.impact | inline_code }}</td><td>{{ row.mitigation | inline_code }}</td></tr>{% endfor %}</tbody></table>
65
- <h3>{{ t('tasks.implementation-planning.rollback-strategy') }}</h3><div class="summary-grid" data-report-field="implementationPlanning.rollbackStrategy">{% for row in planning.rollbackStrategy %}{{ summary_card(row.id ~ " · " ~ (row.triggerSignal | inline_code), row.action, "important", anchor=row.id) }}{% endfor %}</div>
66
- </section>
67
-
68
- <section data-report-section="plan-verification" data-report-field="implementationPlanning.planBodyVerification">
69
- <h2>{{ t('tasks.implementation-planning.plan-body-verification') }}</h2>
70
- <p><strong>{{ t('tasks.implementation-planning.verdict') }}</strong> — {{ planning.planBodyVerification.gateResult | inline_code }} ({{ planning.planBodyVerification.roundCount }} rounds{% if planning.planBodyVerification.get("selfFixRoundsApplied") is not none %}, {{ planning.planBodyVerification.selfFixRoundsApplied }} self-fix rounds{% if planning.planBodyVerification.get("selfFixStopReason") %} · {{ planning.planBodyVerification.selfFixStopReason | inline_code }}{% endif %}{% endif %})</p>
71
- {% if planning.planBodyVerification.get("gateBlockedBy") %}<p><strong>{{ t('tasks.implementation-planning.what-it-blocked') }}</strong> — {{ planning.planBodyVerification.gateBlockedBy | join(", ") | inline_code }}</p>{% endif %}
72
- {% if planning.planBodyVerification.get("uniformVerifiers") %}<p><strong>{{ t('implementationPlanning.planBodyUniformVerifierLabel') }}</strong> — {% for u in planning.planBodyVerification.uniformVerifiers %}{{ u.worker | inline_code }} → {{ u.verdict | inline_code }} ({{ u.itemCount }}){% if not loop.last %}, {% endif %}{% endfor %}<br><small>{{ t('implementationPlanning.planBodyUniformVerifierLegend') }}</small></p>{% endif %}
73
- <table><thead><tr><th>{{ t('tasks.implementation-planning.plan-item') }}</th><th>{{ t('tasks.implementation-planning.subject') }}</th></tr></thead><tbody>{% for row in planning.planBodyVerification.planItems %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Source section", row.sourceSection)]) }}<td>{{ row.subject | inline_code }}</td></tr>{% endfor %}</tbody></table>
74
- {% if planning.planBodyVerification.get("dissentLog") %}<h3>{{ t('tasks.implementation-planning.dissent-left-standing') }}</h3>
75
- <table><thead><tr><th>{{ t('tasks.implementation-planning.subject') }}</th><th>{{ t('tasks.implementation-planning.body') }}</th></tr></thead><tbody>{% for row in planning.planBodyVerification.dissentLog %}<tr>{{ row_key(pairs=[("Subject", row.planItem), ("Worker", row.workerRole)]) }}<td>{{ row.body | inline_code }}</td></tr>{% endfor %}</tbody></table>{% endif %}
58
+ {# The verification checklist, cross-project dependencies, migration risk,
59
+ rollback strategy and plan-body verification rounds are the implementer's
60
+ and the auditor's working material, not the approver's: this reader is
61
+ deciding whether to greenlight the plan, and none of those tables move that
62
+ decision. They stay in the markdown report, which is what the next phase and
63
+ the audit trail read. Requirement coverage is the exception — "does this plan
64
+ actually do what the brief asked" is the approval question itself. #}
65
+ <section data-report-section="requirement-coverage" data-report-field="implementationPlanning.requirementCoverage">
66
+ <h2>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h2>
67
+ <table><thead><tr><th>{{ t('tasks.implementation-planning.requirement') }}</th><th>{{ t('tasks.implementation-planning.body') }}</th><th>{{ t('tasks.implementation-planning.covered-by') }}</th></tr></thead><tbody>{% for row in planning.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[("ID", row.id), ("Source", row.source)], status_name="Status", status_raw=row.status, status_text=row.status) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
76
68
  </section>
77
69
 
78
70
  {% if planning.get("supersessionLedger") is not none %}
@@ -84,8 +76,11 @@
84
76
 
85
77
  {% if planning.get("incrementalDecision") %}<section data-report-section="replan-scope" data-report-field="implementationPlanning.incrementalDecision">
86
78
  <h2>{{ t('tasks.implementation-planning.scope-restated') }}</h2>
87
- <p><strong>{{ planning.incrementalDecision.mode | inline_code }}</strong> — {{ planning.incrementalDecision.reason | inline_code }}</p>
79
+ <p class="replan-lede">{{ t('tasks.implementation-planning.scope-restated-lede') }}</p>
80
+ <p><strong>{{ planning.incrementalDecision.mode | enum_label('replanMode') }}</strong> — {{ planning.incrementalDecision.reason | inline_code }}</p>
81
+ {% if planning.incrementalDecision.reverifyStages or planning.incrementalDecision.carryStages %}
88
82
  <p><strong>{{ t('tasks.implementation-planning.stages-verified-again') }}</strong> — {{ planning.incrementalDecision.reverifyStages | default([]) | join(", ") | inline_code }} · <strong>{{ t('tasks.implementation-planning.stages-carried-over-as-they-were') }}</strong> — {{ planning.incrementalDecision.carryStages | default([]) | join(", ") | inline_code }}</p>
83
+ {% endif %}
89
84
  </section>{% endif %}
90
85
 
91
86
  <section data-report-section="decision-drafts" data-report-field="implementationPlanning.decisionDrafts">
@@ -8,7 +8,8 @@
8
8
  * data-response-id> (everything else / fallback).
9
9
  * 2. Serialise the entries into markdown whose bytes are IDENTICAL
10
10
  * to scripts/okstra_ctl/report_views.py serialize_user_response.
11
- * 3. Collect the analysis-only Accept / Request revision / Reject control.
11
+ * 3. Collect the plan Approve / Request changes / Reject decision and the
12
+ * analysis-only Accept / Request revision / Reject control.
12
13
  * 4. Write the result to <pre id="user-response-output">, offer a
13
14
  * [Copy] button, and download it as
14
15
  * user-response-<task-type>-<seq>.md — the user drops that file in
@@ -95,21 +96,43 @@
95
96
  return entries;
96
97
  }
97
98
 
98
- // Plan Approval 위젯 (implementation-planning 보고서에만 존재). 체크박스가
99
- // disabled(§1 차단 미해소)이거나 미체크면 null. 옵션이 추천(data-recommended)
100
- // 그대로면 implementationOption 을 비워 보낸다 → 소비 측 Recommended 폴백.
101
- function collectApproval() {
102
- var box = document.getElementById("approval-checkbox");
103
- if (!box || box.disabled || !box.checked) return null;
104
- var sel = document.getElementById("approval-option");
99
+ function validatePlanDecision(decision) {
100
+ if (!decision) return null;
101
+ var allowed = ["approved", "revision-requested", "rejected"];
102
+ if (allowed.indexOf(decision.status) < 0) {
103
+ throw new Error("PLAN DECISION Status is invalid");
104
+ }
105
+ if (decision.status !== "approved" && !trimMultiline(decision.reason)) {
106
+ throw new Error("PLAN DECISION " + decision.status + " requires a Reason");
107
+ }
108
+ return decision;
109
+ }
110
+
111
+ // 계획 결정 위젯 (implementation-planning 보고서에만 존재). 아무 판정도
112
+ // 고르지 않았으면 null. 승인 라디오는 §1 차단이 남아 있으면 disabled 다.
113
+ // 구현 옵션은 승인일 때만 의미가 있고, 추천(data-recommended) 그대로면
114
+ // implementationOption 을 비워 보낸다 → 소비 측 Recommended 폴백.
115
+ function collectPlanDecision() {
116
+ var selected = document.querySelector(
117
+ 'input[name="plan-decision-status"]:checked'
118
+ );
119
+ if (!selected || selected.disabled) return null;
105
120
  var option = "";
106
- if (sel && sel.selectedIndex >= 0) {
107
- var opt = sel.options[sel.selectedIndex];
108
- if (opt && opt.getAttribute("data-recommended") !== "true") {
109
- option = opt.value;
121
+ if (selected.value === "approved") {
122
+ var sel = document.getElementById("approval-option");
123
+ if (sel && sel.selectedIndex >= 0) {
124
+ var opt = sel.options[sel.selectedIndex];
125
+ if (opt && opt.getAttribute("data-recommended") !== "true") {
126
+ option = opt.value;
127
+ }
110
128
  }
111
129
  }
112
- return { approved: true, implementationOption: option };
130
+ var reason = document.getElementById("plan-decision-reason");
131
+ return validatePlanDecision({
132
+ status: selected.value,
133
+ implementationOption: option,
134
+ reason: reason ? trimMultiline(reason.value) : "",
135
+ });
113
136
  }
114
137
 
115
138
  function validateAnalysisReview(review) {
@@ -192,7 +215,20 @@
192
215
  );
193
216
  }
194
217
 
195
- function buildUserResponseMarkdown(runMeta, entries, createdAt, approval, analysisReview) {
218
+ function serialisePlanDecision(decision) {
219
+ decision = validatePlanDecision(decision);
220
+ var chunk = "\n## PLAN DECISION\n- Status: " + decision.status + "\n";
221
+ if (decision.implementationOption) {
222
+ chunk +=
223
+ "- Implementation-Option: " + trimMultiline(decision.implementationOption) + "\n";
224
+ }
225
+ if (trimMultiline(decision.reason)) {
226
+ chunk += quotedReviewField("Reason", decision.reason);
227
+ }
228
+ return chunk;
229
+ }
230
+
231
+ function buildUserResponseMarkdown(runMeta, entries, createdAt, planDecision, analysisReview) {
196
232
  var head =
197
233
  "---\n" +
198
234
  "task-key: " + (runMeta["task-key"] || "") + "\n" +
@@ -206,7 +242,7 @@
206
242
  "# User Response\n";
207
243
 
208
244
  entries = entries || [];
209
- var hasApproval = !!(approval && approval.approved);
245
+ var hasPlanDecision = !!planDecision;
210
246
  var hasAnalysisReview = !!analysisReview;
211
247
  var chunks = "";
212
248
  for (var i = 0; i < entries.length; i++) {
@@ -224,15 +260,11 @@
224
260
  }
225
261
  chunks += chunk;
226
262
  }
227
- if (entries.length === 0 && !hasApproval && !hasAnalysisReview) {
263
+ if (entries.length === 0 && !hasPlanDecision && !hasAnalysisReview) {
228
264
  chunks += "\n_(No user responses recorded.)_\n";
229
265
  }
230
- if (hasApproval) {
231
- chunks += "\n## APPROVAL\n- Approved: true\n";
232
- if (approval.implementationOption) {
233
- chunks +=
234
- "- Implementation-Option: " + trimMultiline(approval.implementationOption) + "\n";
235
- }
266
+ if (hasPlanDecision) {
267
+ chunks += serialisePlanDecision(planDecision);
236
268
  }
237
269
  if (hasAnalysisReview) {
238
270
  chunks += serialiseAnalysisReview(analysisReview);
@@ -268,10 +300,11 @@
268
300
  function exportUserResponse() {
269
301
  var runMeta = readRunMeta();
270
302
  var entries = collectEntries();
271
- var approval = collectApproval();
272
303
  var out = document.getElementById("user-response-output");
304
+ var planDecision;
273
305
  var analysisReview;
274
306
  try {
307
+ planDecision = collectPlanDecision();
275
308
  analysisReview = collectAnalysisReview();
276
309
  } catch (e) {
277
310
  showOutput(out, e.message || String(e));
@@ -279,13 +312,13 @@
279
312
  return "";
280
313
  }
281
314
  var md = buildUserResponseMarkdown(
282
- runMeta, entries, isoNowUtc(), approval, analysisReview
315
+ runMeta, entries, isoNowUtc(), planDecision, analysisReview
283
316
  );
284
317
  showOutput(out, md);
285
318
  showDismiss(true);
286
319
  // Nothing answered yet — show the empty serialisation as feedback but
287
320
  // don't download a useless file.
288
- if (entries.length > 0 || approval || analysisReview) {
321
+ if (entries.length > 0 || planDecision || analysisReview) {
289
322
  downloadUserResponse(md, runMeta);
290
323
  }
291
324
  return md;
@@ -415,7 +448,7 @@
415
448
  window.okstraReportView = {
416
449
  buildUserResponseMarkdown: buildUserResponseMarkdown,
417
450
  collectEntries: collectEntries,
418
- collectApproval: collectApproval,
451
+ collectPlanDecision: collectPlanDecision,
419
452
  collectAnalysisReview: collectAnalysisReview,
420
453
  exportUserResponse: exportUserResponse,
421
454
  setReaderMode: setReaderMode,
@@ -63,20 +63,24 @@ created-at: 2026-05-17T10:00:00Z
63
63
  > (prediction=0: 1,204) (prediction=1: 38)
64
64
  ```
65
65
 
66
- ## APPROVAL block (implementation-planning only)
66
+ ## PLAN DECISION block (implementation-planning only)
67
67
 
68
- When you check approval in the Plan Approval widget and Export, the following block is appended to the end of the body:
68
+ When you pick a verdict in the Plan Decision widget and Export, the following block is appended to the end of the body:
69
69
 
70
70
  ```markdown
71
- ## APPROVAL
72
- - Approved: true
71
+ ## PLAN DECISION
72
+ - Status: <approved | revision-requested | rejected>
73
73
  - Implementation-Option: <the name exactly as in Option Candidates>
74
+ - Reason:
75
+ > <one quoted line per input line>
74
76
  ```
75
77
 
76
- - If you leave the option at the recommended default, the `Implementation-Option:` line is omitted — implementation falls back to the plan's Recommended Option.
77
- - The consumer of this block is the approve-confirm step of the implementation start wizard (`scripts/okstra_ctl/wizard.py`). After user confirmation the wizard applies it through the existing `--approve` / `--implementation-option` path; the sidecar itself does not bypass approval-gate validation.
78
- - The parser is `parse_user_response_approval` in `scripts/okstra_ctl/user_response.py`, and it accepts only the lowercase `Approved: true` that is byte-identical to the producer output (hand-edited values such as `TRUE` are rejected fail-closed).
79
- - `--resume-clarification` attaches the sidecar verbatim, so the APPROVAL block is carried along as-is — it is harmless residual information in a planning re-run input.
78
+ - `revision-requested` and `rejected` require a reason: a plan sent back without one leaves the next run guessing at what to change, so Export refuses to serialise it.
79
+ - `Implementation-Option:` is emitted only for `approved`, and only when you moved off the recommended default — otherwise implementation falls back to the plan's Recommended Option.
80
+ - `Reason:` is optional for `approved`, and the line is omitted when empty.
81
+ - The consumer of an `approved` block is the approve-confirm step of the implementation start wizard (`scripts/okstra_ctl/wizard.py`). After user confirmation the wizard applies it through the existing `--approve` / `--implementation-option` path; the sidecar itself does not bypass approval-gate validation. A non-approved status is ignored there — that step only ever looks for an approval.
82
+ - The parser is `parse_plan_decision` in `scripts/okstra_ctl/user_response.py`, and it accepts only the lowercase statuses that are byte-identical to the producer output (hand-edited values such as `Approved` are rejected fail-closed).
83
+ - `--resume-clarification` attaches the sidecar verbatim, so the PLAN DECISION block reaches the next planning run — which is how a rejection and its reason get acted on.
80
84
 
81
85
  ## ANALYSIS REVIEW block (analysis reports only)
82
86
 
@@ -29,7 +29,7 @@ When `**Evidence ledger:** required-v1` is present, append one canonical row to
29
29
 
30
30
  - Evidence read: `<project-relative path without a line suffix>`
31
31
 
32
- Every file citation in the result MUST use backticks and a line suffix, for example `src/config/env.ts:1-22`. A cited path without a matching ledger row fails Phase 7 in `validators/validate-run.py` `validate_worker_results_audit()`. Do not add a row for a file you did not open.
32
+ Every file citation in the result MUST use backticks, a line suffix, and the same project-relative path its ledger row carries — for example `src/config/env.ts:1-22`. A bare filename (`env.ts:1-22`) does not match its row and fails exactly like a file you never opened, however many times you cited the full path earlier. A cited path without a matching ledger row fails Phase 7 in `validators/validate-run.py` `validate_worker_results_audit()`. Do not add a row for a file you did not open.
33
33
 
34
34
  ## Anchor headers (lead-injected, BLOCKING)
35
35
 
@@ -235,8 +235,14 @@ def _check_slice_tdd(text: str, stages: List[StageMapStage]) -> List[ValidationE
235
235
 
236
236
 
237
237
  def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationError]:
238
- """S10c: RED-first + later GREEN, with each step's `expected` cell matching
239
- its prefix (RED → FAIL, GREEN → PASS). Only certain violations fire."""
238
+ """S10c: RED-first + later GREEN, with each step's `outcome` cell matching
239
+ its prefix (RED → FAIL, GREEN → PASS).
240
+
241
+ `outcome` is its own column, holding one of two words; the sentence about
242
+ what that looks like lives in `expected` beside it. Written as one cell the
243
+ author had to bury a literal token inside prose, and the rule fired on
244
+ fourteen rows of one measured plan whose prose was correct throughout.
245
+ """
240
246
  rows = _effective_step_rows(section)
241
247
  steps = [(r[1], r[4]) for r in rows if len(r) > 4]
242
248
  actions = [a for a, _ in steps]
@@ -248,16 +254,14 @@ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationEr
248
254
  "S10c: first step action must start with 'RED:' and some "
249
255
  "step action with 'GREEN:', or add a 'TDD exemption:' line"))
250
256
  return errs
251
- for action, expected in steps:
252
- exp = expected.upper()
253
- if action.startswith("RED:") and "FAIL" not in exp:
257
+ for action, outcome in steps:
258
+ cell = outcome.strip().upper()
259
+ if action.startswith("RED:") and cell != "FAIL":
254
260
  errs.append(ValidationError("S10", stage_number,
255
- "S10c: 'RED:' step's expected cell must read FAIL, got "
256
- f"'{expected}'"))
257
- elif action.startswith("GREEN:") and "PASS" not in exp:
261
+ f"S10c: 'RED:' step's outcome cell must be FAIL, got '{outcome}'"))
262
+ elif action.startswith("GREEN:") and cell != "PASS":
258
263
  errs.append(ValidationError("S10", stage_number,
259
- "S10c: 'GREEN:' step's expected cell must read PASS, got "
260
- f"'{expected}'"))
264
+ f"S10c: 'GREEN:' step's outcome cell must be PASS, got '{outcome}'"))
261
265
  return errs
262
266
 
263
267
 
@@ -432,18 +436,9 @@ def _check_data_slice_tdd(stage: dict) -> List[ValidationError]:
432
436
  return [ValidationError("S10", number,
433
437
  "S10c: first stepwiseExecution action must start with 'RED:' and "
434
438
  "some action with 'GREEN:', or declare a 'tddExemption'")]
435
-
436
- errs: List[ValidationError] = []
437
- for step in steps:
438
- action = str(step.get("action") or "")
439
- expected = str(step.get("expected") or "")
440
- if action.startswith("RED:") and "FAIL" not in expected.upper():
441
- errs.append(ValidationError("S10", number,
442
- f"S10c: 'RED:' step's expected must read FAIL, got '{expected}'"))
443
- elif action.startswith("GREEN:") and "PASS" not in expected.upper():
444
- errs.append(ValidationError("S10", number,
445
- f"S10c: 'GREEN:' step's expected must read PASS, got '{expected}'"))
446
- return errs
439
+ # Whether each row's own `outcome` agrees with its RED/GREEN prefix is a
440
+ # schema conditional now (`StageStepRow.allOf`), not a string search here.
441
+ return []
447
442
 
448
443
 
449
444
  def _check_data_step_counts(
@@ -23,7 +23,6 @@ per line to stderr.
23
23
  from __future__ import annotations
24
24
 
25
25
  import argparse
26
- import hashlib
27
26
  import json
28
27
  import re
29
28
  import sys
@@ -40,9 +39,7 @@ from okstra_ctl.clarification_items import ( # noqa: E402
40
39
  )
41
40
  from okstra_ctl.report_views import ( # noqa: E402
42
41
  analysis_review_context,
43
- extract_html_digest,
44
42
  plan_approval_context,
45
- source_digest,
46
43
  )
47
44
  from okstra_ctl.report_view_artifacts import html_view_path # noqa: E402
48
45
  from okstra_ctl.final_report_paths import final_report_data_path # noqa: E402
@@ -76,15 +73,6 @@ def _main_body(html_text: str) -> str:
76
73
  return match.group("body")
77
74
 
78
75
 
79
- def _sha256(path: Path) -> str:
80
- return hashlib.sha256(path.read_bytes()).hexdigest()
81
-
82
-
83
- def _digest_from_run_meta(html_text: str, key: str) -> str:
84
- match = re.search(rf'"{re.escape(key)}"\s*:\s*"([0-9a-f]{{64}})"', html_text)
85
- return match.group(1) if match else ""
86
-
87
-
88
76
  def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
89
77
  data_path = final_report_data_path(report_path)
90
78
  if not data_path.is_file():
@@ -106,17 +94,6 @@ def _validate_v2(report_path: Path, data_path: Path, data: dict) -> list[str]:
106
94
  main_body = _main_body(html_text)
107
95
  failures: list[str] = []
108
96
 
109
- for key, path in (
110
- ("source-data-sha256", data_path),
111
- ("source-md-sha256", report_path),
112
- ):
113
- recorded = _digest_from_run_meta(html_text, key)
114
- expected = _sha256(path)
115
- if not recorded:
116
- failures.append(f"html missing {key} in run-meta")
117
- elif recorded != expected:
118
- failures.append(f"stale html: {key} does not match current source")
119
-
120
97
  task_type = data.get("header", {}).get("taskType", "")
121
98
  if f'data-task-template="{task_type}"' not in html_text:
122
99
  failures.append(f"v2 html task template mismatch for {task_type}")
@@ -236,22 +213,6 @@ def validate(report_path: Path) -> list[str]:
236
213
  html_text = html_path.read_text(encoding="utf-8")
237
214
  html_body = _main_body(html_text)
238
215
 
239
- # (2) source-digest staleness — html's run-meta source-sha256 must
240
- # match the current MD's digest. Mismatch means the html was
241
- # rendered against an older MD body and Phase 7 step 1.5 was not
242
- # re-run.
243
- expected_md_digest = source_digest(md)
244
- html_digest = extract_html_digest(html_text)
245
- if html_digest is None:
246
- failures.append(
247
- "html missing source-sha256 in run-meta — re-render with okstra render-views"
248
- )
249
- elif html_digest != expected_md_digest:
250
- failures.append(
251
- f"stale html: source-sha256 {html_digest[:12]}… does not match "
252
- f"current MD {expected_md_digest[:12]}…"
253
- )
254
-
255
216
  # (3) deliverable sections contain no form controls
256
217
  for chunk in _no_form_sections(html_body):
257
218
  if "<textarea" in chunk or "<input" in chunk or "<select" in chunk: