okstra 0.30.3 → 0.32.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 (69) hide show
  1. package/docs/kr/architecture.md +2 -2
  2. package/docs/kr/cli.md +2 -2
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/SKILL.md +7 -5
  6. package/runtime/agents/workers/claude-worker.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +23 -6
  8. package/runtime/agents/workers/gemini-worker.md +23 -6
  9. package/runtime/agents/workers/report-writer-worker.md +45 -66
  10. package/runtime/bin/okstra-codex-exec.sh +31 -0
  11. package/runtime/bin/okstra-gemini-exec.sh +26 -0
  12. package/runtime/bin/okstra-render-final-report.py +101 -0
  13. package/runtime/bin/okstra-render-report-views.py +17 -10
  14. package/runtime/bin/okstra-token-usage.py +3 -1
  15. package/runtime/python/lib/okstra/globals.sh +1 -1
  16. package/runtime/python/lib/okstra/usage.sh +2 -2
  17. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  18. package/runtime/python/okstra_ctl/models.py +2 -0
  19. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  20. package/runtime/python/okstra_ctl/report_views.py +276 -297
  21. package/runtime/python/okstra_ctl/run.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +53 -14
  23. package/runtime/python/okstra_ctl/workers.py +45 -11
  24. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  25. package/runtime/python/okstra_token_usage/cli.py +66 -36
  26. package/runtime/python/okstra_token_usage/pricing.py +1 -0
  27. package/runtime/python/okstra_token_usage/report.py +148 -65
  28. package/runtime/python/okstra_vendor/__init__.py +37 -0
  29. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  30. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  31. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  32. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  33. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  34. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  35. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  36. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  37. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  38. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  39. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  40. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  41. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  42. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  43. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  44. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  45. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  46. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  47. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  48. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  49. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  50. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  51. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  52. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  53. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  54. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  55. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  56. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  57. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  58. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  59. package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
  60. package/runtime/skills/okstra-run/SKILL.md +6 -4
  61. package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
  62. package/runtime/templates/reports/final-report.template.md +370 -405
  63. package/runtime/templates/reports/report.css +57 -4
  64. package/runtime/templates/reports/report.js +63 -7
  65. package/runtime/templates/reports/settings.template.json +1 -0
  66. package/runtime/validators/lib/fixtures.sh +7 -7
  67. package/runtime/validators/validate-report-views.py +24 -153
  68. package/runtime/validators/validate-run.py +102 -19
  69. package/src/install.mjs +21 -1
@@ -1,11 +1,7 @@
1
- """Render two derived views of an okstra final-report markdown.
1
+ """Render the self-contained HTML view of an okstra final-report markdown.
2
2
 
3
- Two products, one source of truth:
3
+ Single product, single source of truth:
4
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
5
  * ``render_html(src_md, *, run_meta)`` — deterministic self-contained
10
6
  HTML renderer for human readers. Sections §5/§6/§7 user-actionable
11
7
  rows (those reachable from §5 ``C-*`` IDs) get embedded ``<form>``
@@ -18,44 +14,9 @@ response`` button (client-side JS, single-reference-point with the
18
14
  Python ``serialize_user_response`` function below) and the user pastes
19
15
  it to ``runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md``.
20
16
 
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.
17
+ The next-phase lead prompt now consumes the source MD directly — the
18
+ former ``*.slim.md`` derived artefact was removed (its strip rules
19
+ saved no tokens worth the contract maintenance cost).
59
20
  """
60
21
  from __future__ import annotations
61
22
 
@@ -68,27 +29,12 @@ from typing import Iterable, Optional
68
29
 
69
30
 
70
31
  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."""
32
+ """Stable identifier for a final-report markdown body. The HTML
33
+ view embeds this in its run-meta block so the validator can detect
34
+ a stale html that was generated from an older MD."""
74
35
  return hashlib.sha256(src_md.encode("utf-8")).hexdigest()
75
36
 
76
37
 
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
38
  _HTML_DIGEST_RE = re.compile(r'"source-sha256":"([0-9a-f]{64})"')
93
39
 
94
40
 
@@ -97,15 +43,17 @@ def extract_html_digest(html_text: str) -> Optional[str]:
97
43
  return m.group(1) if m else None
98
44
 
