okstra 0.72.0 → 0.74.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 (44) hide show
  1. package/docs/kr/architecture.md +2 -1
  2. package/docs/kr/cli.md +1 -1
  3. package/docs/kr/performance-improvement-plan-v2.md +27 -7
  4. package/docs/project-structure-overview.md +1 -0
  5. package/docs/superpowers/plans/2026-06-12-html-plan-approval.md +1000 -0
  6. package/docs/superpowers/specs/2026-06-12-html-plan-approval-design.md +85 -0
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +3 -3
  10. package/runtime/agents/workers/codex-worker.md +5 -5
  11. package/runtime/agents/workers/gemini-worker.md +5 -5
  12. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  13. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  14. package/runtime/prompts/profiles/error-analysis.md +1 -1
  15. package/runtime/prompts/profiles/final-verification.md +1 -1
  16. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  17. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  18. package/runtime/prompts/wizard/prompts.ko.json +11 -2
  19. package/runtime/python/okstra_ctl/clarification_items.py +32 -22
  20. package/runtime/python/okstra_ctl/context_cost.py +32 -5
  21. package/runtime/python/okstra_ctl/md_table.py +56 -0
  22. package/runtime/python/okstra_ctl/render_final_report.py +8 -1
  23. package/runtime/python/okstra_ctl/report_views.py +218 -26
  24. package/runtime/python/okstra_ctl/run.py +4 -4
  25. package/runtime/python/okstra_ctl/user_response.py +55 -0
  26. package/runtime/python/okstra_ctl/wizard.py +116 -11
  27. package/runtime/python/okstra_token_usage/claude.py +22 -0
  28. package/runtime/python/okstra_token_usage/collect.py +51 -3
  29. package/runtime/python/okstra_token_usage/cursor.py +3 -1
  30. package/runtime/schemas/final-report-v1.0.schema.json +2 -1
  31. package/runtime/skills/okstra-convergence/SKILL.md +8 -4
  32. package/runtime/skills/okstra-inspect/SKILL.md +20 -0
  33. package/runtime/skills/okstra-report-writer/SKILL.md +4 -3
  34. package/runtime/skills/okstra-run/SKILL.md +1 -1
  35. package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
  36. package/runtime/templates/reports/final-report.template.md +57 -52
  37. package/runtime/templates/reports/report.css +21 -7
  38. package/runtime/templates/reports/report.js +56 -8
  39. package/runtime/templates/reports/user-response.template.md +15 -0
  40. package/runtime/validators/validate-implementation-plan-stages.py +9 -2
  41. package/runtime/validators/validate-report-views.py +2 -1
  42. package/runtime/validators/validate-run.py +6 -17
  43. package/runtime/validators/validate-schedule.py +9 -2
  44. package/runtime/validators/validate_improvement_report.py +2 -1
@@ -2,7 +2,12 @@
2
2
  Consumed by scripts/okstra-render-final-report.py against the JSON
3
3
  SSOT at runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json.
4
4
  Custom filters: format_int, format_usd, format_duration_ms,
5
- yaml_scalar, yaml_inline_list (defined in render_final_report.py).
5
+ yaml_scalar, yaml_inline_list, model_detail, mdcell (defined in
6
+ render_final_report.py). Every data interpolation inside a table row
7
+ carries `| mdcell` so a literal `|` in worker prose (e.g. "PASS|FAIL")
8
+ cannot split the row — except t() labels and format_int/format_usd/
9
+ format_duration_ms outputs, which cannot contain `|` by construction.
10
+ Never apply mdcell outside table cells (code fences, headings, prose).
6
11
  Token Usage cells render as `--` while tokenUsage.*.totalTokens is
7
12
  null in Phase 6; Phase 7 mutates the data.json then re-renders. #}
8
13
  ---
@@ -47,7 +52,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
47
52
  | {{ t("columns.recordMeta") }} | {{ t("columns.summary") }} |
48
53
  |--------|------------|
49
54
  {% for row in summary -%}
50
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>{{ t("columns.source") }}: {{ row.source }} | {{ row.summary }} |
55
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>{{ t("columns.source") }}: {{ row.source | mdcell }} | {{ row.summary | mdcell }} |
51
56
  {% endfor %}
52
57
 
53
58
  ## Verdict Card
@@ -56,11 +61,11 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
56
61
 
57
62
  | {{ t("verdictCard.tableHeaderLabel") }} | {{ t("verdictCard.tableHeaderValue") }} |
58
63
  |------|----|
59
- | Final Conclusion | {{ verdictCard.finalConclusion }} |
60
- | Verdict Token | `{{ verdictCard.verdictToken }}` |
61
- | Direction | `{{ verdictCard.direction }}` |
64
+ | Final Conclusion | {{ verdictCard.finalConclusion | mdcell }} |
65
+ | Verdict Token | `{{ verdictCard.verdictToken | mdcell }}` |
66
+ | Direction | `{{ verdictCard.direction | mdcell }}` |
62
67
  | Approval Required? | `{% if verdictCard.approvalRequired %}yes — {{ t("verdictCard.approvalRequiredSuffix") }}{% else %}no{% endif %}` |
63
- | Next Step | {{ verdictCard.nextStep }} |
68
+ | Next Step | {{ verdictCard.nextStep | mdcell }} |
64
69
 
65
70
  ## 1. Clarification Items
66
71
 
@@ -77,7 +82,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
77
82
  | {{ t("columns.recordMeta") }} | Statement | Expected form | User input |
78
83
  |---|---|---|---|
79
84
  {% for row in clarificationItems -%}
80
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Kind: `{{ row.kind }}`<br>Blocks: `{{ row.blocks }}`<br>Status: {{ row.status }} | {{ row.statement }} | {{ row.expectedForm }} | {{ row.userInput or '' }} |
85
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Kind: `{{ row.kind | mdcell }}`<br>Blocks: `{{ row.blocks | mdcell }}`<br>Status: {{ row.status | mdcell }} | {{ row.statement | mdcell }} | {{ row.expectedForm | mdcell }} | {{ (row.userInput or '') | mdcell }} |
81
86
  {% endfor %}
