okstra 0.31.0 → 0.32.1

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 (52) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/agents/SKILL.md +3 -3
  4. package/runtime/agents/workers/report-writer-worker.md +45 -67
  5. package/runtime/bin/okstra-render-final-report.py +101 -0
  6. package/runtime/bin/okstra-render-report-views.py +17 -10
  7. package/runtime/bin/okstra-token-usage.py +3 -1
  8. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  9. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  10. package/runtime/python/okstra_ctl/report_views.py +108 -305
  11. package/runtime/python/okstra_ctl/wizard.py +16 -5
  12. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  13. package/runtime/python/okstra_token_usage/cli.py +66 -36
  14. package/runtime/python/okstra_token_usage/report.py +148 -65
  15. package/runtime/python/okstra_vendor/__init__.py +37 -0
  16. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  17. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  18. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  19. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  20. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  21. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  22. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  23. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  24. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  25. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  26. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  27. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  28. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  29. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  30. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  31. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  32. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  33. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  34. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  35. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  36. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  37. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  38. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  39. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  40. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  41. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  42. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  43. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  44. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  45. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  46. package/runtime/skills/okstra-report-writer/SKILL.md +29 -28
  47. package/runtime/templates/reports/final-report.template.md +370 -411
  48. package/runtime/templates/reports/report.css +12 -6
  49. package/runtime/validators/lib/fixtures.sh +7 -7
  50. package/runtime/validators/validate-report-views.py +24 -153
  51. package/runtime/validators/validate-run.py +102 -19
  52. package/src/install.mjs +20 -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,17 +43,6 @@ 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
109
-
110
-
111
46
  _LEADING_FRONTMATTER_RE = re.compile(
112
47
  r"\A---\s*\n.*?\n---\s*\n?", re.DOTALL
113
48
  )