99
45
 
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
46
+ _LEADING_FRONTMATTER_RE = re.compile(
47
+ r"\A---\s*\n.*?\n---\s*\n?", re.DOTALL
48
+ )
49
+
50
+
51
+ def _strip_leading_frontmatter(text: str) -> str:
52
+ """Remove a leading YAML frontmatter block (``---\\n…\\n---\\n``) if
53
+ present. Used by ``render_html`` so the HTML view does not surface
54
+ the Obsidian-side frontmatter as a paragraph; the source MD keeps
55
+ the frontmatter for Obsidian/Templater consumers."""
56
+ return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
109
57
 
110
58
  from .clarification_items import (
111
59
  _is_separator_row,
@@ -116,205 +64,10 @@ from .clarification_items import (
116
64
 
117
65
 
118
66
  # --------------------------------------------------------------------------- #
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
67
+ # HTML renderer minimal markdown-to-HTML converter (no external deps)
193
68
  # --------------------------------------------------------------------------- #
194
69
 
195
- _HR_PATTERN = re.compile(r"^-{3,}\s*$")
196
70
  _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
71
 
319
72
  _HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
320
73
  _CODEFENCE_PATTERN = re.compile(r"^```(.*)$")
@@ -344,15 +97,14 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
344
97
  into the document (validator enforces this — see
345
98
  validate-report-views.py).
346
99
  """
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
100
+ # Compute the digest over the source MD body. The validator
350
101
  # recomputes the same digest from the current MD and compares it to
351
102
  # 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
103
  digest = source_digest(src_md)
104
+ body_md = _strip_leading_frontmatter(src_md)
105
+ body_html, toc_headings = _markdown_to_html(body_md)
106
+ body_html = _inject_toc(body_html, toc_headings)
107
+ response_ids = sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
356
108
 
357
109
  title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
358
110
  response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
@@ -391,15 +143,22 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
391
143
  )
392
144
 
393
145
 
394
- def _markdown_to_html(src_md: str) -> str:
146
+ def _markdown_to_html(
147
+ src_md: str,
148
+ ) -> tuple[str, list[tuple[int, str, str]]]:
395
149
  """Tiny line-based markdown→HTML emitter. Handles only what the
396
150
  final-report template uses: headings, paragraphs, pipe tables,
397
151
  fenced code blocks, blockquotes, ordered+unordered lists, inline
398
152
  code/bold/links. Anything outside that surface is passed through
399
153
  as escaped text inside a paragraph — there are no extension points.
154
+
155
+ Returns ``(body_html, toc_headings)`` where ``toc_headings`` is a
156
+ list of ``(level, slug, text)`` for every emitted heading in source
157
+ order. Callers use this to build a navigable table of contents.
400
158
  """
401
159
  lines = src_md.splitlines()
402
160
  out: list[str] = []
161
+ headings: list[tuple[int, str, str]] = []
403
162
  i = 0
404
163
  n = len(lines)
405
164
  current_section_path: list[str] = [] # ['## 5. ...', '### 5.1 ...'] etc.
@@ -414,6 +173,7 @@ def _markdown_to_html(src_md: str) -> str:
414
173
  slug = _slugify(text)
415
174
  current_section_path = _update_section_path(current_section_path, level, line)
416
175
  out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
176
+ headings.append((level, slug, text))
417
177
  i += 1
418
178
  continue
419
179
 
@@ -472,7 +232,48 @@ def _markdown_to_html(src_md: str) -> str:
472
232
  if para_lines:
473
233
  out.append("<p>" + _inline(" ".join(para_lines)) + "</p>")
474
234
 
475
- return "\n".join(out)
235
+ return "\n".join(out), headings
236
+
237
+
238
+ _INDEX_BLOCK_RE = re.compile(
239
+ r'<h2 id="index">Index</h2>\n<ul>.*?</ul>', re.DOTALL
240
+ )
241
+
242
+
243
+ def _build_toc(headings: list[tuple[int, str, str]]) -> str:
244
+ """Render a ``<nav class="toc">`` block from collected h2/h3 entries.
245
+ h1 (the report title) is omitted; h4+ are too granular for the TOC."""
246
+ items: list[str] = []
247
+ for level, slug, text in headings:
248
+ if level not in (2, 3):
249
+ continue
250
+ if slug == "index": # skip the source "Index" heading itself
251
+ continue
252
+ cls = "toc-h2" if level == 2 else "toc-h3"
253
+ items.append(
254
+ f'<li class="{cls}"><a href="#{slug}">{_inline(text)}</a></li>'
255
+ )
256
+ if not items:
257
+ return ""
258
+ return '<nav class="toc" aria-label="Table of contents">' \
259
+ + '<div class="toc-title">목차</div>' \
260
+ + "<ul>" + "".join(items) + "</ul>" \
261
+ + "</nav>"
262
+
263
+
264
+ def _inject_toc(body_html: str, headings: list[tuple[int, str, str]]) -> str:
265
+ """If the source had an ``## Index`` section followed by a bullet
266
+ list (the template's static TOC), replace it with the auto-built
267
+ nav. Otherwise prepend the nav at the top of the body so readers
268
+ still get a navigation aid. If no h2/h3 headings exist the body is
269
+ returned unchanged.
270
+ """
271
+ nav = _build_toc(headings)
272
+ if not nav:
273
+ return body_html
274
+ if _INDEX_BLOCK_RE.search(body_html):
275
+ return _INDEX_BLOCK_RE.sub(nav, body_html, count=1)
276
+ return nav + "\n" + body_html
476
277
 
477
278
 
478
279
  def _update_section_path(path: list[str], level: int, line: str) -> list[str]:
@@ -507,9 +308,20 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
507
308
  and "Status" in header_cells
508
309
  )
509
310
 
311
+ narrow_cols = _narrow_columns(header_cells, rows)
312
+ # `User input` carries the embedded form widget (textarea / select /
313
+ # input) which needs all the horizontal space it can get; its
314
+ # markdown plain text is empty or a short placeholder so the auto
315
+ # detector would otherwise flag it as narrow. Force it wide.
316
+ if "User input" in header_cells:
317
+ narrow_cols.discard(header_cells.index("User input"))
318
+
510
319
  head = (
511
320
  "<thead><tr>"
512
- + "".join(f"<th>{_inline(c)}</th>" for c in header_cells)
321
+ + "".join(
322
+ f"<th{_col_class(idx, narrow_cols)}>{_inline(c)}</th>"
323
+ for idx, c in enumerate(header_cells)
324
+ )
513
325
  + "</tr></thead>"
514
326
  )
515
327
 
@@ -517,6 +329,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
517
329
  id_col = header_cells.index("ID") if "ID" in header_cells else -1
518
330
  kind_col = header_cells.index("Kind") if "Kind" in header_cells else -1
519
331
  status_col = header_cells.index("Status") if "Status" in header_cells else -1
332
+ statement_col = next(
333
+ (i for i, h in enumerate(header_cells) if h.startswith("Statement")),
334
+ -1,
335
+ )
520
336
  user_input_col = (
521
337
  header_cells.index("User input") if "User input" in header_cells else -1
522
338
  )
@@ -531,14 +347,21 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
531
347
  response_id = row[id_col]
532
348
  kind = row[kind_col] if 0 <= kind_col < len(row) else ""
533
349
  status = row[status_col] if 0 <= status_col < len(row) else ""
350
+ statement = (
351
+ row[statement_col]
352
+ if 0 <= statement_col < len(row)
353
+ else ""
354
+ )
534
355
  cells_html: list[str] = []
535
356
  for idx, cell in enumerate(row):
536
357
  if idx == user_input_col:
537
358
  cells_html.append(
538
- f"<td>{_form_control(response_id, kind, status, cell)}</td>"
359
+ f"<td>{_form_control(response_id, kind, status, cell, statement)}</td>"
539
360
  )
540
361
  else:
541
- cells_html.append(f"<td>{_inline(cell)}</td>")
362
+ cells_html.append(
363
+ f"<td{_col_class(idx, narrow_cols)}>{_inline(cell)}</td>"
364
+ )
542
365
  body_rows.append(
543
366
  f'<tr data-response-id="{html.escape(response_id)}" '
544
367
  f'data-kind="{html.escape(kind)}" '
@@ -548,22 +371,109 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
548
371
  )
549
372
  else:
550
373
  body_rows.append(
551
- "<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in row) + "</tr>"
374
+ "<tr>"
375
+ + "".join(
376
+ f"<td{_col_class(idx, narrow_cols)}>{_inline(c)}</td>"
377
+ for idx, c in enumerate(row)
378
+ )
379
+ + "</tr>"
552
380
  )
553
381
 
554
382
  body = "<tbody>" + "".join(body_rows) + "</tbody>"
555
383
  return f"<table>{head}{body}</table>", i - start
556
384
 
557
385
 
558
- def _form_control(response_id: str, kind: str, status: str, current_value: str) -> str:
386
+ _ENUM_LETTERS = "abcde"
387
+ _ENUM_CUE_WORDS: tuple[str, ...] = (
388
+ "권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
389
+ )
390
+
391
+
392
+ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
393
+ """Return ``[(letter, text)]`` pairs if ``statement`` looks like a
394
+ ``(a) X (b) Y (c) Z`` enumeration, else ``[]``. Stops at the first
395
+ recommendation cue (``권장``/``추천``/``사유``/``근거``) so the
396
+ "(c) — recommended because ..." tail doesn't leak into the last
397
+ option's text. Requires ≥ 2 options to trigger select rendering.
398
+ """
399
+ if not statement:
400
+ return []
401
+ out: list[tuple[str, str]] = []
402
+ cursor = 0
403
+ for i, letter in enumerate(_ENUM_LETTERS):
404
+ anchor = f"({letter}) "
405
+ idx = statement.find(anchor, cursor)
406
+ if idx < 0:
407
+ break
408
+ start = idx + len(anchor)
409
+ end_candidates: list[int] = []
410
+ if i + 1 < len(_ENUM_LETTERS):
411
+ n_idx = statement.find(f"({_ENUM_LETTERS[i + 1]}) ", start)
412
+ if n_idx >= 0:
413
+ end_candidates.append(n_idx)
414
+ for cue in _ENUM_CUE_WORDS:
415
+ c_idx = statement.find(cue, start)
416
+ if c_idx >= 0:
417
+ end_candidates.append(c_idx)
418
+ end = min(end_candidates) if end_candidates else len(statement)
419
+ text = statement[start:end].strip(" .,—-")
420
+ if not text:
421
+ break
422
+ out.append((letter, text))
423
+ cursor = end
424
+ return out if len(out) >= 2 else []
425
+
426
+
427
+ def _form_control(
428
+ response_id: str,
429
+ kind: str,
430
+ status: str,
431
+ current_value: str,
432
+ statement: str = "",
433
+ ) -> str:
559
434
  rid = html.escape(response_id)
560
435
  disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
561
436
  safe_value = html.escape(current_value or "")
437
+ kind_lc = kind.lower()
562
438
  placeholder = {
563
439
  "material": "파일 경로 또는 본문",
564
440
  "decision": "선택 또는 짧은 응답",
565
441
  "data-point": "값",
566
- }.get(kind.lower(), "응답")
442
+ }.get(kind_lc, "응답")
443
+
444
+ # decision 인 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
445
+ if kind_lc == "decision":
446
+ opts = _parse_enum_options(statement)
447
+ if opts:
448
+ select_opts = "".join(
449
+ f'<option value="{letter}">'
450
+ f'{_inline(f"({letter}) " + text)}</option>'
451
+ for letter, text in opts
452
+ )
453
+ select_html = (
454
+ f'<select name="{rid}" data-response-id="{rid}" '
455
+ f'data-kind="{html.escape(kind)}"{disabled}>'
456
+ '<option value="">(선택)</option>'
457
+ f"{select_opts}"
458
+ '<option value="__other__">기타 (직접 입력)</option>'
459
+ "</select>"
460
+ )
461
+ other_html = (
462
+ f' <input type="text" data-other-for="{rid}" '
463
+ f'placeholder="기타 응답"{disabled} hidden value="{safe_value}">'
464
+ )
465
+ return select_html + other_html
466
+
467
+ # material/data-point 류는 짧은 단일값. 텍스트 인풋으로.
468
+ if kind_lc in ("material", "data-point"):
469
+ return (
470
+ f'<input type="text" name="{rid}" data-response-id="{rid}" '
471
+ f'data-kind="{html.escape(kind)}" '
472
+ f'placeholder="{html.escape(placeholder)}"{disabled} '
473
+ f'value="{safe_value}">'
474
+ )
475
+
476
+ # 그 외 (decision-without-enum 포함) — 자유 본문은 textarea 유지.
567
477
  return (
568
478
  f'<textarea name="{rid}" data-response-id="{rid}" '
569
479
  f'data-kind="{html.escape(kind)}" rows="2" '
@@ -599,6 +509,86 @@ def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
599
509
  return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start
600
510
 
601
511
 
512
+ _NARROW_COL_MAX_PLAIN_LEN = 30
513
+
514
+ _INLINE_MD_STRIP_RE = re.compile(r"[`*_]")
515
+
516
+ # Header-name whitelist: columns whose width is effectively fixed by
517
+ # convention even when one row carries a long body cell (e.g. "출처"
518
+ # starts with a short token but the prose body of a single citation
519
+ # row may run 60+ chars). Matched as case-insensitive prefix on the
520
+ # header's first plain token, so "Ticket ID" / "출처 (brief/source/worker)"
521
+ # / "환산 토큰 (input 기준)" all hit the same entry. Keep this list
522
+ # tight — every entry is a promise that the column is *always* one of
523
+ # a handful of canonical short values across every final-report.
524
+ _NARROW_HEADER_PREFIXES: tuple[str, ...] = (
525
+ # English / latin
526
+ "id",
527
+ "ticket id",
528
+ "kind",
529
+ "status",
530
+ "blocks",
531
+ "origin",
532
+ "priority",
533
+ "auto-spawn",
534
+ # Korean
535
+ "출처",
536
+ "에이전트",
537
+ "역할",
538
+ "모델",
539
+ "상태",
540
+ "항목",
541
+ "처리 토큰",
542
+ "환산 토큰",
543
+ "비용",
544
+ )
545
+
546
+
547
+ def _plain_len(raw_cell: str) -> int:
548
+ return len(_INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip())
549
+
550
+
551
+ def _matches_narrow_whitelist(header: str) -> bool:
552
+ plain = _INLINE_MD_STRIP_RE.sub("", header or "").strip().lower()
553
+ return any(plain.startswith(p) for p in _NARROW_HEADER_PREFIXES)
554
+
555
+
556
+ def _narrow_columns(
557
+ header_cells: list[str], rows: list[list[str]]
558
+ ) -> set[int]:
559
+ """Return the set of column indices that should render with the
560
+ fixed-narrow ``td-narrow`` width (5% under report.css).
561
+
562
+ A column qualifies when EITHER:
563
+ (a) its header matches `_NARROW_HEADER_PREFIXES` — these are
564
+ ``ID`` / ``출처`` / ``에이전트`` etc. whose values are
565
+ conventionally short across every final-report even when a
566
+ single row drops in 60+ chars of prose; OR
567
+ (b) every cell in the column (header + body) fits within
568
+ ``_NARROW_COL_MAX_PLAIN_LEN`` plain chars — this catches
569
+ ad-hoc short columns that the whitelist did not anticipate.
570
+
571
+ Per-column (not per-cell) so the verdict applies uniformly across
572
+ the column even when one neighbouring column holds long-form text.
573
+ """
574
+ narrow: set[int] = set()
575
+ for col, header in enumerate(header_cells):
576
+ if _matches_narrow_whitelist(header):
577
+ narrow.add(col)
578
+ continue
579
+ max_len = _plain_len(header)
580
+ for row in rows:
581
+ if col < len(row):
582
+ max_len = max(max_len, _plain_len(row[col]))
583
+ if max_len <= _NARROW_COL_MAX_PLAIN_LEN:
584
+ narrow.add(col)
585
+ return narrow
586
+
587
+
588
+ def _col_class(col_idx: int, narrow_cols: set[int]) -> str:
589
+ return ' class="td-narrow"' if col_idx in narrow_cols else ""
590
+
591
+
602
592
  def _inline(text: str) -> str:
603
593
  out = html.escape(text)
604
594
  # Restore inline markdown after escaping (we re-process the escaped
@@ -674,28 +664,17 @@ def serialize_user_response(
674
664
  # Convenience entrypoint for tests + CLI.
675
665
  # --------------------------------------------------------------------------- #
676
666
 
677
- def render_both_views(
667
+ def render_html_view(
678
668
  src_md_path: Path,
679
669
  *,
680
670
  run_meta: RunMeta,
681
671
  css: str,
682
672
  js: str,
683
- ) -> tuple[Path, Path]:
684
- """Write ``*.slim.md`` and ``*.html`` next to ``src_md_path`` and
685
- return their paths. Idempotent."""
673
+ ) -> Path:
674
+ """Write ``<stem>.html`` next to ``src_md_path`` and return its
675
+ path. Idempotent overwrites the existing html sibling."""
686
676
  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
677
  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
678
  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
679
  html_path.write_text(html_text, encoding="utf-8")
701
- return slim_path, html_path
680
+ return html_path