82
87
  {%- endif %}
83
88
 
@@ -91,7 +96,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
91
96
  | {{ t("columns.recordMeta") }} | Evidence |
92
97
  |--------|----------|
93
98
  {% for row in evidence.primary -%}
94
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Source items: {{ row.sourceItems | join(', ') }}<br>Source: {{ row.source }} | {{ row.evidence }} |
99
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Source items: {{ row.sourceItems | join(', ') | mdcell }}<br>Source: {{ row.source | mdcell }} | {{ row.evidence | mdcell }} |
95
100
  {% endfor %}
96
101
  {%- endif %}
97
102
 
@@ -105,7 +110,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
105
110
  | {{ t("columns.recordMeta") }} | Hypothesis or supporting evidence | Source / confidence |
106
111
  |--------|-----------------------------------|---------------------|
107
112
  {% for row in evidence.secondary -%}
108
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}` | {{ row.hypothesis }} | {{ row.confidence }} |
113
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}` | {{ row.hypothesis | mdcell }} | {{ row.confidence | mdcell }} |
109
114
  {% endfor %}
110
115
  {%- endif %}
111
116
 
@@ -134,7 +139,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
134
139
  | {{ t("columns.recordMeta") }} | Title | Scope (files/areas) | Reason / Why deferred |
135
140
  |--------|-------|---------------------|------------------------|
136
141
  {% for row in followUpTasks -%}
137
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Origin: `{{ row.origin }}`<br>New Task ID: `{{ row.newTaskId }}`<br>Type: `{{ row.suggestedTaskType }}`<br>Priority: `{{ row.priority }}`<br>Auto-spawn: `{{ row.autoSpawn }}` | {{ row.title }} | {{ row.scope }} | {{ row.reason }} |
142
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Origin: `{{ row.origin | mdcell }}`<br>New Task ID: `{{ row.newTaskId | mdcell }}`<br>Type: `{{ row.suggestedTaskType | mdcell }}`<br>Priority: `{{ row.priority | mdcell }}`<br>Auto-spawn: `{{ row.autoSpawn | mdcell }}` | {{ row.title | mdcell }} | {{ row.scope | mdcell }} | {{ row.reason | mdcell }} |
138
143
  {% endfor %}
139
144
  {%- endif %}
140
145
 
@@ -146,7 +151,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
146
151
  | {{ t("columns.recordMeta") }} | Item | Risk if ignored | Mitigation Owner |
147
152
  |--------|------|-----------------|------------------|
148
153
  {% for row in missingInformation -%}
149
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}` | {{ row.item }} | {{ row.risk }} | {{ row.owner }} |
154
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}` | {{ row.item | mdcell }} | {{ row.risk | mdcell }} | {{ row.owner | mdcell }} |
150
155
  {% endfor %}
151
156
  {%- endif %}
152
157
 
@@ -163,7 +168,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
163
168
  | ID | Ticket ID | Action | Path (and line-range) | Change summary |
164
169
  |----|-----------|--------|------------------------|----------------|
165
170
  {% for fs in opt.fileStructure -%}
166
- | {{ fs.id }} | `{{ fs.ticketId }}` | {{ fs.action }} | `{{ fs.path }}` | {{ fs.summary }} |
171
+ | {{ fs.id | mdcell }} | `{{ fs.ticketId | mdcell }}` | {{ fs.action | mdcell }} | `{{ fs.path | mdcell }}` | {{ fs.summary | mdcell }} |
167
172
  {% endfor %}
168
173
 
169
174
  - {{ t("implementationPlanning.optionInterfacesLabel") }}: {{ opt.interfaces }}
@@ -176,17 +181,17 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
176
181
  | Option | Complexity | Risk | Reversibility | Test Coverage Cost | Rollout Cost |
177
182
  |--------|-----------|------|---------------|--------------------|--------------|
178
183
  {% for row in implementationPlanning.tradeoffMatrix -%}
179
- | {{ row.option }} | {{ row.complexity }} | {{ row.risk }} | {{ row.reversibility }} | {{ row.testCoverageCost }} | {{ row.rolloutCost }} |
184
+ | {{ row.option | mdcell }} | {{ row.complexity | mdcell }} | {{ row.risk | mdcell }} | {{ row.reversibility | mdcell }} | {{ row.testCoverageCost | mdcell }} | {{ row.rolloutCost | mdcell }} |
180
185
  {% endfor %}
181
186
 
182
187
  ### Recommended Option{% if t("sectionAside.recommendedOption") != "Recommended Option" %} ({{ t("sectionAside.recommendedOption") }}){% endif %}
183
188
 
184
189
  | {{ t("implementationPlanning.recommendedTableHeaderLabel") }} | {{ t("implementationPlanning.recommendedTableHeaderValue") }} |
185
190
  |------|----|
186
- | Recommended Option | {{ implementationPlanning.recommendedOption.name }} |
187
- | {{ t("implementationPlanning.coreReasonLabel") }} | {{ implementationPlanning.recommendedOption.coreReason }} |
188
- | {{ t("implementationPlanning.rationaleLabel") }} | {{ implementationPlanning.recommendedOption.rationale }} |
189
- | {{ t("implementationPlanning.rejectedSummaryLabel") }} | {{ implementationPlanning.recommendedOption.rejectedSummary }} |
191
+ | Recommended Option | {{ implementationPlanning.recommendedOption.name | mdcell }} |
192
+ | {{ t("implementationPlanning.coreReasonLabel") }} | {{ implementationPlanning.recommendedOption.coreReason | mdcell }} |
193
+ | {{ t("implementationPlanning.rationaleLabel") }} | {{ implementationPlanning.recommendedOption.rationale | mdcell }} |
194
+ | {{ t("implementationPlanning.rejectedSummaryLabel") }} | {{ implementationPlanning.recommendedOption.rejectedSummary | mdcell }} |
190
195
 
191
196
  ### Dependency / Migration Risk{% if t("sectionAside.dependencyRisk") != "Dependency / Migration Risk" %} ({{ t("sectionAside.dependencyRisk") }}){% endif %}
