okstra 0.27.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.
Files changed (28) hide show
  1. package/bin/okstra +1 -0
  2. package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +167 -0
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/workers/claude-worker.md +6 -5
  6. package/runtime/agents/workers/codex-worker.md +5 -4
  7. package/runtime/agents/workers/gemini-worker.md +5 -4
  8. package/runtime/agents/workers/report-writer-worker.md +10 -3
  9. package/runtime/bin/okstra-render-report-views.py +129 -0
  10. package/runtime/prompts/launch.template.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +12 -4
  12. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  13. package/runtime/python/okstra_ctl/report_views.py +701 -0
  14. package/runtime/python/okstra_token_usage/cli.py +9 -2
  15. package/runtime/python/okstra_token_usage/report.py +32 -3
  16. package/runtime/skills/okstra-convergence/SKILL.md +2 -2
  17. package/runtime/skills/okstra-report-writer/SKILL.md +25 -8
  18. package/runtime/skills/okstra-team-contract/SKILL.md +16 -15
  19. package/runtime/templates/reports/final-report.template.md +398 -211
  20. package/runtime/templates/reports/report.css +151 -0
  21. package/runtime/templates/reports/report.js +163 -0
  22. package/runtime/templates/reports/user-response.template.md +69 -0
  23. package/runtime/validators/lib/fixtures.sh +76 -2
  24. package/runtime/validators/validate-report-views.py +283 -0
  25. package/runtime/validators/validate-run.py +564 -4
  26. package/runtime/validators/validate-workflow.sh +4 -0
  27. package/src/install.mjs +1 -0
  28. 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