@@ -116,8 +51,8 @@ _LEADING_FRONTMATTER_RE = re.compile(
116
51
  def _strip_leading_frontmatter(text: str) -> str:
117
52
  """Remove a leading YAML frontmatter block (``---\\n…\\n---\\n``) if
118
53
  present. Used by ``render_html`` so the HTML view does not surface
119
- the Obsidian-side frontmatter as a paragraph. The slim markdown
120
- keeps the frontmatter for Obsidian/Templater consumers."""
54
+ the Obsidian-side frontmatter as a paragraph; the source MD keeps
55
+ the frontmatter for Obsidian/Templater consumers."""
121
56
  return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
122
57
 
123
58
  from .clarification_items import (
@@ -129,205 +64,10 @@ from .clarification_items import (
129
64
 
130
65
 
131
66
  # --------------------------------------------------------------------------- #
132
- # Preserved substrings (single source of truth keep in sync with
133
- # validators/validate-run.py).
134
- # --------------------------------------------------------------------------- #
135
-
136
- PRESERVED_SUBSTRINGS_PLANNING: tuple[str, ...] = (
137
- "Option Candidates",
138
- "Trade-off",
139
- "Recommended Option",
140
- "Stepwise Execution Order",
141
- "Dependency",
142
- "Validation Checklist",
143
- "Rollback",
144
- "User Approval Request",
145
- "Plan Body Verification",
146
- "Gate result:",
147
- )
148
-
149
- PRESERVED_SUBSTRINGS_IMPLEMENTATION: tuple[str, ...] = (
150
- "Approved Plan Reference",
151
- "Commit List",
152
- "Diff Summary",
153
- "Out-of-plan Edits",
154
- "Validation Evidence",
155
- "Verifier Results",
156
- "Rollback Verification",
157
- "Routing Recommendation",
158
- )
159
-
160
- PRESERVED_SUBSTRINGS_FINAL_VERIFICATION: tuple[str, ...] = (
161
- "Source Implementation Report",
162
- "Acceptance Blockers",
163
- "Residual Risk",
164
- "Validation Evidence",
165
- "Read-only Command Log",
166
- "Routing Recommendation",
167
- )
168
-
169
- PRESERVED_SUBSTRINGS_RELEASE_HANDOFF: tuple[str, ...] = (
170
- "Source Verification Report",
171
- "Feature Branch & Working-Tree State",
172
- "User Selections",
173
- "Executed Commands",
174
- "Commit List",
175
- "Merge Conflict Probe",
176
- "Pull Request Outcome",
177
- "Routing Recommendation",
178
- )
179
-
180
- PRESERVED_HEADINGS_ALWAYS: tuple[str, ...] = (
181
- "## Verdict Card",
182
- "## 0. Clarification Response Carried In",
183
- "## 2. Final Verdict",
184
- "## 5. Clarification Items",
185
- "## 6. Recommended Next Steps",
186
- "## 7. Follow-up Tasks",
187
- )
188
-
189
-
190
- def preserved_substrings(task_type: Optional[str]) -> tuple[str, ...]:
191
- """Return every substring slim_markdown MUST keep byte-identical."""
192
- extras: tuple[str, ...] = ()
193
- if task_type == "implementation-planning":
194
- extras = PRESERVED_SUBSTRINGS_PLANNING
195
- elif task_type == "implementation":
196
- extras = PRESERVED_SUBSTRINGS_IMPLEMENTATION
197
- elif task_type == "final-verification":
198
- extras = PRESERVED_SUBSTRINGS_FINAL_VERIFICATION
199
- elif task_type == "release-handoff":
200
- extras = PRESERVED_SUBSTRINGS_RELEASE_HANDOFF
201
- return PRESERVED_HEADINGS_ALWAYS + extras
202
-
203
-
204
- # --------------------------------------------------------------------------- #
205
- # Slim stripper
67
+ # HTML renderer minimal markdown-to-HTML converter (no external deps)
206
68
  # --------------------------------------------------------------------------- #
207
69
 
208
- _HR_PATTERN = re.compile(r"^-{3,}\s*$")
209
70
  _BLANK_PATTERN = re.compile(r"^\s*$")
210
- _RECIPE_QUOTE_PATTERN = re.compile(r"^>\s*\*\*읽는 법\*\*")
211
- _CLI_COST_ROW_HINT = "Codex/Gemini CLI 추가 비용"
212
- # Verdict details heading historically lived under ``### 4.5.9 Verdict
213
- # details`` and is now (post-c2c603d split) written as ``#### Verdict
214
- # details`` directly under §4.5.9 Plan Body Verification. Match both
215
- # forms so the slim stripper drops the wide matrix body regardless of
216
- # which template the report was generated against. The trailing ``$``
217
- # anchor avoids accidentally matching ``Verdict details (excerpt)`` etc.
218
- _VERDICT_DETAIL_RE = re.compile(
219
- r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict details\s*$"
220
- )
221
- _VERDICT_SUMMARY_RE = re.compile(
222
- r"^#{3,4}\s+(?:4\.5\.9\s+)?Verdict summary\s*$"
223
- )
224
- _ANY_HEADING_RE = re.compile(r"^#{1,6}\s+\S")
225
-
226
-
227
- def slim_markdown(src_md: str, *, task_type: Optional[str] = None) -> str:
228
- """Return a slimmed copy of ``src_md`` per the rules in the module
229
- docstring. Deterministic — same input always yields the same output.
230
-
231
- Idempotency note: the returned slim begins with a
232
- ``<!-- source-sha256: ... -->`` comment so the validator can detect
233
- a stale slim against a later MD. Re-running slim on its own output
234
- must produce a byte-identical result, so we first strip the leading
235
- digest comment (if any) before re-deriving everything from the
236
- normalized body.
237
- """
238
- src_md = _strip_leading_digest_comment(src_md)
239
- lines = src_md.splitlines()
240
- out: list[str] = []
241
-
242
- hr_run = 0
243
- blank_run = 0
244
- in_verdict_detail = False # inside the §4.5.9 Verdict details wide matrix
245
-
246
- for line in lines:
247
- # STRIP_VERDICT_DETAIL — drop body of Verdict details (both
248
- # ``### 4.5.9 Verdict details`` and ``#### Verdict details``
249
- # forms), keep the heading line itself.
250
- if _VERDICT_DETAIL_RE.match(line):
251
- in_verdict_detail = True
252
- out.append(line)
253
- continue
254
- if in_verdict_detail:
255
- if _ANY_HEADING_RE.match(line):
256
- in_verdict_detail = False
257
- # fall through and emit this heading
258
- else:
259
- continue
260
-
261
- # STRIP_INTRO_RECIPE — drop the "> **읽는 법**:" quote paragraph.
262
- if _RECIPE_QUOTE_PATTERN.match(line):
263
- continue
264
-
265
- # STRIP_TOKEN_PLACEHOLDER_ZEROS — drop $0.00 rows except CLI cost.
266
- if _is_zero_cost_row(line) and _CLI_COST_ROW_HINT not in line:
267
- continue
268
-
269
- # STRIP_DECORATIVE_HR — collapse runs of horizontal rules.
270
- if _HR_PATTERN.match(line):
271
- hr_run += 1
272
- if hr_run > 1:
273
- continue
274
- else:
275
- hr_run = 0
276
-
277
- # COLLAPSE_BLANK_LINES — collapse 3+ blank lines to one.
278
- if _BLANK_PATTERN.match(line):
279
- blank_run += 1
280
- if blank_run > 1:
281
- continue
282
- else:
283
- blank_run = 0
284
-
285
- out.append(line)
286
-
287
- result = "\n".join(out)
288
- # STRIP_DECORATIVE_HR (post-process) — collapse runs of HRs that
289
- # were separated only by blank lines (the line scanner above can't
290
- # reach across blanks without losing blank-line semantics elsewhere).
291
- result = re.sub(r"(?:^---\s*\n(?:\s*\n)*){2,}", "---\n", result, flags=re.MULTILINE)
292
- if src_md.endswith("\n") and not result.endswith("\n"):
293
- result += "\n"
294
-
295
- _assert_preserved(src_md, result, task_type)
296
- # Prepend the source-digest comment so the validator can detect a
297
- # stale slim that was generated from an older MD. The digest is
298
- # over the slim BODY itself, not over the original MD, so the
299
- # validator's expected-slim recomputation embeds the same digest
300
- # (any byte difference between expected and actual body shows up
301
- # as a mismatched digest).
302
- digest_line = f"<!-- source-sha256: {source_digest(result)} -->\n"
303
- return digest_line + result
304
-
305
-
306
- def _is_zero_cost_row(line: str) -> bool:
307
- """True iff this is a markdown table row whose cost cell is exactly
308
- ``$0.00`` and no other meaningful cell content suggests audit
309
- relevance.
310
- """
311
- if not line.lstrip().startswith("|"):
312
- return False
313
- if "$0.00" not in line:
314
- return False
315
- # Heuristic — defer to the CLI-cost guard at the caller.
316
- return True
317
-
318
-
319
- def _assert_preserved(original: str, slim: str, task_type: Optional[str]) -> None:
320
- """Raise AssertionError if any preserved substring was dropped."""
321
- missing = [s for s in preserved_substrings(task_type) if s in original and s not in slim]
322
- if missing:
323
- raise AssertionError(
324
- "slim_markdown dropped preserved substring(s): " + ", ".join(missing)
325
- )
326
-
327
-
328
- # --------------------------------------------------------------------------- #
329
- # HTML renderer — minimal markdown-to-HTML converter (no external deps)
330
- # --------------------------------------------------------------------------- #
331
71
 
332
72
  _HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
333
73
  _CODEFENCE_PATTERN = re.compile(r"^```(.*)$")
@@ -357,12 +97,9 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
357
97
  into the document (validator enforces this — see
358
98
  validate-report-views.py).
359
99
  """
360
- # Normalise the input so a digest-bearing leading comment from a
361
- # previous slim cycle doesn't leak into the rendered HTML body, then
362
- # compute the digest over the normalised MD body. The validator
100
+ # Compute the digest over the source MD body. The validator
363
101
  # recomputes the same digest from the current MD and compares it to
364
102
  # the value embedded in run-meta below.
365
- src_md = _strip_leading_digest_comment(src_md)
366
103
  digest = source_digest(src_md)
367
104
  body_md = _strip_leading_frontmatter(src_md)
368
105
  body_html, toc_headings = _markdown_to_html(body_md)
@@ -571,9 +308,20 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
571
308
  and "Status" in header_cells
572
309
  )