192
197
 
@@ -196,16 +201,16 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
196
201
  | ID | Kind | Item | {{ t("implementationPlanning.columnImpact") }} | {{ t("implementationPlanning.columnMitigation") }} |
197
202
  |----|------|------|------|------------------|
198
203
  {% for row in implementationPlanning.dependencyMigrationRisk -%}
199
- | {{ row.id }} | {{ row.kind }} | {{ row.item }} | {{ row.impact }} | {{ row.mitigation }} |
204
+ | {{ row.id | mdcell }} | {{ row.kind | mdcell }} | {{ row.item | mdcell }} | {{ row.impact | mdcell }} | {{ row.mitigation | mdcell }} |
200
205
  {% endfor %}
201
206
  {%- endif %}
202
207
 
203
208
  ### Validation Checklist{% if t("sectionAside.validationChecklist") != "Validation Checklist" %} ({{ t("sectionAside.validationChecklist") }}){% endif %}
204
209
 
205
- | Phase | Ticket ID | Check | Command / Observation | Expected outcome |
206
- |-------|-----------|-------|------------------------|-------------------|
210
+ | ID | Phase | Ticket ID | Check | Command / Observation | Expected outcome |
211
+ |----|-------|-----------|-------|------------------------|-------------------|
207
212
  {% for row in implementationPlanning.validationChecklist -%}
208
- | {{ row.phase }} | `{{ row.ticketId }}` | {{ row.check }} | `{{ row.commandOrObservation }}` | {{ row.expectedOutcome }} |
213
+ | {{ row.id | mdcell }} | {{ row.phase | mdcell }} | `{{ row.ticketId | mdcell }}` | {{ row.check | mdcell }} | `{{ row.commandOrObservation | mdcell }}` | {{ row.expectedOutcome | mdcell }} |
209
214
  {% endfor %}
210
215
 
211
216
  ### Rollback Strategy{% if t("sectionAside.rollbackStrategy") != "Rollback Strategy" %} ({{ t("sectionAside.rollbackStrategy") }}){% endif %}
@@ -213,7 +218,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
213
218
  | ID | Step | Action | Trigger signal | {{ t("columns.checkMethod") }} |
214
219
  |----|------|--------|----------------|-----------|
215
220
  {% for row in implementationPlanning.rollbackStrategy -%}
216
- | {{ row.id }} | {{ row.step }} | `{{ row.action }}` | {{ row.triggerSignal }} | `{{ row.verificationMethod }}` |
221
+ | {{ row.id | mdcell }} | {{ row.step | mdcell }} | `{{ row.action | mdcell }}` | {{ row.triggerSignal | mdcell }} | `{{ row.verificationMethod | mdcell }}` |
217
222
  {% endfor %}
218
223
 
219
224
  ### Requirement Coverage
@@ -221,7 +226,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
221
226
  | ID | Source | Requirement | Covered by option / stage / step | Status |
222
227
  |----|--------|-------------|-----------------------------------|--------|
223
228
  {% for row in implementationPlanning.requirementCoverage -%}
224
- | {{ row.id }} | `{{ row.source }}` | {{ row.requirement }} | {{ row.coveredBy }} | `{{ row.status }}` |
229
+ | {{ row.id | mdcell }} | `{{ row.source | mdcell }}` | {{ row.requirement | mdcell }} | {{ row.coveredBy | mdcell }} | `{{ row.status | mdcell }}` |
225
230
  {% endfor %}
226
231
 
227
232
  ## 5.5 Stage Map
@@ -229,7 +234,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
229
234
  | stage | title | depends-on | step-count | exit-contract-summary |
230
235
  |-------|-------|------------|------------|-----------------------|
231
236
  {% for row in implementationPlanning.stageMap -%}
232
- | {{ row.stage }} | {{ row.title }} | {{ row.dependsOn }} | {{ row.stepCount }} | {{ row.exitContractSummary }} |
237
+ | {{ row.stage | mdcell }} | {{ row.title | mdcell }} | {{ row.dependsOn | mdcell }} | {{ row.stepCount | mdcell }} | {{ row.exitContractSummary | mdcell }} |
233
238
  {% endfor %}
234
239
 
235
240
  {% for stage in implementationPlanning.stages %}
@@ -255,7 +260,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
255
260
  | step | action | files | command | expected |
256
261
  |------|--------|-------|---------|----------|
257
262
  {% for row in stage.stepwiseExecution -%}
258
- | {{ row.step }} | {{ row.action }} | `{{ row.files }}` | `{{ row.command }}` | {{ row.expected }} |
263
+ | {{ row.step | mdcell }} | {{ row.action | mdcell }} | `{{ row.files | mdcell }}` | `{{ row.command | mdcell }}` | {{ row.expected | mdcell }} |
259
264
  {% endfor %}
260
265
 
261
266
  ### Stage Exit Contract
@@ -280,7 +285,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
280
285
  | Plan item | Worker | Verdict | Breakage kind | Note |
281
286
  |-----------|--------|---------|---------------|------|
282
287
  {% for row in implementationPlanning.planBodyVerification.verdictDetails -%}
283
- | {{ row.planItem }} | {{ row.worker }} | {{ row.verdict }} | {{ row.breakageKind or '--' }} | {{ row.note or '' }} |
288
+ | {{ row.planItem | mdcell }} | {{ row.worker | mdcell }} | {{ row.verdict | mdcell }} | {{ (row.breakageKind or '--') | mdcell }} | {{ (row.note or '') | mdcell }} |
284
289
  {% endfor %}
285
290
 
286
291
  #### Dissent log
@@ -318,10 +323,10 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
318
323
 
319
324
  | {{ t("releaseHandoff.questionsTableHeader.questionId") }} | {{ t("releaseHandoff.questionsTableHeader.questionBody") }} | {{ t("releaseHandoff.questionsTableHeader.userResponse") }} | {{ t("releaseHandoff.questionsTableHeader.allowedOptions") }} |
320
325
  |---------|-----------|--------------------|--------------------|
