okstra 0.28.0 → 0.29.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.
- package/bin/okstra +1 -0
- package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +167 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +2 -2
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/gemini-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- package/runtime/bin/okstra-render-report-views.py +129 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/report_views.py +701 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +19 -4
- package/runtime/skills/okstra-team-contract/SKILL.md +2 -5
- package/runtime/templates/reports/final-report.template.md +169 -2
- package/runtime/templates/reports/report.css +151 -0
- package/runtime/templates/reports/report.js +163 -0
- package/runtime/templates/reports/user-response.template.md +69 -0
- package/runtime/validators/lib/fixtures.sh +40 -3
- package/runtime/validators/validate-report-views.py +283 -0
- package/runtime/validators/validate-run.py +251 -3
- package/runtime/validators/validate-workflow.sh +4 -0
- package/src/install.mjs +1 -0
- package/src/render-views.mjs +67 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Render two derived views of an okstra final-report markdown.
|
|
2
|
+
|
|
3
|
+
Two products, one source of truth:
|
|
4
|
+
|
|
5
|
+
* ``slim_markdown(src_md)`` — deterministic token-saving stripper that
|
|
6
|
+
removes presentational fluff while preserving every substring the
|
|
7
|
+
``validators/validate-run.py`` substring checks rely on. Intended as
|
|
8
|
+
the AI consumption surface (next-phase lead prompt input).
|
|
9
|
+
* ``render_html(src_md, *, run_meta)`` — deterministic self-contained
|
|
10
|
+
HTML renderer for human readers. Sections §5/§6/§7 user-actionable
|
|
11
|
+
rows (those reachable from §5 ``C-*`` IDs) get embedded ``<form>``
|
|
12
|
+
controls. §4.6 / §4.7 / §4.8 deliverable sub-sections are explicitly
|
|
13
|
+
excluded from form attachment — they are read-only deliverables.
|
|
14
|
+
|
|
15
|
+
User responses are NEVER merged back into the original report. The HTML
|
|
16
|
+
serialises a ``user-response`` markdown sidecar via ``Export user
|
|
17
|
+
response`` button (client-side JS, single-reference-point with the
|
|
18
|
+
Python ``serialize_user_response`` function below) and the user pastes
|
|
19
|
+
it to ``runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md``.
|
|
20
|
+
|
|
21
|
+
Strip rules (deterministic):
|
|
22
|
+
- ``STRIP_TOKEN_PLACEHOLDER_ZEROS`` — remove ``$0.00`` cost rows
|
|
23
|
+
except the ``Codex/Gemini CLI 추가 비용`` row (keep, as the literal
|
|
24
|
+
zero is meaningful audit info).
|
|
25
|
+
- ``STRIP_DECORATIVE_HR`` — collapse 3+ consecutive horizontal rules
|
|
26
|
+
to a single ``---``.
|
|
27
|
+
- ``STRIP_VERDICT_DETAIL`` — drop ``### 4.5.9 Verdict details`` wide
|
|
28
|
+
matrix body when the preceding ``Verdict summary`` card is present.
|
|
29
|
+
Keep the heading line itself so heading-set invariants hold.
|
|
30
|
+
- ``STRIP_INTRO_RECIPE`` — drop the ``> **읽는 법**:`` quoted recipe
|
|
31
|
+
paragraph beneath the Token Usage Summary table. The definition is
|
|
32
|
+
audience-facing; the slim AI consumer already knows the schema.
|
|
33
|
+
- ``COLLAPSE_BLANK_LINES`` — normalise 3+ blank lines to one.
|
|
34
|
+
|
|
35
|
+
Absolutely-preserve invariants (validator hard-fail prevention):
|
|
36
|
+
- ``## Verdict Card`` block content byte-identical
|
|
37
|
+
- ``## 2. Final Verdict`` table rows for ``Verdict Token`` /
|
|
38
|
+
``Direction`` / ``Next Step``
|
|
39
|
+
- ``## 0. Clarification Response Carried In`` (when present)
|
|
40
|
+
- implementation-planning 9 substrings (Option Candidates,
|
|
41
|
+
Trade-off, Recommended Option, Stepwise Execution Order,
|
|
42
|
+
Dependency, Validation Checklist, Rollback, User Approval Request,
|
|
43
|
+
Plan Body Verification + Gate result:)
|
|
44
|
+
- implementation §4.7 8 sub-section substrings (Approved Plan
|
|
45
|
+
Reference, Commit List, Diff Summary, Out-of-plan Edits,
|
|
46
|
+
Validation Evidence, Verifier Results, Rollback Verification,
|
|
47
|
+
Routing Recommendation)
|
|
48
|
+
- final-verification §4.8 6 sub-section substrings (Source
|
|
49
|
+
Implementation Report, Acceptance Blockers, Residual Risk,
|
|
50
|
+
Validation Evidence, Read-only Command Log, Routing Recommendation)
|
|
51
|
+
- release-handoff §4.6.1–4.6.7 sub-section substrings (Source
|
|
52
|
+
Verification Report, Feature Branch & Working-Tree State, User
|
|
53
|
+
Selections, Executed Commands, Commit List, Merge Conflict Probe,
|
|
54
|
+
Pull Request Outcome, Routing Recommendation)
|
|
55
|
+
|
|
56
|
+
The substring list is sourced from validate-run.py via
|
|
57
|
+
``preserved_substrings()`` so a future contract change touches one
|
|
58
|
+
place only.
|
|
59
|
+
"""
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import hashlib
|
|
63
|
+
import html
|
|
64
|
+
import re
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Iterable, Optional
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def source_digest(src_md: str) -> str:
|
|
71
|
+
"""Stable identifier for a final-report markdown body. Both
|
|
72
|
+
derived views embed this so the validator can detect a stale
|
|
73
|
+
slim/html that was generated from an older MD."""
|
|
74
|
+
return hashlib.sha256(src_md.encode("utf-8")).hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_SOURCE_DIGEST_COMMENT_RE = re.compile(
|
|
78
|
+
r"^<!--\s*source-sha256:\s*([0-9a-f]{64})\s*-->\s*$", re.MULTILINE
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_slim_digest(slim_text: str) -> Optional[str]:
|
|
83
|
+
"""Return the source-sha256 token embedded in a slim MD's first
|
|
84
|
+
comment line, or None if absent (older artifacts)."""
|
|
85
|
+
first_nonblank = next(
|
|
86
|
+
(ln for ln in slim_text.splitlines() if ln.strip()), ""
|
|
87
|
+
)
|
|
88
|
+
m = _SOURCE_DIGEST_COMMENT_RE.match(first_nonblank)
|
|
89
|
+
return m.group(1) if m else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_HTML_DIGEST_RE = re.compile(r'"source-sha256":"([0-9a-f]{64})"')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_html_digest(html_text: str) -> Optional[str]:
|
|
96
|
+
m = _HTML_DIGEST_RE.search(html_text)
|
|
97
|
+
return m.group(1) if m else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _strip_leading_digest_comment(text: str) -> str:
|
|
101
|
+
"""Remove a leading ``<!-- source-sha256: <hex> -->\\n`` line if
|
|
102
|
+
present. Used so ``slim_markdown`` and ``render_html`` can be called
|
|
103
|
+
on their own previously-stripped output without producing a
|
|
104
|
+
different digest the second time around."""
|
|
105
|
+
head, _, rest = text.partition("\n")
|
|
106
|
+
if _SOURCE_DIGEST_COMMENT_RE.match(head):
|
|
107
|
+
return rest
|
|
108
|
+
return text
|
|
109
|
+
|
|
110
|
+
from .clarification_items import (
|
|
111
|
+
_is_separator_row,
|
|
112
|
+
_section_5_slice,
|
|
113
|
+
_split_pipe_row,
|
|
114
|
+
parse_clarification_items,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Preserved substrings (single source of truth — keep in sync with
|
|
120
|
+
# validators/validate-run.py).
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
|
|
123
|
+
PRESERVED_SUBSTRINGS_PLANNING: tuple[str, ...] = (
|
|
124
|
+
"Option Candidates",
|
|
125
|
+
"Trade-off",
|
|
126
|
+
"Recommended Option",
|
|
127
|
+
"Stepwise Execution Order",
|
|
128
|
+
"Dependency",
|
|
129
|
+
"Validation Checklist",
|
|
130
|
+
"Rollback",
|
|
131
|
+
"User Approval Request",
|
|
132
|
+
"Plan Body Verification",
|
|
133
|
+
"Gate result:",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
PRESERVED_SUBSTRINGS_IMPLEMENTATION: tuple[str, ...] = (
|
|
137
|
+
"Approved Plan Reference",
|
|
138
|
+
"Commit List",
|
|
139
|
+
"Diff Summary",
|
|
140
|
+
"Out-of-plan Edits",
|
|
141
|
+
"Validation Evidence",
|
|
142
|
+
"Verifier Results",
|
|
143
|
+
"Rollback Verification",
|
|
144
|
+
"Routing Recommendation",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
PRESERVED_SUBSTRINGS_FINAL_VERIFICATION: tuple[str, ...] = (
|
|
148
|
+
"Source Implementation Report",
|
|
149
|
+
"Acceptance Blockers",
|
|
150
|
+
"Residual Risk",
|
|
151
|
+
"Validation Evidence",
|
|
152
|
+
"Read-only Command Log",
|
|
153
|
+
"Routing Recommendation",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
PRESERVED_SUBSTRINGS_RELEASE_HANDOFF: tuple[str, ...] = (
|
|
157
|
+
"Source Verification Report",
|
|
158
|
+
"Feature Branch & Working-Tree State",
|
|
159
|
+
"User Selections",
|
|
160
|
+
"Executed Commands",
|
|
161
|
+
"Commit List",
|
|
162
|
+
"Merge Conflict Probe",
|
|
163
|
+
"Pull Request Outcome",
|
|
164
|
+
"Routing Recommendation",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
PRESERVED_HEADINGS_ALWAYS: tuple[str, ...] = (
|
|
168
|
+
"## Verdict Card",
|
|
169
|
+
"## 0. Clarification Response Carried In",
|
|
170
|
+
"## 2. Final Verdict",
|
|
171
|
+
"## 5. Clarification Items",
|
|
172
|
+
"## 6. Recommended Next Steps",
|
|
173
|
+
"## 7. Follow-up Tasks",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def preserved_substrings(task_type: Optional[str]) -> tuple[str, ...]:
|
|
178
|
+
"""Return every substring slim_markdown MUST keep byte-identical."""
|
|
179
|
+
extras: tuple[str, ...] = ()
|
|
180
|
+
if task_type == "implementation-planning":
|
|
181
|
+
extras = PRESERVED_SUBSTRINGS_PLANNING
|
|
182
|
+
elif task_type == "implementation":
|
|
183
|
+
extras = PRESERVED_SUBSTRINGS_IMPLEMENTATION
|
|
184
|
+
elif task_type == "final-verification":
|
|
185
|
+
extras = PRESERVED_SUBSTRINGS_FINAL_VERIFICATION
|
|
186
|
+
elif task_type == "release-handoff":
|
|
187
|
+
extras = PRESERVED_SUBSTRINGS_RELEASE_HANDOFF
|
|
188
|
+
return PRESERVED_HEADINGS_ALWAYS + extras
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
# Slim stripper
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
|
|
195
|
+
_HR_PATTERN = re.compile(r"^-{3,}\s*$")
|
|
196
|
+
_BLANK_PATTERN = re.compile(r"^\s*$")
|
|
197
|
+
_RECIPE_QUOTE_PATTERN = re.compile(r"^>\s*\*\*읽는 법\*\*")
|
|
198
|
+
_CLI_COST_ROW_HINT = "Codex/Gemini CLI 추가 비용"
|
|
199
|
+
# Verdict details heading historically lived under ``### 4.5.9 Verdict
|
|
200
|
+
# details`` and is now (post-c2c603d split) written as ``#### Verdict
|
|
201
|
+
# details`` directly under §4.5.9 Plan Body Verification. Match both
|
|
202
|
+
# forms so the slim stripper drops the wide matrix body regardless of
|
|
203
|
+
# which template the report was generated against. The trailing ``$``
|
|
204
|
+
# anchor avoids accidentally matching ``Verdict details (excerpt)`` etc.
|
|
205
|
+
_VERDICT_DETAIL_RE = re.compile(
|
|
206
|
+
r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict details\s*$"
|
|
207
|
+
)
|
|
208
|
+
_VERDICT_SUMMARY_RE = re.compile(
|
|
209
|
+
r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict summary\s*$"
|
|
210
|
+
)
|
|
211
|
+
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+\S")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def slim_markdown(src_md: str, *, task_type: Optional[str] = None) -> str:
|
|
215
|
+
"""Return a slimmed copy of ``src_md`` per the rules in the module
|
|
216
|
+
docstring. Deterministic — same input always yields the same output.
|
|
217
|
+
|
|
218
|
+
Idempotency note: the returned slim begins with a
|
|
219
|
+
``<!-- source-sha256: ... -->`` comment so the validator can detect
|
|
220
|
+
a stale slim against a later MD. Re-running slim on its own output
|
|
221
|
+
must produce a byte-identical result, so we first strip the leading
|
|
222
|
+
digest comment (if any) before re-deriving everything from the
|
|
223
|
+
normalized body.
|
|
224
|
+
"""
|
|
225
|
+
src_md = _strip_leading_digest_comment(src_md)
|
|
226
|
+
lines = src_md.splitlines()
|
|
227
|
+
out: list[str] = []
|
|
228
|
+
|
|
229
|
+
hr_run = 0
|
|
230
|
+
blank_run = 0
|
|
231
|
+
in_verdict_detail = False # inside the §4.5.9 Verdict details wide matrix
|
|
232
|
+
|
|
233
|
+
for line in lines:
|
|
234
|
+
# STRIP_VERDICT_DETAIL — drop body of Verdict details (both
|
|
235
|
+
# ``### 4.5.9 Verdict details`` and ``#### Verdict details``
|
|
236
|
+
# forms), keep the heading line itself.
|
|
237
|
+
if _VERDICT_DETAIL_RE.match(line):
|
|
238
|
+
in_verdict_detail = True
|
|
239
|
+
out.append(line)
|
|
240
|
+
continue
|
|
241
|
+
if in_verdict_detail:
|
|
242
|
+
if _ANY_HEADING_RE.match(line):
|
|
243
|
+
in_verdict_detail = False
|
|
244
|
+
# fall through and emit this heading
|
|
245
|
+
else:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# STRIP_INTRO_RECIPE — drop the "> **읽는 법**:" quote paragraph.
|
|
249
|
+
if _RECIPE_QUOTE_PATTERN.match(line):
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# STRIP_TOKEN_PLACEHOLDER_ZEROS — drop $0.00 rows except CLI cost.
|
|
253
|
+
if _is_zero_cost_row(line) and _CLI_COST_ROW_HINT not in line:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# STRIP_DECORATIVE_HR — collapse runs of horizontal rules.
|
|
257
|
+
if _HR_PATTERN.match(line):
|
|
258
|
+
hr_run += 1
|
|
259
|
+
if hr_run > 1:
|
|
260
|
+
continue
|
|
261
|
+
else:
|
|
262
|
+
hr_run = 0
|
|
263
|
+
|
|
264
|
+
# COLLAPSE_BLANK_LINES — collapse 3+ blank lines to one.
|
|
265
|
+
if _BLANK_PATTERN.match(line):
|
|
266
|
+
blank_run += 1
|
|
267
|
+
if blank_run > 1:
|
|
268
|
+
continue
|
|
269
|
+
else:
|
|
270
|
+
blank_run = 0
|
|
271
|
+
|
|
272
|
+
out.append(line)
|
|
273
|
+
|
|
274
|
+
result = "\n".join(out)
|
|
275
|
+
# STRIP_DECORATIVE_HR (post-process) — collapse runs of HRs that
|
|
276
|
+
# were separated only by blank lines (the line scanner above can't
|
|
277
|
+
# reach across blanks without losing blank-line semantics elsewhere).
|
|
278
|
+
result = re.sub(r"(?:^---\s*\n(?:\s*\n)*){2,}", "---\n", result, flags=re.MULTILINE)
|
|
279
|
+
if src_md.endswith("\n") and not result.endswith("\n"):
|
|
280
|
+
result += "\n"
|
|
281
|
+
|
|
282
|
+
_assert_preserved(src_md, result, task_type)
|
|
283
|
+
# Prepend the source-digest comment so the validator can detect a
|
|
284
|
+
# stale slim that was generated from an older MD. The digest is
|
|
285
|
+
# over the slim BODY itself, not over the original MD, so the
|
|
286
|
+
# validator's expected-slim recomputation embeds the same digest
|
|
287
|
+
# (any byte difference between expected and actual body shows up
|
|
288
|
+
# as a mismatched digest).
|
|
289
|
+
digest_line = f"<!-- source-sha256: {source_digest(result)} -->\n"
|
|
290
|
+
return digest_line + result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _is_zero_cost_row(line: str) -> bool:
|
|
294
|
+
"""True iff this is a markdown table row whose cost cell is exactly
|
|
295
|
+
``$0.00`` and no other meaningful cell content suggests audit
|
|
296
|
+
relevance.
|
|
297
|
+
"""
|
|
298
|
+
if not line.lstrip().startswith("|"):
|
|
299
|
+
return False
|
|
300
|
+
if "$0.00" not in line:
|
|
301
|
+
return False
|
|
302
|
+
# Heuristic — defer to the CLI-cost guard at the caller.
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _assert_preserved(original: str, slim: str, task_type: Optional[str]) -> None:
|
|
307
|
+
"""Raise AssertionError if any preserved substring was dropped."""
|
|
308
|
+
missing = [s for s in preserved_substrings(task_type) if s in original and s not in slim]
|
|
309
|
+
if missing:
|
|
310
|
+
raise AssertionError(
|
|
311
|
+
"slim_markdown dropped preserved substring(s): " + ", ".join(missing)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --------------------------------------------------------------------------- #
|
|
316
|
+
# HTML renderer — minimal markdown-to-HTML converter (no external deps)
|
|
317
|
+
# --------------------------------------------------------------------------- #
|
|
318
|
+
|
|
319
|
+
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
320
|
+
_CODEFENCE_PATTERN = re.compile(r"^```(.*)$")
|
|
321
|
+
_LIST_BULLET_PATTERN = re.compile(r"^(\s*)[-*]\s+(.*)$")
|
|
322
|
+
_LIST_NUMBERED_PATTERN = re.compile(r"^(\s*)(\d+)\.\s+(.*)$")
|
|
323
|
+
_INLINE_CODE_PATTERN = re.compile(r"`([^`]+)`")
|
|
324
|
+
_BOLD_PATTERN = re.compile(r"\*\*([^*]+)\*\*")
|
|
325
|
+
_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
326
|
+
|
|
327
|
+
# Sections whose Response-ID-bearing rows must NOT get form attachment
|
|
328
|
+
# (read-only deliverables — see plan §1.4).
|
|
329
|
+
_NO_FORM_SECTION_PREFIXES = ("## 4.6", "### 4.6", "## 4.7", "### 4.7", "## 4.8", "### 4.8")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class RunMeta:
|
|
334
|
+
task_key: str
|
|
335
|
+
task_type: str
|
|
336
|
+
seq: str
|
|
337
|
+
source_report: str # relative path of the .md the HTML is derived from
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
|
|
341
|
+
"""Return a single self-contained HTML document for ``src_md``.
|
|
342
|
+
|
|
343
|
+
``css`` / ``js`` are inlined verbatim. No external URLs are written
|
|
344
|
+
into the document (validator enforces this — see
|
|
345
|
+
validate-report-views.py).
|
|
346
|
+
"""
|
|
347
|
+
# Normalise the input so a digest-bearing leading comment from a
|
|
348
|
+
# previous slim cycle doesn't leak into the rendered HTML body, then
|
|
349
|
+
# compute the digest over the normalised MD body. The validator
|
|
350
|
+
# recomputes the same digest from the current MD and compares it to
|
|
351
|
+
# the value embedded in run-meta below.
|
|
352
|
+
src_md = _strip_leading_digest_comment(src_md)
|
|
353
|
+
body_html = _markdown_to_html(src_md)
|
|
354
|
+
response_ids = sorted({item.row_id for item in (parse_clarification_items(src_md) or [])})
|
|
355
|
+
digest = source_digest(src_md)
|
|
356
|
+
|
|
357
|
+
title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
|
|
358
|
+
response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
f"<!DOCTYPE html>\n"
|
|
362
|
+
f"<html lang=\"ko\">\n"
|
|
363
|
+
f"<head>\n"
|
|
364
|
+
f"<meta charset=\"utf-8\">\n"
|
|
365
|
+
f"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
|
|
366
|
+
f"<title>{title}</title>\n"
|
|
367
|
+
f"<style>{css}</style>\n"
|
|
368
|
+
f"</head>\n"
|
|
369
|
+
f"<body>\n"
|
|
370
|
+
f"<header class=\"report-header\">\n"
|
|
371
|
+
f" <div>{title}</div>\n"
|
|
372
|
+
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
373
|
+
f"</header>\n"
|
|
374
|
+
f"<main>{body_html}</main>\n"
|
|
375
|
+
f"<footer class=\"report-footer\">\n"
|
|
376
|
+
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
377
|
+
f" <pre id=\"user-response-output\" aria-live=\"polite\"></pre>\n"
|
|
378
|
+
f" <button type=\"button\" data-action=\"copy-user-response\">Copy</button>\n"
|
|
379
|
+
f"</footer>\n"
|
|
380
|
+
f"<script id=\"run-meta\" type=\"application/json\">"
|
|
381
|
+
f"{{\"task-key\":\"{html.escape(run_meta.task_key)}\","
|
|
382
|
+
f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
|
|
383
|
+
f"\"seq\":\"{html.escape(run_meta.seq)}\","
|
|
384
|
+
f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
|
|
385
|
+
f"\"source-sha256\":\"{digest}\","
|
|
386
|
+
f"\"response-ids\":{response_ids_json}}}"
|
|
387
|
+
f"</script>\n"
|
|
388
|
+
f"<script>{js}</script>\n"
|
|
389
|
+
f"</body>\n"
|
|
390
|
+
f"</html>\n"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _markdown_to_html(src_md: str) -> str:
|
|
395
|
+
"""Tiny line-based markdown→HTML emitter. Handles only what the
|
|
396
|
+
final-report template uses: headings, paragraphs, pipe tables,
|
|
397
|
+
fenced code blocks, blockquotes, ordered+unordered lists, inline
|
|
398
|
+
code/bold/links. Anything outside that surface is passed through
|
|
399
|
+
as escaped text inside a paragraph — there are no extension points.
|
|
400
|
+
"""
|
|
401
|
+
lines = src_md.splitlines()
|
|
402
|
+
out: list[str] = []
|
|
403
|
+
i = 0
|
|
404
|
+
n = len(lines)
|
|
405
|
+
current_section_path: list[str] = [] # ['## 5. ...', '### 5.1 ...'] etc.
|
|
406
|
+
|
|
407
|
+
while i < n:
|
|
408
|
+
line = lines[i]
|
|
409
|
+
|
|
410
|
+
m_heading = _HEADING_PATTERN.match(line)
|
|
411
|
+
if m_heading:
|
|
412
|
+
level = len(m_heading.group(1))
|
|
413
|
+
text = m_heading.group(2)
|
|
414
|
+
slug = _slugify(text)
|
|
415
|
+
current_section_path = _update_section_path(current_section_path, level, line)
|
|
416
|
+
out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
|
|
417
|
+
i += 1
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
m_code = _CODEFENCE_PATTERN.match(line)
|
|
421
|
+
if m_code:
|
|
422
|
+
lang = m_code.group(1).strip()
|
|
423
|
+
code_lines: list[str] = []
|
|
424
|
+
i += 1
|
|
425
|
+
while i < n and not _CODEFENCE_PATTERN.match(lines[i]):
|
|
426
|
+
code_lines.append(lines[i])
|
|
427
|
+
i += 1
|
|
428
|
+
if i < n:
|
|
429
|
+
i += 1
|
|
430
|
+
cls = f' class="lang-{html.escape(lang)}"' if lang else ""
|
|
431
|
+
code = html.escape("\n".join(code_lines))
|
|
432
|
+
out.append(f"<pre><code{cls}>{code}</code></pre>")
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
if line.lstrip().startswith("|") and i + 1 < n and _is_separator_row(lines[i + 1]):
|
|
436
|
+
table_html, consumed = _emit_table(lines, i, current_section_path)
|
|
437
|
+
out.append(table_html)
|
|
438
|
+
i += consumed
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
if line.startswith(">"):
|
|
442
|
+
quote_lines: list[str] = []
|
|
443
|
+
while i < n and lines[i].startswith(">"):
|
|
444
|
+
quote_lines.append(lines[i][1:].lstrip())
|
|
445
|
+
i += 1
|
|
446
|
+
out.append("<blockquote>" + _inline(" ".join(quote_lines)) + "</blockquote>")
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
if _LIST_BULLET_PATTERN.match(line) or _LIST_NUMBERED_PATTERN.match(line):
|
|
450
|
+
list_html, consumed = _emit_list(lines, i)
|
|
451
|
+
out.append(list_html)
|
|
452
|
+
i += consumed
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if _BLANK_PATTERN.match(line):
|
|
456
|
+
i += 1
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# Plain paragraph — collect until next blank line or block element.
|
|
460
|
+
para_lines: list[str] = []
|
|
461
|
+
while i < n and not _BLANK_PATTERN.match(lines[i]):
|
|
462
|
+
ln = lines[i]
|
|
463
|
+
if (
|
|
464
|
+
_HEADING_PATTERN.match(ln)
|
|
465
|
+
or _CODEFENCE_PATTERN.match(ln)
|
|
466
|
+
or ln.lstrip().startswith("|")
|
|
467
|
+
or ln.startswith(">")
|
|
468
|
+
):
|
|
469
|
+
break
|
|
470
|
+
para_lines.append(ln)
|
|
471
|
+
i += 1
|
|
472
|
+
if para_lines:
|
|
473
|
+
out.append("<p>" + _inline(" ".join(para_lines)) + "</p>")
|
|
474
|
+
|
|
475
|
+
return "\n".join(out)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _update_section_path(path: list[str], level: int, line: str) -> list[str]:
|
|
479
|
+
while path and _heading_level(path[-1]) >= level:
|
|
480
|
+
path.pop()
|
|
481
|
+
path.append(line.strip())
|
|
482
|
+
return path
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _heading_level(heading_line: str) -> int:
|
|
486
|
+
m = _HEADING_PATTERN.match(heading_line)
|
|
487
|
+
return len(m.group(1)) if m else 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _section_forbids_form(path: Iterable[str]) -> bool:
|
|
491
|
+
return any(any(h.startswith(p) for p in _NO_FORM_SECTION_PREFIXES) for h in path)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[str, int]:
|
|
495
|
+
header_cells = _split_pipe_row(lines[start])
|
|
496
|
+
rows: list[list[str]] = []
|
|
497
|
+
i = start + 2 # skip header + separator
|
|
498
|
+
while i < len(lines) and lines[i].lstrip().startswith("|"):
|
|
499
|
+
rows.append(_split_pipe_row(lines[i]))
|
|
500
|
+
i += 1
|
|
501
|
+
|
|
502
|
+
is_clarification_table = (
|
|
503
|
+
not _section_forbids_form(section_path)
|
|
504
|
+
and any("Clarification Items" in h for h in section_path)
|
|
505
|
+
and "ID" in header_cells
|
|
506
|
+
and "Blocks" in header_cells
|
|
507
|
+
and "Status" in header_cells
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
head = (
|
|
511
|
+
"<thead><tr>"
|
|
512
|
+
+ "".join(f"<th>{_inline(c)}</th>" for c in header_cells)
|
|
513
|
+
+ "</tr></thead>"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
body_rows: list[str] = []
|
|
517
|
+
id_col = header_cells.index("ID") if "ID" in header_cells else -1
|
|
518
|
+
kind_col = header_cells.index("Kind") if "Kind" in header_cells else -1
|
|
519
|
+
status_col = header_cells.index("Status") if "Status" in header_cells else -1
|
|
520
|
+
user_input_col = (
|
|
521
|
+
header_cells.index("User input") if "User input" in header_cells else -1
|
|
522
|
+
)
|
|
523
|
+
for row in rows:
|
|
524
|
+
if (
|
|
525
|
+
is_clarification_table
|
|
526
|
+
and id_col >= 0
|
|
527
|
+
and id_col < len(row)
|
|
528
|
+
and re.fullmatch(r"C-\d+", row[id_col])
|
|
529
|
+
and user_input_col >= 0
|
|
530
|
+
):
|
|
531
|
+
response_id = row[id_col]
|
|
532
|
+
kind = row[kind_col] if 0 <= kind_col < len(row) else ""
|
|
533
|
+
status = row[status_col] if 0 <= status_col < len(row) else ""
|
|
534
|
+
cells_html: list[str] = []
|
|
535
|
+
for idx, cell in enumerate(row):
|
|
536
|
+
if idx == user_input_col:
|
|
537
|
+
cells_html.append(
|
|
538
|
+
f"<td>{_form_control(response_id, kind, status, cell)}</td>"
|
|
539
|
+
)
|
|
540
|
+
else:
|
|
541
|
+
cells_html.append(f"<td>{_inline(cell)}</td>")
|
|
542
|
+
body_rows.append(
|
|
543
|
+
f'<tr data-response-id="{html.escape(response_id)}" '
|
|
544
|
+
f'data-kind="{html.escape(kind)}" '
|
|
545
|
+
f'data-status="{html.escape(status)}">'
|
|
546
|
+
+ "".join(cells_html)
|
|
547
|
+
+ "</tr>"
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
body_rows.append(
|
|
551
|
+
"<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in row) + "</tr>"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
|
555
|
+
return f"<table>{head}{body}</table>", i - start
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _form_control(response_id: str, kind: str, status: str, current_value: str) -> str:
|
|
559
|
+
rid = html.escape(response_id)
|
|
560
|
+
disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
|
|
561
|
+
safe_value = html.escape(current_value or "")
|
|
562
|
+
placeholder = {
|
|
563
|
+
"material": "파일 경로 또는 본문",
|
|
564
|
+
"decision": "선택 또는 짧은 응답",
|
|
565
|
+
"data-point": "값",
|
|
566
|
+
}.get(kind.lower(), "응답")
|
|
567
|
+
return (
|
|
568
|
+
f'<textarea name="{rid}" data-response-id="{rid}" '
|
|
569
|
+
f'data-kind="{html.escape(kind)}" rows="2" '
|
|
570
|
+
f'placeholder="{html.escape(placeholder)}"{disabled}>'
|
|
571
|
+
f"{safe_value}</textarea>"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
|
|
576
|
+
i = start
|
|
577
|
+
first = lines[start]
|
|
578
|
+
is_numbered = bool(_LIST_NUMBERED_PATTERN.match(first))
|
|
579
|
+
tag = "ol" if is_numbered else "ul"
|
|
580
|
+
items: list[str] = []
|
|
581
|
+
while i < len(lines):
|
|
582
|
+
ln = lines[i]
|
|
583
|
+
m_b = _LIST_BULLET_PATTERN.match(ln) if not is_numbered else None
|
|
584
|
+
m_n = _LIST_NUMBERED_PATTERN.match(ln) if is_numbered else None
|
|
585
|
+
if not (m_b or m_n):
|
|
586
|
+
if _BLANK_PATTERN.match(ln):
|
|
587
|
+
# Blank inside a list — peek; if next line continues the
|
|
588
|
+
# list, swallow the blank, else end.
|
|
589
|
+
if i + 1 < len(lines) and (
|
|
590
|
+
_LIST_BULLET_PATTERN.match(lines[i + 1])
|
|
591
|
+
or _LIST_NUMBERED_PATTERN.match(lines[i + 1])
|
|
592
|
+
):
|
|
593
|
+
i += 1
|
|
594
|
+
continue
|
|
595
|
+
break
|
|
596
|
+
text = m_b.group(2) if m_b else m_n.group(3)
|
|
597
|
+
items.append(f"<li>{_inline(text)}</li>")
|
|
598
|
+
i += 1
|
|
599
|
+
return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _inline(text: str) -> str:
|
|
603
|
+
out = html.escape(text)
|
|
604
|
+
# Restore inline markdown after escaping (we re-process the escaped
|
|
605
|
+
# forms — backticks/asterisks/brackets survive html.escape).
|
|
606
|
+
out = _INLINE_CODE_PATTERN.sub(lambda m: f"<code>{m.group(1)}</code>", out)
|
|
607
|
+
out = _BOLD_PATTERN.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
|
|
608
|
+
out = _LINK_PATTERN.sub(
|
|
609
|
+
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out
|
|
610
|
+
)
|
|
611
|
+
return out
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _slugify(text: str) -> str:
|
|
615
|
+
s = text.strip().lower()
|
|
616
|
+
s = re.sub(r"[^a-z0-9ㄱ-힝\-\.\s]", "", s)
|
|
617
|
+
s = re.sub(r"\s+", "-", s)
|
|
618
|
+
return s or "section"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# --------------------------------------------------------------------------- #
|
|
622
|
+
# User-response sidecar serialiser (reference for the validator;
|
|
623
|
+
# templates/reports/report.js implements the byte-identical client side).
|
|
624
|
+
# --------------------------------------------------------------------------- #
|
|
625
|
+
|
|
626
|
+
@dataclass(frozen=True)
|
|
627
|
+
class UserResponseEntry:
|
|
628
|
+
response_id: str
|
|
629
|
+
kind: str
|
|
630
|
+
value: str
|
|
631
|
+
rationale: Optional[str] = None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def serialize_user_response(
|
|
635
|
+
*,
|
|
636
|
+
run_meta: RunMeta,
|
|
637
|
+
entries: list[UserResponseEntry],
|
|
638
|
+
created_at: str,
|
|
639
|
+
) -> str:
|
|
640
|
+
"""Return the canonical markdown text the HTML 'Export user
|
|
641
|
+
response' button must produce. Used by validators to confirm that
|
|
642
|
+
pasted sidecar files conform to the schema and that the JS
|
|
643
|
+
serialiser stayed in sync.
|
|
644
|
+
"""
|
|
645
|
+
head = (
|
|
646
|
+
"---\n"
|
|
647
|
+
f"task-key: {run_meta.task_key}\n"
|
|
648
|
+
f"task-type: {run_meta.task_type}\n"
|
|
649
|
+
f"seq: {run_meta.seq}\n"
|
|
650
|
+
f"source-report: {run_meta.source_report}\n"
|
|
651
|
+
"created-by: user\n"
|
|
652
|
+
f"created-at: {created_at}\n"
|
|
653
|
+
"---\n"
|
|
654
|
+
"\n"
|
|
655
|
+
"# User Response\n"
|
|
656
|
+
)
|
|
657
|
+
body_chunks: list[str] = []
|
|
658
|
+
for e in entries:
|
|
659
|
+
chunk = (
|
|
660
|
+
f"\n## {e.response_id}\n"
|
|
661
|
+
f"- Kind: {e.kind}\n"
|
|
662
|
+
"- Value:\n"
|
|
663
|
+
f" > {e.value.strip()}\n"
|
|
664
|
+
)
|
|
665
|
+
if e.rationale:
|
|
666
|
+
chunk += f"- Rationale: {e.rationale.strip()}\n"
|
|
667
|
+
body_chunks.append(chunk)
|
|
668
|
+
if not entries:
|
|
669
|
+
body_chunks.append("\n_(No user responses recorded.)_\n")
|
|
670
|
+
return head + "".join(body_chunks)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# --------------------------------------------------------------------------- #
|
|
674
|
+
# Convenience entrypoint for tests + CLI.
|
|
675
|
+
# --------------------------------------------------------------------------- #
|
|
676
|
+
|
|
677
|
+
def render_both_views(
|
|
678
|
+
src_md_path: Path,
|
|
679
|
+
*,
|
|
680
|
+
run_meta: RunMeta,
|
|
681
|
+
css: str,
|
|
682
|
+
js: str,
|
|
683
|
+
) -> tuple[Path, Path]:
|
|
684
|
+
"""Write ``*.slim.md`` and ``*.html`` next to ``src_md_path`` and
|
|
685
|
+
return their paths. Idempotent."""
|
|
686
|
+
src_text = src_md_path.read_text(encoding="utf-8")
|
|
687
|
+
|
|
688
|
+
slim_path = src_md_path.with_suffix(".slim.md")
|
|
689
|
+
if src_md_path.suffix == ".md":
|
|
690
|
+
# ``with_suffix`` replaces the LAST suffix only; we want
|
|
691
|
+
# ``foo.md`` → ``foo.slim.md`` (insert before ``.md``).
|
|
692
|
+
slim_path = src_md_path.with_name(src_md_path.stem + ".slim.md")
|
|
693
|
+
|
|
694
|
+
html_path = src_md_path.with_name(src_md_path.stem + ".html")
|
|
695
|
+
|
|
696
|
+
slim_text = slim_markdown(src_text, task_type=run_meta.task_type)
|
|
697
|
+
html_text = render_html(src_text, run_meta=run_meta, css=css, js=js)
|
|
698
|
+
|
|
699
|
+
slim_path.write_text(slim_text, encoding="utf-8")
|
|
700
|
+
html_path.write_text(html_text, encoding="utf-8")
|
|
701
|
+
return slim_path, html_path
|