573
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
+
574
319
  head = (
575
320
  "<thead><tr>"
576
- + "".join(f"<th{_cell_class(c)}>{_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
+ )
577
325
  + "</tr></thead>"
578
326
  )
579
327
 
@@ -611,7 +359,9 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
611
359
  f"<td>{_form_control(response_id, kind, status, cell, statement)}</td>"
612
360
  )
613
361
  else:
614
- cells_html.append(f"<td{_cell_class(cell)}>{_inline(cell)}</td>")
362
+ cells_html.append(
363
+ f"<td{_col_class(idx, narrow_cols)}>{_inline(cell)}</td>"
364
+ )
615
365
  body_rows.append(
616
366
  f'<tr data-response-id="{html.escape(response_id)}" '
617
367
  f'data-kind="{html.escape(kind)}" '
@@ -622,7 +372,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
622
372
  else:
623
373
  body_rows.append(
624
374
  "<tr>"
625
- + "".join(f"<td{_cell_class(c)}>{_inline(c)}</td>" for c in row)
375
+ + "".join(
376
+ f"<td{_col_class(idx, narrow_cols)}>{_inline(c)}</td>"
377
+ for idx, c in enumerate(row)
378
+ )
626
379
  + "</tr>"
627
380
  )
628
381
 
@@ -756,23 +509,84 @@ def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
756
509
  return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start
757
510
 
758
511
 
759
- _TIGHT_CELL_MAX_PLAIN_LEN = 20
512
+ _NARROW_COL_MAX_PLAIN_LEN = 30
760
513
 
761
514
  _INLINE_MD_STRIP_RE = re.compile(r"[`*_]")
762
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
+
763
555
 
764
- def _cell_class(raw_cell: str) -> str:
765
- """Return ``" class=\\"td-tight\\""`` when the cell's plain content
766
- is short enough to fit on one line (<= 20 chars after stripping
767
- inline markdown punctuation). Empty string otherwise.
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).
768
561
 
769
- Threshold mirrors the user-visible rule "td columns under 20 chars
770
- should render on a single line".
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.
771
573
  """
772
- plain = _INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip()
773
- if 0 < len(plain) <= _TIGHT_CELL_MAX_PLAIN_LEN:
774
- return ' class="td-tight"'
775
- return ""
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 ""
776
590
 
777
591
 
778
592
  def _inline(text: str) -> str:
@@ -850,28 +664,17 @@ def serialize_user_response(
850
664
  # Convenience entrypoint for tests + CLI.
851
665
  # --------------------------------------------------------------------------- #
852
666
 
853
- def render_both_views(
667
+ def render_html_view(
854
668
  src_md_path: Path,
855
669
  *,
856
670
  run_meta: RunMeta,
857
671
  css: str,
858
672
  js: str,
859
- ) -> tuple[Path, Path]:
860
- """Write ``*.slim.md`` and ``*.html`` next to ``src_md_path`` and
861
- 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."""
862
676
  src_text = src_md_path.read_text(encoding="utf-8")
863
-
864
- slim_path = src_md_path.with_suffix(".slim.md")
865
- if src_md_path.suffix == ".md":
866
- # ``with_suffix`` replaces the LAST suffix only; we want
867
- # ``foo.md`` → ``foo.slim.md`` (insert before ``.md``).
868
- slim_path = src_md_path.with_name(src_md_path.stem + ".slim.md")
869
-
870
677
  html_path = src_md_path.with_name(src_md_path.stem + ".html")
871
-
872
- slim_text = slim_markdown(src_text, task_type=run_meta.task_type)
873
678
  html_text = render_html(src_text, run_meta=run_meta, css=css, js=js)
874
-
875
- slim_path.write_text(slim_text, encoding="utf-8")
876
679
  html_path.write_text(html_text, encoding="utf-8")
877
- return slim_path, html_path
680
+ return html_path
@@ -1314,11 +1314,15 @@ STEPS: list[Step] = [
1314
1314
  and s.use_defaults is None),
1315
1315
  build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
1316
1316
  owns=("use_defaults",)),
1317
- # Customize branch workers override only when the profile actually
1318
- # has analyser-candidate workers (required ∪ optional, minus report-writer).
1317
+ # Worker roster is ALWAYS prompted (independent of the defaults /
1318
+ # customize branch) when the profile has analyser-candidate workers
1319
+ # (required ∪ optional, minus report-writer). Users repeatedly hit
1320
+ # the failure mode where the lead silently picked "Use defaults"
1321
+ # and the worker prompt never appeared — defaults should govern
1322
+ # model choice, not worker selection. `implementation` task-type is
1323
+ # still skipped because it runs lead + executor only.
1319
1324
  Step(S_WORKERS_OVERRIDE,
1320
- applies=lambda s: (s.use_defaults is False
1321
- and s.task_type != "implementation"
1325
+ applies=lambda s: (s.task_type != "implementation"
1322
1326
  and any(
1323
1327
  w != "report-writer"
1324
1328
  for w in (s.profile_workers
@@ -1451,10 +1455,17 @@ def _identity_ready(s: WizardState) -> bool:
1451
1455
  def _ready_for_confirm(s: WizardState) -> bool:
1452
1456
  if s.use_defaults is None:
1453
1457
  return False
1458
+ # Worker roster is required regardless of the defaults / customize
1459
+ # branch. Skipping this check let the lead drop into confirm with no
1460
+ # worker prompt ever shown — the very failure mode this gate exists
1461
+ # to prevent.
1462
+ workers_step = STEP_BY_ID[S_WORKERS_OVERRIDE]
1463
+ if workers_step.applies(s):
1464
+ return False
1454
1465
  if s.use_defaults:
1455
1466
  return True
1456
1467
  # customize: every customize-branch step must be answered or not-applicable.
1457
- custom_ids = [S_WORKERS_OVERRIDE, S_LEAD_MODEL, S_EXECUTOR_MODEL,
1468
+ custom_ids = [S_LEAD_MODEL, S_EXECUTOR_MODEL,
1458
1469
  S_CLAUDE_MODEL, S_CODEX_MODEL, S_GEMINI_MODEL,
1459
1470
  S_REPORT_WRITER_MODEL, S_DIRECTIVE, S_RELATED_TASKS,
1460
1471
  S_CLARIFICATION, S_PR_TEMPLATE, S_PR_TEMPLATE_SCOPE]
@@ -23,4 +23,8 @@ from .pricing import (
23
23
  codex_cost_usd,
24
24
  gemini_cost_usd,
25
25
  )
26
- from .report import substitute_final_report
26
+ from .report import (
27
+ SubstituteRefusedError,
28
+ populate_and_render,
29
+ populate_data_token_cells,
30
+ )