321
- | H1 | {{ t("releaseHandoff.h1Body") }} | `{{ releaseHandoff.userSelections.h1 }}` | `local only` / `push + PR` / `skip` |
322
- | H2 | {{ t("releaseHandoff.h2Body") }} | `{{ releaseHandoff.userSelections.h2 or t("releaseHandoff.h2DefaultLabel") }}` | {{ t("releaseHandoff.h2OptionsLabel") }} |
323
- | H2b | Merge conflict probe | `{{ releaseHandoff.userSelections.h2b or releaseHandoff.mergeConflictProbe.kind }}` | `not-run` / `clean` / `proceed anyway` / `change base branch` / `cancel` |
324
- | H3 | {{ t("releaseHandoff.h3Body") }} | `{{ releaseHandoff.userSelections.h3 }}` | `use as-is` / `edit then proceed` / `cancel` |
326
+ | H1 | {{ t("releaseHandoff.h1Body") }} | `{{ releaseHandoff.userSelections.h1 | mdcell }}` | `local only` / `push + PR` / `skip` |
327
+ | H2 | {{ t("releaseHandoff.h2Body") }} | `{{ (releaseHandoff.userSelections.h2 or t("releaseHandoff.h2DefaultLabel")) | mdcell }}` | {{ t("releaseHandoff.h2OptionsLabel") }} |
328
+ | H2b | Merge conflict probe | `{{ (releaseHandoff.userSelections.h2b or releaseHandoff.mergeConflictProbe.kind) | mdcell }}` | `not-run` / `clean` / `proceed anyway` / `change base branch` / `cancel` |
329
+ | H3 | {{ t("releaseHandoff.h3Body") }} | `{{ releaseHandoff.userSelections.h3 | mdcell }}` | `use as-is` / `edit then proceed` / `cancel` |
325
330
 
326
331
  ### 5.6.4 Executed Commands
327
332
 
@@ -331,7 +336,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
331
336
  | # | command (verbatim) | exit code | {{ t("releaseHandoff.commandsTableHeader.outputSummary") }} |
332
337
  |---|---------------------|-----------|--------------------|
333
338
  {% for row in releaseHandoff.executedCommands -%}
334
- | {{ row.number }} | `{{ row.command }}` | `{{ row.exitCode }}` | {{ row.outputSummary }} |
339
+ | {{ row.number | mdcell }} | `{{ row.command | mdcell }}` | `{{ row.exitCode | mdcell }}` | {{ row.outputSummary | mdcell }} |
335
340
  {% endfor %}
336
341
  {%- endif %}
337
342
 
@@ -343,7 +348,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
343
348
  | Short SHA | Full SHA | Subject | Files |
344
349
  |-----------|----------|---------|-------|
345
350
  {% for row in releaseHandoff.commitList -%}
346
- | `{{ row.shortSha }}` | `{{ row.fullSha }}` | {{ row.subject }} | {{ row.files }} |
351
+ | `{{ row.shortSha | mdcell }}` | `{{ row.fullSha | mdcell }}` | {{ row.subject | mdcell }} | {{ row.files | mdcell }} |
347
352
  {% endfor %}
348
353
  {%- endif %}
349
354
 
@@ -393,7 +398,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
393
398
  | # | Short SHA | Full SHA | Plan Step | Subject | Files |
394
399
  |---|-----------|----------|-----------|---------|-------|
395
400
  {% for row in implementation.commitList -%}
396
- | {{ row.number }} | `{{ row.shortSha }}` | `{{ row.fullSha }}` | {{ row.planStep }} | {{ row.subject }} | {{ row.files }} |
401
+ | {{ row.number | mdcell }} | `{{ row.shortSha | mdcell }}` | `{{ row.fullSha | mdcell }}` | {{ row.planStep | mdcell }} | {{ row.subject | mdcell }} | {{ row.files | mdcell }} |
397
402
  {% endfor %}
398
403
  {%- endif %}
399
404
 
@@ -406,7 +411,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
406
411
  | File | Action | Lines (+/-) | Plan step / Out-of-plan |
407
412
  |------|--------|-------------|--------------------------|
408
413
  {% for row in implementation.diffSummary.files -%}
409
- | `{{ row.file }}` | {{ row.action }} | `{{ row.lines }}` | {{ row.planStep }} |
414
+ | `{{ row.file | mdcell }}` | {{ row.action | mdcell }} | `{{ row.lines | mdcell }}` | {{ row.planStep | mdcell }} |
410
415
  {% endfor %}
411
416
 
412
417
  ### 5.7.4 Out-of-plan Edits
@@ -417,7 +422,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
417
422
  | ID | File | Rationale | Trigger |
418
423
  |----|------|-----------|---------|
419
424
  {% for row in implementation.outOfPlanEdits -%}
420
- | {{ row.id }} | `{{ row.file }}` | {{ row.rationale }} | {{ row.trigger }} |
425
+ | {{ row.id | mdcell }} | `{{ row.file | mdcell }}` | {{ row.rationale | mdcell }} | {{ row.trigger | mdcell }} |
421
426
  {% endfor %}
422
427
  {%- endif %}
423
428
 
@@ -438,7 +443,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
438
443
  | Phase | Command | Exit code | Output tail | TDD evidence |
439
444
  |-------|---------|-----------|-------------|--------------|
440
445
  {% for row in implementation.validationEvidence -%}
441
- | {{ row.phase }} | `{{ row.command }}` | `{{ row.exitCode }}` | {{ row.outputTail }} | {{ row.tddEvidence or '--' }} |
446
+ | {{ row.phase | mdcell }} | `{{ row.command | mdcell }}` | `{{ row.exitCode | mdcell }}` | {{ row.outputTail | mdcell }} | {{ (row.tddEvidence or '--') | mdcell }} |
442
447
  {% endfor %}
443
448
 
444
449
  ### 5.7.7 Verifier Results
@@ -457,7 +462,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
457
462
  | Category | Rollback command | Verification | Result |
458
463
  |----------|-------------------|---------------|--------|
459
464
  {% for row in implementation.rollbackVerification -%}
460
- | {{ row.category }} | `{{ row.rollbackCommand }}` | {{ row.verification }} | `{{ row.result }}` |
465
+ | {{ row.category | mdcell }} | `{{ row.rollbackCommand | mdcell }}` | {{ row.verification | mdcell }} | `{{ row.result | mdcell }}` |
461
466
  {% endfor %}
462
467
 
463
468
  ### 5.7.9 Routing Recommendation
@@ -499,7 +504,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
499
504
  | ID | Severity | Statement | Evidence | Recommended follow-up phase |
500
505
  |----|----------|-----------|----------|------------------------------|
501
506
  {% for row in finalVerification.acceptanceBlockers -%}
502
- | {{ row.id }} | `{{ row.severity }}` | {{ row.statement }} | {{ row.evidence }} | `{{ row.followUpPhase }}` |
507
+ | {{ row.id | mdcell }} | `{{ row.severity | mdcell }}` | {{ row.statement | mdcell }} | {{ row.evidence | mdcell }} | `{{ row.followUpPhase | mdcell }}` |
503
508
  {% endfor %}
504
509
  {%- endif %}
505
510
 
@@ -511,7 +516,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
511
516
  | ID | Item | Mitigation owner | Escalation trigger |
512
517
  |----|------|------------------|---------------------|
513
518
  {% for row in finalVerification.residualRisk -%}
514
- | {{ row.id }} | {{ row.item }} | {{ row.owner }} | {{ row.escalationTrigger }} |
519
+ | {{ row.id | mdcell }} | {{ row.item | mdcell }} | {{ row.owner | mdcell }} | {{ row.escalationTrigger | mdcell }} |
515
520
  {% endfor %}
516
521
  {%- endif %}
517
522
 
@@ -520,7 +525,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
520
525
  | ID | {{ t("finalVerification.columnRequirement") }} | Artifact | Status |
521
526
  |----|--------------------------------|----------|--------|
522
527
  {% for row in finalVerification.validationEvidence -%}
523
- | {{ row.id }} | {{ row.requirement }} | `{{ row.artifact }}` | {{ row.status }} |
528
+ | {{ row.id | mdcell }} | {{ row.requirement | mdcell }} | `{{ row.artifact | mdcell }}` | {{ row.status | mdcell }} |
524
529
  {% endfor %}
525
530
 
526
531
  ### 5.8.5 Read-only Command Log
@@ -528,7 +533,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
528
533
  | # | Tier | Command (verbatim) | Status | Exit code | Output tail |
529
534
  |---|------|---------------------|--------|-----------|-------------|
530
535
  {% for row in finalVerification.readonlyCommandLog -%}
531
- | {{ row.number }} | {{ row.tier }} | `{{ row.command }}` | `{{ row.status }}` | {{ row.exitCode if row.exitCode is not none else '—' }} | {{ row.rejectionReason if row.status == 'rejected' else row.outputTail }} |
536
+ | {{ row.number | mdcell }} | {{ row.tier | mdcell }} | `{{ row.command | mdcell }}` | `{{ row.status | mdcell }}` | {{ (row.exitCode if row.exitCode is not none else '—') | mdcell }} | {{ (row.rejectionReason if row.status == 'rejected' else row.outputTail) | mdcell }} |
532
537
  {% endfor %}
533
538
 
534
539
  ### 5.8.6 Conditional Acceptance Conditions
@@ -539,7 +544,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
539
544
  | ID | Condition | Evidence required | Blocks release-handoff |
540
545
  |----|-----------|-------------------|------------------------|
541
546
  {% for row in finalVerdict.conditionalAcceptanceConditions -%}
542
- | {{ row.id }} | {{ row.condition }} | {{ row.evidenceRequired }} | {{ row.blocksReleaseHandoff }} |
547
+ | {{ row.id | mdcell }} | {{ row.condition | mdcell }} | {{ row.evidenceRequired | mdcell }} | {{ row.blocksReleaseHandoff | mdcell }} |
543
548
  {% endfor %}
544
549
  {%- endif %}
545
550
 
@@ -580,7 +585,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
580
585
  | Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches (worker:status:durationMs) | skippedWorkers (worker:reason) |
581
586
  |-------|----------------|---------------|----------------------|----------------------------------------|---------------------------------|
582
587
  {% for row in crossVerification.roundHistory.rounds -%}
583
- | {{ row.round }} | {{ row.inputQueueSize }} | {{ row.resolvedCount }} | {{ row.carriedForwardCount }} | {{ row.dispatches }} | {{ row.skippedWorkers }} |
588
+ | {{ row.round | mdcell }} | {{ row.inputQueueSize | mdcell }} | {{ row.resolvedCount | mdcell }} | {{ row.carriedForwardCount | mdcell }} | {{ row.dispatches | mdcell }} | {{ row.skippedWorkers | mdcell }} |
584
589
  {% endfor %}
585
590
 
586
591
  - `round2SkippedReason`: `{{ crossVerification.roundHistory.round2SkippedReason }}` ← {{ t("roundHistory.round2SkippedReasonNote") }}
@@ -600,7 +605,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
600
605
  | {{ t("columns.recordMeta") }} | Statement | Evidence (path:line / log / worker report) |
601
606
  |--------|-----------|---------------------------------------------|
602
607
  {% for row in crossVerification.consensus -%}
603
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Source items: {{ row.sourceItems | join(', ') }} | {{ row.statement }} | {{ row.evidence }} |
608
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Source items: {{ row.sourceItems | join(', ') | mdcell }} | {{ row.statement | mdcell }} | {{ row.evidence | mdcell }} |
604
609
  {% endfor %}
605
610
  {%- endif %}
606
611
 
@@ -614,7 +619,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
614
619
  | {{ t("columns.recordMeta") }} | Disagreement | Evidence |
615
620
  |--------|--------------|----------|
616
621
  {% for row in crossVerification.differences -%}
617
- | **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Workers: {% for w in row.workersPosition %}{{ w.worker }}:{{ w.itemId }} ({{ w.position }}){% if not loop.last %} / {% endif %}{% endfor %} | {{ row.disagreement }} | {{ row.evidence }} |
622
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Workers: {% for w in row.workersPosition %}{{ w.worker | mdcell }}:{{ w.itemId | mdcell }} ({{ w.position | mdcell }}){% if not loop.last %} / {% endif %}{% endfor %} | {{ row.disagreement | mdcell }} | {{ row.evidence | mdcell }} |
618
623
  {% endfor %}
619
624
  {%- endif %}
620
625
 
@@ -624,11 +629,11 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
624
629
 
625
630
  | {{ t("verdictCard.tableHeaderLabel") }} | {{ t("verdictCard.tableHeaderValue") }} |
626
631
  |------|----|
627
- | Final Conclusion | {{ finalVerdict.finalConclusion }} |
628
- | Verdict Token | `{{ finalVerdict.verdictToken }}` |
629
- | Direction | `{{ finalVerdict.direction }}` |
630
- | {{ t("verdictCard.rationaleLabel") }} | {{ finalVerdict.rationaleRowIds | join(', ') }} |
631
- | {{ t("verdictCard.nextStepLabel") }} | {{ finalVerdict.nextStep }} |
632
+ | Final Conclusion | {{ finalVerdict.finalConclusion | mdcell }} |
633
+ | Verdict Token | `{{ finalVerdict.verdictToken | mdcell }}` |
634
+ | Direction | `{{ finalVerdict.direction | mdcell }}` |
635
+ | {{ t("verdictCard.rationaleLabel") }} | {{ finalVerdict.rationaleRowIds | join(', ') | mdcell }} |
636
+ | {{ t("verdictCard.nextStepLabel") }} | {{ finalVerdict.nextStep | mdcell }} |
632
637
 
633
638
  {% if ticketCoverage.omit %}
634
639
  {# Ticket Coverage omitted entirely — release-handoff / final-verification #}
@@ -643,7 +648,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
643
648
  | Ticket ID | {{ t("ticketCoverage.columnSections") }} | {{ t("ticketCoverage.columnRelatedIds") }} |
644
649
  |-----------|-----------|---------------|
645
650
  {% for row in ticketCoverage.rows -%}
646
- | `{{ row.ticketId }}` | `{{ row.sections }}` | `{{ row.relatedIds }}` |
651
+ | `{{ row.ticketId | mdcell }}` | `{{ row.sections | mdcell }}` | `{{ row.relatedIds | mdcell }}` |
647
652
  {% endfor %}
648
653
  {%- endif %}
649
654
 
@@ -655,7 +660,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
655
660
  | {{ t("columns.recordMeta") }} | Summary of Key Findings |
656
661
  |--------|-------------------------|
657
662
  {% for row in executionStatus -%}
658
- | **{{ row.agent }}**<br>Role: {{ row.role }}<br>Model: {{ row.model }}<br>Status: {{ row.status }}<br>{{ t("columns.rawTokens") }}: {{ row.totalTokens | format_int }}{% if row.cliTotalTokens %} (CLI: {{ row.cliTotalTokens | format_int }}){% endif %}<br>{{ t("columns.billableTokens") }}: {{ row.billableTokens | format_int }}<br>{{ t("columns.cost") }}: {{ row.costUsd | format_usd }}{% if row.cliCostUsd %} (+ CLI {{ row.cliCostUsd | format_usd }}){% endif %}<br>Duration: {{ row.durationMs | format_duration_ms }} | {{ row.summary }} |
663
+ | **{{ row.agent | mdcell }}**<br>Role: {{ row.role | mdcell }}<br>Model: {{ row.model | mdcell }}<br>Status: {{ row.status | mdcell }}<br>{{ t("columns.rawTokens") }}: {{ row.totalTokens | format_int }}{% if row.cliTotalTokens %} (CLI: {{ row.cliTotalTokens | format_int }}){% endif %}<br>{{ t("columns.billableTokens") }}: {{ row.billableTokens | format_int }}<br>{{ t("columns.cost") }}: {{ row.costUsd | format_usd }}{% if row.cliCostUsd %} (+ CLI {{ row.cliCostUsd | format_usd }}){% endif %}<br>Duration: {{ row.durationMs | format_duration_ms }} | {{ row.summary | mdcell }} |
659
664
  {% endfor %}
660
665
 
661
666
  ## {{ t("tokenSummary.heading") }}
@@ -121,13 +121,14 @@ th, td {
121
121
  /* No max-width: cells that carry prose (statement / source / scope)
122
122
  * absorb the remaining horizontal space within `main`'s 120ch. */
123
123
  }
124
- /* Narrow columns — header AND every body cell of this column fit
125
- * within 30 plain chars (per `_narrow_columns()` in report_views.py).
126
- * Examples: ID, Ticket ID, Kind, Status, Priority, Auto-spawn?,
127
- * 에이전트, 역할, 모델, 상태. Pinning `width: 5%` makes these
128
- * columns shrink toward the suggestion while wide neighbours absorb
129
- * the remainder, so a long-prose row never squashes ID-style stacks
130
- * into one-character ladders. */
124
+ /* Narrow columns — assigned by `_narrow_columns()` in report_views.py:
125
+ * either every cell fits within 30 plain chars, or the header is
126
+ * whitelisted (ID, Ticket ID, Kind, Status, 항목, 출처, 에이전트 …)
127
+ * AND no cell carries a <br>-line over 60 plain chars — `nowrap` below
128
+ * makes the longest line the column's intrinsic width, so longer lines
129
+ * must fall back to a plain wrapping column. Pinning `width: 5%` makes
130
+ * narrow columns shrink toward the suggestion while wide neighbours
131
+ * absorb the remainder. */
131
132
  td.td-narrow, th.td-narrow {
132
133
  width: 5%;
133
134
  white-space: nowrap;
@@ -196,9 +197,22 @@ button[data-action]:hover { background: color-mix(in srgb, Highlight 20%, Button
196
197
  }
197
198
  #user-response-output:empty { display: none; }
198
199
 
200
+ #plan-approval {
201
+ max-width: 120ch;
202
+ margin: 1.5rem auto;
203
+ padding: 0.8em 1rem;
204
+ border: 1px solid color-mix(in srgb, GrayText 40%, transparent);
205
+ border-radius: 4px;
206
+ }
207
+ #plan-approval h2 { margin-top: 0; }
208
+ #plan-approval label { display: block; margin: 0.4em 0; }
209
+ #plan-approval select { font: inherit; max-width: 100%; }
210
+ .approval-disabled-reason { color: GrayText; margin: 0.4em 0 0; }
211
+
199
212
  @media print {
200
213
  .report-header, .report-footer { position: static; }
201
214
  thead th { position: static; }
202
215
  button[data-action] { display: none; }
203
216
  #user-response-output { display: none; }
217
+ #plan-approval { display: none; }
204
218
  }
@@ -9,8 +9,11 @@
9
9
  * (everything else / fallback).
10
10
  * 2. Serialise the entries into markdown whose bytes are IDENTICAL
11
11
  * to scripts/okstra_ctl/report_views.py serialize_user_response.
12
- * 3. Write the result to <pre id="user-response-output"> and offer a
13
- * [Copy] button.
12
+ * 3. Write the result to <pre id="user-response-output">, offer a
13
+ * [Copy] button, and download it as
14
+ * user-response-<task-type>-<seq>.md — the user drops that file in
15
+ * runs/<task-type>/user-responses/ and --resume-clarification
16
+ * auto-appends it to the next run's clarification-response.md.
14
17
  *
15
18
  * The byte-identity contract is enforced by tests/test_report_views.py
16
19
  * which spawns Node to execute buildUserResponseMarkdown and diffs the
@@ -91,6 +94,23 @@
91
94
  return entries;
92
95
  }
93
96
 
97
+ // Plan Approval 위젯 (implementation-planning 보고서에만 존재). 체크박스가
98
+ // disabled(§1 차단 미해소)이거나 미체크면 null. 옵션이 추천(data-recommended)
99
+ // 그대로면 implementationOption 을 비워 보낸다 → 소비 측 Recommended 폴백.
100
+ function collectApproval() {
101
+ var box = document.getElementById("approval-checkbox");
102
+ if (!box || box.disabled || !box.checked) return null;
103
+ var sel = document.getElementById("approval-option");
104
+ var option = "";
105
+ if (sel && sel.selectedIndex >= 0) {
106
+ var opt = sel.options[sel.selectedIndex];
107
+ if (opt && opt.getAttribute("data-recommended") !== "true") {
108
+ option = opt.value;
109
+ }
110
+ }
111
+ return { approved: true, implementationOption: option };
112
+ }
113
+
94
114
  // Toggle the visibility of the "기타" companion input next to each
95
115
  // select whose current value is "__other__". Wired at bind() time and
96
116
  // also called once for the initial state.
@@ -110,7 +130,7 @@
110
130
  }
111
131
  }
112
132
 
113
- function buildUserResponseMarkdown(runMeta, entries, createdAt) {
133
+ function buildUserResponseMarkdown(runMeta, entries, createdAt, approval) {
114
134
  var head =
115
135
  "---\n" +
116
136
  "task-key: " + (runMeta["task-key"] || "") + "\n" +
@@ -123,10 +143,8 @@
123
143
  "\n" +
124
144
  "# User Response\n";
125
145
 
126
- if (!entries || entries.length === 0) {
127
- return head + "\n_(No user responses recorded.)_\n";
128
- }
129
-
146
+ entries = entries || [];
147
+ var hasApproval = !!(approval && approval.approved);
130
148
  var chunks = "";
131
149
  for (var i = 0; i < entries.length; i++) {
132
150
  var e = entries[i];
@@ -140,15 +158,44 @@
140
158
  }
141
159
  chunks += chunk;
142
160
  }
161
+ if (entries.length === 0 && !hasApproval) {
162
+ chunks += "\n_(No user responses recorded.)_\n";
163
+ }
164
+ if (hasApproval) {
165
+ chunks += "\n## APPROVAL\n- Approved: true\n";
166
+ if (approval.implementationOption) {
167
+ chunks +=
168
+ "- Implementation-Option: " + trimMultiline(approval.implementationOption) + "\n";
169
+ }
170
+ }
143
171
  return head + chunks;
144
172
  }
145
173
 
174
+ function downloadUserResponse(md, runMeta) {
175
+ var name =
176
+ "user-response-" + (runMeta["task-type"] || "report") + "-" +
177
+ (runMeta["seq"] || "000") + ".md";
178
+ var blob = new Blob([md], { type: "text/markdown" });
179
+ var url = URL.createObjectURL(blob);
180
+ var a = document.createElement("a");
181
+ a.href = url;
182
+ a.download = name;
183
+ document.body.appendChild(a);
184
+ a.click();
185
+ document.body.removeChild(a);
186
+ setTimeout(function () { URL.revokeObjectURL(url); }, 0);
187
+ }
188
+
146
189
  function exportUserResponse() {
147
190
  var runMeta = readRunMeta();
148
191
  var entries = collectEntries();
149
- var md = buildUserResponseMarkdown(runMeta, entries, isoNowUtc());
192
+ var approval = collectApproval();
193
+ var md = buildUserResponseMarkdown(runMeta, entries, isoNowUtc(), approval);
150
194
  var out = document.getElementById("user-response-output");
151
195
  if (out) out.textContent = md;
196
+ // Nothing answered yet — show the empty serialisation as feedback but
197
+ // don't download a useless file.
198
+ if (entries.length > 0 || approval) downloadUserResponse(md, runMeta);
152
199
  return md;
153
200
  }
154
201
 
@@ -200,6 +247,7 @@
200
247
  window.okstraReportView = {
201
248
  buildUserResponseMarkdown: buildUserResponseMarkdown,
202
249
  collectEntries: collectEntries,
250
+ collectApproval: collectApproval,
203
251
  exportUserResponse: exportUserResponse,
204
252
  };
205
253
  }
@@ -62,6 +62,21 @@ created-at: 2026-05-17T10:00:00Z
62
62
  > (prediction=0: 1,204) (prediction=1: 38)
63
63
  ```
64
64
 
65
+ ## APPROVAL 블록 (implementation-planning 전용)
66
+
67
+ Plan Approval 위젯에서 승인을 체크하고 Export 하면 본문 끝에 다음 블록이 추가됩니다:
68
+
69
+ ```markdown
70
+ ## APPROVAL
71
+ - Approved: true
72
+ - Implementation-Option: <Option Candidates 의 name 그대로>
73
+ ```
74
+
75
+ - 옵션을 추천(권장) 그대로 두면 `Implementation-Option:` 라인은 생략됩니다 — implementation 은 plan 의 Recommended Option 으로 폴백합니다.
76
+ - 이 블록의 소비자는 implementation 시작 마법사의 approve-confirm 단계입니다 (`scripts/okstra_ctl/wizard.py`). 마법사가 사용자 확인 후 기존 `--approve` / `--implementation-option` 경로로 적용하며, sidecar 자체는 승인 게이트 검증을 우회하지 않습니다.
77
+ - 파서는 `scripts/okstra_ctl/user_response.py` `parse_user_response_approval` 이며, producer 출력과 byte-identical 한 소문자 `Approved: true` 만 인정합니다 (손편집된 `TRUE` 등은 fail-closed 로 불인정).
78
+ - `--resume-clarification` 은 sidecar 를 verbatim 첨부하므로 APPROVAL 블록도 그대로 실립니다 — planning 재실행 입력에서는 무해한 잔류 정보입니다.
79
+
65
80
  ## 호환성 규칙
66
81
 
67
82
  - `Kind` 가 알 수 없는 값이면 폼은 `<textarea>` fallback 으로 렌더되며, 직렬화 시 받은 `Kind` 문자열을 그대로 보존합니다.
@@ -12,6 +12,13 @@ from dataclasses import dataclass
12
12
  from pathlib import Path
13
13
  from typing import List, Tuple
14
14
 
15
+ # scripts/ is not a package; insert it so okstra_ctl is importable directly.
16
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
17
+ if str(_SCRIPTS_DIR) not in sys.path:
18
+ sys.path.insert(0, str(_SCRIPTS_DIR))
19
+
20
+ from okstra_ctl.md_table import split_pipe_row # noqa: E402
21
+
15
22
  STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
16
23
  REQUIRED_SUBSECTIONS = (
17
24
  "Carry-In",
@@ -60,7 +67,7 @@ def _parse_stage_map(text: str) -> Tuple[List[StageMeta], List[ValidationError]]
60
67
  break
61
68
  if not line.strip().startswith("|"):
62
69
  continue
63
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
70
+ cells = split_pipe_row(line)
64
71
  if len(cells) != 5:
65
72
  continue
66
73
  # skip header and separator rows (all-dash of any length is covered by set check)
@@ -119,7 +126,7 @@ def _effective_step_rows(section: str) -> List[List[str]]:
119
126
  continue
120
127
  if not s.startswith("|"):
121
128
  continue
122
- cells = [c.strip() for c in s.strip("|").split("|")]
129
+ cells = split_pipe_row(s)
123
130
  first_cell = cells[0]
124
131
  if first_cell.lower() == "step":
125
132
  continue
@@ -35,6 +35,7 @@ from okstra_ctl.clarification_items import ( # noqa: E402
35
35
  )
36
36
  from okstra_ctl.report_views import ( # noqa: E402
37
37
  extract_html_digest,
38
+ plan_approval_context,
38
39
  source_digest,
39
40
  )
40
41
 
@@ -99,7 +100,7 @@ def validate(report_path: Path) -> list[str]:
99
100
  # html sibling (see report_views.report_has_clarification_items). When
100
101
  # there are no C-* rows and no html, that is the expected skip state.
101
102
  if not html_path.is_file():
102
- if not md_ids:
103
+ if not md_ids and plan_approval_context(report_path, md) is None:
103
104
  return []
104
105
  return [f"missing html artifact: {html_path}"]
105
106
 
@@ -40,6 +40,10 @@ from okstra_ctl.conformance import ( # noqa: E402
40
40
  qa_result_from_dict,
41
41
  validate_conformance_manifest,
42
42
  )
43
+ from okstra_ctl.md_table import ( # noqa: E402
44
+ is_separator_row as _is_markdown_separator,
45
+ split_pipe_row as _split_pipe_row,
46
+ )
43
47
 
44
48
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
45
49
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
@@ -624,7 +628,7 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
624
628
  # Skip non-table lines, the header separator (`|------|`), and
625
629
  # blank lines. Header rows have no backticks so they self-skip.
626
630
  continue
627
- cells = [c.strip() for c in line.strip("|").split("|")]
631
+ cells = _split_pipe_row(line)
628
632
  if not cells:
629
633
  continue
630
634
  label_cell = cells[0].strip("* `")
@@ -1214,27 +1218,12 @@ _CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
1214
1218
 
1215
1219
 
1216
1220
  def _split_markdown_row(line: str) -> list[str]:
1217
- stripped = line.strip()
1218
- if stripped.startswith("|"):
1219
- stripped = stripped[1:]
1220
- if stripped.endswith("|"):
1221
- stripped = stripped[:-1]
1222
1221
  return [
1223
1222
  _CELL_ANCHOR_RE.sub("", cell).strip().strip("`").strip()
1224
- for cell in stripped.split("|")
1223
+ for cell in _split_pipe_row(line)
1225
1224
  ]
1226
1225
 
1227
1226
 
1228
- def _is_markdown_separator(line: str) -> bool:
1229
- stripped = line.strip()
1230
- if not stripped.startswith("|"):
1231
- return False
1232
- for cell in stripped.strip("|").split("|"):
1233
- if not re.fullmatch(r"\s*:?-{2,}:?\s*", cell):
1234
- return False
1235
- return True
1236
-
1237
-
1238
1227
  def _append_requirement_coverage_failures(
1239
1228
  content: str,
1240
1229
  gate_value: str,