okstra 0.54.0 → 0.55.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.54.0",
3
- "builtAt": "2026-06-06T15:10:15.217Z",
2
+ "package": "0.55.0",
3
+ "builtAt": "2026-06-06T16:37:32.221Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -101,7 +101,7 @@ Rules (the schema enforces most of these — they are listed here so you know *w
101
101
  - Cite file paths and line numbers in every `evidence.primary[].source` / `consensus[].evidence` cell.
102
102
  - Preserve every analysis worker's ticket tagging — every row's `ticketId` field carries the ticket key or the task-fallback. For single-ticket runs, set `ticketCoverage` to `{"singleTicket": "<ticket>"}`. For runs that do not require ticket tagging (`release-handoff`, `final-verification`), set `ticketCoverage` to `{"omit": true}`.
103
103
  - For `implementation-planning`, populate `implementationPlanning.requirementCoverage` with one row per concrete requirement from the brief / packet, using IDs `R-001`, `R-002`, ... in source order. `coveredBy` MUST name the specific Option Candidate plus Stage/Step that satisfies the requirement. Use `status: "covered"` only when the report's plan actually covers it; otherwise use `gap` or `blocked C-NNN` and ensure the corresponding `Clarification Items` row blocks approval. Do not collapse this into `ticketCoverage`; ticket coverage is not requirement coverage.
104
- - When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate-improvement-report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes.
104
+ - When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate-improvement-report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes. `improvement-discovery` is NOT in the data.json schema enum, so author its markdown directly (not via `okstra-render-final-report.py`). Immediately after writing the markdown, run (`Bash`): `python3 scripts/okstra-inject-report-index.py <markdown path> --report-language <en|ko>`. That adds the top-of-report Index plus `I-NNN` / `C-NNN` scroll anchors; the run validator fails the report when the Index anchor is absent.
105
105
 
106
106
  Write the data.json with your `Write` tool against the absolute `Result Path`. Then invoke the renderer (`Bash`): `python3 scripts/okstra-render-final-report.py <data.json path>`. Confirm both files exist and respond with a short status line: `data.json written to <abs path>; markdown rendered to <abs path>. Sections populated: <count>.`
107
107
 
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entry for the top-of-report index / scroll-anchor injector.
3
+
4
+ Usage:
5
+ python3 scripts/okstra-inject-report-index.py \\
6
+ <final-report.md> \\
7
+ [--report-language en|ko]
8
+
9
+ Adds the top-of-report Index (section list + ID index) and `<a id="...">`
10
+ scroll anchors to a markdown report that was authored *free-form* rather
11
+ than rendered from a data.json. The only such task-type today is
12
+ `improvement-discovery`: its `## 5.9 Improvement Candidates` table is
13
+ written directly by the report-writer worker, so the data.json renderer
14
+ (which injects the index for every other task-type) never sees it.
15
+
16
+ Idempotent — re-running on an already-indexed report is a no-op.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ _HERE = Path(__file__).resolve().parent
25
+ # Make ``okstra_ctl`` importable for in-repo invocation (the installed
26
+ # runtime adds ``~/.okstra/lib/python`` via the wrapper scripts).
27
+ sys.path.insert(0, str(_HERE))
28
+
29
+ from okstra_ctl.i18n import SUPPORTED_LANGS # noqa: E402
30
+ from okstra_ctl.render_final_report import ( # noqa: E402
31
+ FinalReportRenderError,
32
+ inject_index_into_file,
33
+ )
34
+
35
+
36
+ def main(argv: list[str]) -> int:
37
+ parser = argparse.ArgumentParser(
38
+ description="Inject the top-of-report index + scroll anchors into a free-form markdown report.",
39
+ )
40
+ parser.add_argument(
41
+ "report",
42
+ type=Path,
43
+ help="Path to the final-report markdown to rewrite in place.",
44
+ )
45
+ parser.add_argument(
46
+ "--report-language",
47
+ choices=list(SUPPORTED_LANGS),
48
+ default="en",
49
+ help="Language for the index labels (Index/목차, …). Default: en.",
50
+ )
51
+ args = parser.parse_args(argv)
52
+
53
+ try:
54
+ bytes_written = inject_index_into_file(
55
+ args.report, report_language=args.report_language
56
+ )
57
+ except FinalReportRenderError as exc:
58
+ print(f"error: {exc}", file=sys.stderr)
59
+ return 1
60
+
61
+ print(f"injected index + anchors -> {args.report} ({bytes_written} bytes)")
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main(sys.argv[1:]))
@@ -32,6 +32,7 @@
32
32
  - the `## 5.9 Improvement Candidates` table populated with rows that obey the 10-column schema from `validators/validate-improvement-report.py` (Cand ID `I-NNN`, Lens from whitelist, Title, Scope ⊆ scan-scope, Severity, Effort, Consensus, Source workers `<worker>:<id>` from {claude, codex, gemini}, Recommended next-phase ∈ {requirements-discovery, implementation-planning, error-analysis}, Evidence as path:line list)
33
33
  - `## 7. Final Verdict` Verdict Token ∈ {`candidates-ready`, `no-candidates`, `blocked`}; Direction `routing`; Next Step "사용자에게 후보 K개 선택 의뢰 (## 5.9 표 참조)"
34
34
  - `## 3. Recommended Next Steps` first entry summarises per-candidate routing and proposes new task-key names of the form `<task-group>/imp-<Cand-ID>`
35
+ - this report is authored free-form (improvement-discovery is not in the data.json schema enum); after the markdown is written, the report-writer runs `scripts/okstra-inject-report-index.py <report.md> --report-language <en|ko>` to add the top-of-report Index + `I-NNN`/`C-NNN` scroll anchors. The run validator fails the report when the Index anchor is missing.
35
36
  - Clarification request policy (phase-specific addenda — shared policy is in `_common-contract.md`):
36
37
  - if scan-scope or priority-lenses cannot be made concrete during Phase 1.5, end the run with Verdict Token `blocked`, populate `## 1. Clarification Items` with `Blocks=next-phase` rows, and do not run worker dispatch
37
38
  - every clarification row carries a recommended answer + one-line rationale inside the `Expected form` cell
@@ -46,8 +46,17 @@ class ClarificationItem:
46
46
  raw_status: str
47
47
 
48
48
 
49
+ # The final-report renderer injects a scroll anchor into ID-defining first
50
+ # cells — either leading (`<a id="c-001"></a>C-001`) or inside the bold marker
51
+ # (`**<a id="e-001"></a>E-001**`). Strip every such empty anchor during cell
52
+ # normalization so the ID parses as a bare token for clarification parsing AND
53
+ # the HTML view's `C-\d+` form detection, and so the anchor never leaks into
54
+ # the HTML view as html-escaped literal text.
55
+ _CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
56
+
57
+
49
58
  def _strip_backticks(cell: str) -> str:
50
- s = cell.strip()
59
+ s = _CELL_ANCHOR_RE.sub("", cell.strip()).strip()
51
60
  if s.startswith("`") and s.endswith("`") and len(s) >= 2:
52
61
  s = s[1:-1].strip()
53
62
  return s
@@ -10,9 +10,17 @@ the canonical user-facing markdown.
10
10
  Why this exists: prior to v0.32, report-writer-worker wrote the markdown
11
11
  directly. Free-form authoring led to silent contract violations — missing
12
12
  columns in the Execution Status table, omitted §4 phase-continuation
13
- rows, invented ``## Index`` sections. Routing everything through one
13
+ rows, ad-hoc ``## Index`` sections. Routing everything through one
14
14
  template + schema cuts those failure modes to zero.
15
15
 
16
+ The top-of-report ``## Index`` is now a *deterministic* post-render
17
+ section: after Jinja2 renders the body, ``_inject_index_and_anchors``
18
+ appends a scroll anchor to every heading and every ID-defining table row,
19
+ links in-body ID references to their definition, and builds the index
20
+ (section list + ID index) — so every ``FU-001`` / ``E-001`` / ``S-001``
21
+ token is clickable and the reader can jump to any section. This runs on
22
+ every render (including the Phase 7 re-render) and is idempotent.
23
+
16
24
  Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
17
25
  the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
18
26
  data.json, then re-invokes this renderer so the markdown stays in sync.
@@ -27,6 +35,7 @@ from __future__ import annotations
27
35
 
28
36
  import json
29
37
  import os
38
+ import re
30
39
  import sys
31
40
  from pathlib import Path
32
41
  from typing import Any
@@ -110,6 +119,215 @@ def _yaml_inline_list(values: list[str]) -> str:
110
119
  return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
111
120
 
112
121
 
122
+ # --- Index / scroll-anchor post-render pass -------------------------------
123
+ # After Jinja2 renders the body, every report gets a top-of-report index and
124
+ # clickable scroll anchors. An ID is *defined* when it is the leading token of
125
+ # a table row's first cell (`| **FU-001**<br>… |` or `| C-001 | …`); that row
126
+ # gets an `<a id="…">` anchor. Every other in-body mention of a *uniquely*
127
+ # defined ID becomes a `[ID](#anchor)` link. Same ID string defined in two
128
+ # sections (e.g. `C-001` in §1 Clarification AND §6.1 Consensus) gets two
129
+ # distinct anchors and its bare references are left unlinked — an ambiguous
130
+ # reference must not silently jump to the wrong row.
131
+ _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
132
+ _FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
133
+ _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b")
134
+ # A reference token: an ID not glued to a word char / `-` on either side and
135
+ # not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
136
+ _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{3,}(?![\w-])")
137
+
138
+
139
+ def _slugify(text: str) -> str:
140
+ slug = re.sub(r"[^\w\s-]", "", text.strip().lower())
141
+ slug = re.sub(r"[\s_]+", "-", slug).strip("-")
142
+ return slug or "section"
143
+
144
+
145
+ def _dedupe(base: str, used: set[str]) -> str:
146
+ candidate, suffix = base, 1
147
+ while candidate in used:
148
+ suffix += 1
149
+ candidate = f"{base}-{suffix}"
150
+ used.add(candidate)
151
+ return candidate
152
+
153
+
154
+ def _code_line_mask(lines: list[str]) -> list[bool]:
155
+ """Mark lines the anchor / reference passes must never rewrite: fenced
156
+ code blocks (git status dumps, etc.) and the leading YAML frontmatter
157
+ (a `task-id` that happens to look like an ID must not be linkified)."""
158
+ frontmatter_end = -1
159
+ if lines and lines[0].strip() == "---":
160
+ for k in range(1, len(lines)):
161
+ if lines[k].strip() == "---":
162
+ frontmatter_end = k
163
+ break
164
+ mask, in_fence = [], False
165
+ for i, line in enumerate(lines):
166
+ if i <= frontmatter_end:
167
+ mask.append(True)
168
+ elif _FENCE_RE.match(line):
169
+ mask.append(True)
170
+ in_fence = not in_fence
171
+ else:
172
+ mask.append(in_fence)
173
+ return mask
174
+
175
+
176
+ def _first_cell(line: str) -> str | None:
177
+ if not line.lstrip().startswith("|"):
178
+ return None
179
+ parts = line.split("|")
180
+ return parts[1] if len(parts) >= 3 else None
181
+
182
+
183
+ def _scan_structure(lines: list[str], mask: list[bool]) -> tuple[list, list]:
184
+ """Collect (level, text, slug, line_idx) headings and {id, section,
185
+ line} definitions in document order. H1 (the title) is skipped."""
186
+ headings: list = []
187
+ definitions: list = []
188
+ used_slugs: set[str] = set()
189
+ current_section: str | None = None
190
+ for i, line in enumerate(lines):
191
+ if mask[i]:
192
+ continue
193
+ heading = _HEADING_RE.match(line)
194
+ if heading:
195
+ if len(heading.group(1)) < 2:
196
+ continue # H1 title — not an index entry, no anchor
197
+ text = heading.group(2).strip()
198
+ slug = _dedupe(_slugify(text), used_slugs)
199
+ headings.append((len(heading.group(1)), text, slug, i))
200
+ current_section = text
201
+ continue
202
+ cell = _first_cell(line)
203
+ if cell is None:
204
+ continue
205
+ match = _FIRST_CELL_ID_RE.match(cell)
206
+ if match:
207
+ definitions.append({"id": match.group(1), "section": current_section, "line": i})
208
+ return headings, definitions
209
+
210
+
211
+ def _assign_anchors(definitions: list) -> dict[str, str]:
212
+ """Give every definition a unique anchor; return the id→anchor map for
213
+ the subset of IDs that are defined exactly once (safe to link)."""
214
+ used: set[str] = set()
215
+ counts: dict[str, int] = {}
216
+ for d in definitions:
217
+ counts[d["id"]] = counts.get(d["id"], 0) + 1
218
+ for d in definitions:
219
+ d["anchor"] = _dedupe(d["id"].lower(), used)
220
+ return {d["id"]: d["anchor"] for d in definitions if counts[d["id"]] == 1}
221
+
222
+
223
+ def _inject_anchors(lines: list[str], headings: list, definitions: list) -> None:
224
+ for _level, _text, slug, i in headings:
225
+ if "<a id=" not in lines[i]:
226
+ lines[i] = lines[i].rstrip() + f' <a id="{slug}"></a>'
227
+ for d in definitions:
228
+ i = d["line"]
229
+ pos = lines[i].index(d["id"])
230
+ lines[i] = lines[i][:pos] + f'<a id="{d["anchor"]}"></a>' + lines[i][pos:]
231
+
232
+
233
+ def _maybe_link(match: re.Match, id_to_anchor: dict[str, str]) -> str:
234
+ token = match.group(0)
235
+ anchor = id_to_anchor.get(token)
236
+ if anchor is None:
237
+ return token
238
+ # The definition site is already `<a id="…"></a>**FU-001**`; never wrap it.
239
+ if match.string[: match.start()].rstrip("*").endswith("</a>"):
240
+ return token
241
+ return f"[{token}](#{anchor})"
242
+
243
+
244
+ def _link_references(lines: list[str], mask: list[bool], id_to_anchor: dict[str, str]) -> None:
245
+ if not id_to_anchor:
246
+ return
247
+ for i, line in enumerate(lines):
248
+ if mask[i]:
249
+ continue
250
+ # Split on backticks so inline-code spans (odd segments) are skipped.
251
+ segments = line.split("`")
252
+ for j in range(0, len(segments), 2):
253
+ segments[j] = _REF_ID_RE.sub(lambda m: _maybe_link(m, id_to_anchor), segments[j])
254
+ lines[i] = "`".join(segments)
255
+
256
+
257
+ def _build_index(headings: list, definitions: list, labels: dict) -> list[str]:
258
+ # The section list / ID index use bold labels, not `###` sub-headings, so
259
+ # the HTML view's auto-TOC (report_views._build_toc) doesn't pick them up
260
+ # as navigable headings.
261
+ heading = labels.get("heading", "Index")
262
+ block = [f'## {heading} <a id="report-index"></a>', "", f'**{labels.get("sectionsLabel", "Sections")}**', ""]
263
+ for level, text, slug, _i in headings:
264
+ block.append(f'{" " * (level - 2)}- [{text}](#{slug})')
265
+ block += ["", f'**{labels.get("idIndexLabel", "ID Index")}**', ""]
266
+ if not definitions:
267
+ block += [labels.get("noIds", "- (no tracked IDs in this report.)"), ""]
268
+ return block
269
+ groups: list = []
270
+ order: dict = {}
271
+ for d in definitions:
272
+ section = d["section"] or "—"
273
+ if section not in order:
274
+ order[section] = len(groups)
275
+ groups.append((section, []))
276
+ groups[order[section]][1].append((d["id"], d["anchor"]))
277
+ for section, items in groups:
278
+ links = ", ".join(f"[{tok}](#{anchor})" for tok, anchor in items)
279
+ block.append(f"- **{section}**: {links}")
280
+ block.append("")
281
+ return block
282
+
283
+
284
+ def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
285
+ """Append scroll anchors + a top-of-report index to a rendered report.
286
+ Idempotent: re-running on already-anchored markdown is a no-op for
287
+ headings (anchor already present) and re-derives the same anchors."""
288
+ # Idempotent: a markdown that already carries the index anchor has been
289
+ # processed (or hand-seeded) — re-running must not stack a second index.
290
+ if '<a id="report-index"' in markdown:
291
+ return markdown
292
+ labels = (dictionary or {}).get("index", {})
293
+ lines = markdown.split("\n")
294
+ mask = _code_line_mask(lines)
295
+ headings, definitions = _scan_structure(lines, mask)
296
+ if not headings:
297
+ return markdown
298
+ id_to_anchor = _assign_anchors(definitions)
299
+ _inject_anchors(lines, headings, definitions)
300
+ _link_references(lines, mask, id_to_anchor)
301
+ insert_at = headings[0][3]
302
+ lines = lines[:insert_at] + _build_index(headings, definitions, labels) + lines[insert_at:]
303
+ return "\n".join(lines)
304
+
305
+
306
+ def inject_index_into_file(md_path: Path, *, report_language: str = "en") -> int:
307
+ """Apply the top-of-report index + scroll anchors to an already-written
308
+ markdown report, in place. This is the seam for task-types that author
309
+ the markdown free-form (``improvement-discovery``) instead of through the
310
+ data.json renderer — every other task-type gets the same treatment inside
311
+ ``render()``. Idempotent (the index anchor guards re-runs). Returns the
312
+ number of bytes written.
313
+ """
314
+ if not md_path.is_file():
315
+ raise FinalReportRenderError(f"report markdown not found: {md_path}")
316
+ if report_language not in SUPPORTED_LANGS:
317
+ raise FinalReportRenderError(
318
+ f"report_language must be one of {SUPPORTED_LANGS}, got {report_language!r}"
319
+ )
320
+ try:
321
+ dictionary = load_dictionary(report_language)
322
+ except I18nError as exc:
323
+ raise FinalReportRenderError(str(exc)) from exc
324
+ injected = _inject_index_and_anchors(md_path.read_text(encoding="utf-8"), dictionary)
325
+ tmp = md_path.with_suffix(md_path.suffix + f".tmp.{os.getpid()}")
326
+ tmp.write_text(injected, encoding="utf-8")
327
+ tmp.replace(md_path)
328
+ return len(injected.encode("utf-8"))
329
+
330
+
113
331
  def _enforce_schema(data: dict) -> None:
114
332
  """렌더 전에 data.json 을 스키마에 대해 검증하는 seam.
115
333
 
@@ -203,7 +421,8 @@ def render(
203
421
 
204
422
  try:
205
423
  template = env.get_template(template_path.name)
206
- return template.render(**data)
424
+ rendered = template.render(**data)
425
+ return _inject_index_and_anchors(rendered, dictionary)
207
426
  except I18nError as exc:
208
427
  raise FinalReportRenderError(
209
428
  f"i18n lookup failed while rendering {template_path.name}: {exc}"
@@ -169,8 +169,12 @@ def _markdown_to_html(
169
169
  m_heading = _HEADING_PATTERN.match(line)
170
170
  if m_heading:
171
171
  level = len(m_heading.group(1))
172
- text = m_heading.group(2)
173
- slug = _slugify(text)
172
+ # The final-report renderer appends an explicit scroll anchor to
173
+ # each heading (`## Verdict Card <a id="verdict-card"></a>`). Honor
174
+ # that id as the slug (keeps markdown ↔ HTML anchors consistent and
175
+ # language-independent) and drop it from the displayed text.
176
+ explicit_id, text = _split_heading_anchor(m_heading.group(2))
177
+ slug = explicit_id or _slugify(text)
174
178
  current_section_path = _update_section_path(current_section_path, level, line)
175
179
  out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
176
180
  headings.append((level, slug, text))
@@ -235,11 +239,26 @@ def _markdown_to_html(
235
239
  return "\n".join(out), headings
236
240
 
237
241
 
242
+ _HEADING_ANCHOR_RE = re.compile(r'\s*<a id="([^"]+)"></a>\s*$')
243
+
244
+ # The renderer's top-of-report Index section (`## Index`/`## 목차` carrying
245
+ # `<a id="report-index">`, followed by bold-labelled bullet lists). The whole
246
+ # section — heading through the bullet lists, up to the next h2 — is replaced
247
+ # by the auto-built nav.
238
248
  _INDEX_BLOCK_RE = re.compile(
239
- r'<h2 id="index">Index</h2>\n<ul>.*?</ul>', re.DOTALL
249
+ r'<h2 id="report-index">.*?(?=<h2|\Z)', re.DOTALL
240
250
  )
241
251
 
242
252
 
253
+ def _split_heading_anchor(text: str) -> tuple[Optional[str], str]:
254
+ """Split a trailing ``<a id="…"></a>`` off a heading's text. Returns
255
+ ``(explicit_id_or_None, display_text)``."""
256
+ match = _HEADING_ANCHOR_RE.search(text)
257
+ if match:
258
+ return match.group(1), text[: match.start()].rstrip()
259
+ return None, text
260
+
261
+
243
262
  def _build_toc(headings: list[tuple[int, str, str]]) -> str:
244
263
  """Render a ``<nav class="toc">`` block from collected h2/h3 entries.
245
264
  h1 (the report title) is omitted; h4+ are too granular for the TOC."""
@@ -247,7 +266,7 @@ def _build_toc(headings: list[tuple[int, str, str]]) -> str:
247
266
  for level, slug, text in headings:
248
267
  if level not in (2, 3):
249
268
  continue
250
- if slug == "index": # skip the source "Index" heading itself
269
+ if slug == "report-index": # skip the source Index/목차 heading itself
251
270
  continue
252
271
  cls = "toc-h2" if level == 2 else "toc-h3"
253
272
  items.append(
@@ -137,5 +137,11 @@
137
137
  "columnRequirement": "Requirement (plan/brief citation)",
138
138
  "verificationScope": "Verification scope",
139
139
  "stageReportsLabel": "Source implementation reports (per stage)"
140
+ },
141
+ "index": {
142
+ "heading": "Index",
143
+ "sectionsLabel": "Sections",
144
+ "idIndexLabel": "ID Index",
145
+ "noIds": "- (no tracked IDs in this report.)"
140
146
  }
141
147
  }
@@ -137,5 +137,11 @@
137
137
  "columnRequirement": "Requirement (plan/brief 인용)",
138
138
  "verificationScope": "검증 범위",
139
139
  "stageReportsLabel": "stage 별 구현 리포트"
140
+ },
141
+ "index": {
142
+ "heading": "목차",
143
+ "sectionsLabel": "섹션",
144
+ "idIndexLabel": "ID 색인",
145
+ "noIds": "- (이 보고서에 추적 대상 ID 가 없습니다.)"
140
146
  }
141
147
  }
@@ -323,6 +323,15 @@ if not isinstance(required_status_entries, list):
323
323
  report_lines = [
324
324
  "# Validation Fixture Report",
325
325
  "",
326
+ # Top-of-report Index — the renderer injects this on real runs; the
327
+ # hand-crafted fixture mirrors it so validate_report's index/anchor
328
+ # contract (introduced with the clickable-ID pass) is satisfied.
329
+ '## Index <a id="report-index"></a>',
330
+ "",
331
+ "### Sections",
332
+ "",
333
+ "- [Verdict Card](#verdict-card)",
334
+ "",
326
335
  "## Verdict Card",
327
336
  "",
328
337
  "| 항목 | 값 |",
@@ -616,6 +616,19 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
616
616
  # a section heading line (not as inline text inside a paragraph or table).
617
617
  _VERDICT_CARD_HEADING_RE = re.compile(r"^##[ \t]+Verdict Card\b", re.MULTILINE)
618
618
 
619
+ # Top-of-report Index block. The renderer
620
+ # (scripts/okstra_ctl/render_final_report.py) injects `<a id="report-index">`
621
+ # into the index heading; a missing anchor means the markdown was produced
622
+ # outside the renderer or hand-edited. Language-independent (the heading text
623
+ # itself is localized "Index" / "목차").
624
+ _REPORT_INDEX_ANCHOR_RE = re.compile(r'<a id="report-index"')
625
+
626
+ # An ID-defining table row: `| **FU-001**…` or `| C-001 |`. After the
627
+ # renderer's anchor pass the leading token becomes `<a id="…">…`, so a row
628
+ # still matching this (ID is the bare leading token) is one the renderer
629
+ # never anchored — i.e. an un-anchored ID that the Index cannot link to.
630
+ _UNANCHORED_ID_ROW_RE = re.compile(r"^\|[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b", re.MULTILINE)
631
+
619
632
  # Reading Confirmation heading must NOT appear in the final-report — it
620
633
  # belongs in the worker audit sidecar (`<worker>-audit-<task-type>-<seq>.md`).
621
634
  _READING_CONFIRMATION_HEADING_RE = re.compile(
@@ -723,6 +736,32 @@ def validate_report(
723
736
  "the corresponding cells in `## 7. Final Verdict` and `## 3.` first item."
724
737
  )
725
738
 
739
+ # Top-of-report Index (목차 / Index) is mandatory in every final-report so
740
+ # the reader can jump to any section / tracked ID. Schema task-types get it
741
+ # from render_final_report.py; `improvement-discovery` (authored free-form)
742
+ # gets it from the `okstra-inject-report-index.py` post-step. A missing
743
+ # index anchor means that injection never ran.
744
+ if _REPORT_INDEX_ANCHOR_RE.search(content) is None:
745
+ failures.append(
746
+ "final report is missing the top-of-report Index block "
747
+ '(`## Index` / `## 목차` carrying `<a id="report-index">`). It is '
748
+ "injected by scripts/okstra_ctl/render_final_report.py (schema "
749
+ "task-types) or scripts/okstra-inject-report-index.py "
750
+ "(improvement-discovery); a missing index means that step never ran."
751
+ )
752
+
753
+ # Every ID-defining table row (FU-/E-/S-/C-/R-/I-/… in the first cell) must
754
+ # carry a scroll anchor so the Index can link to it. A row still matching
755
+ # the bare-leading-token shape is one the injector never anchored.
756
+ for match in _UNANCHORED_ID_ROW_RE.finditer(content):
757
+ failures.append(
758
+ f"final report has an ID-defining table row for `{match.group(1)}` "
759
+ 'without a scroll anchor (`<a id="…">`). IDs must be anchored so the '
760
+ "top-of-report Index can link to them — run the index injector "
761
+ "(render_final_report.py / okstra-inject-report-index.py) instead of "
762
+ "hand-editing."
763
+ )
764
+
726
765
  # Reading Confirmation belongs in the worker audit sidecar, not the
727
766
  # user-facing final-report.
728
767
  if _READING_CONFIRMATION_HEADING_RE.search(content) is not None:
@@ -1870,11 +1909,11 @@ def main() -> int:
1870
1909
  # data.json is well-formed, the rendered markdown is guaranteed to
1871
1910
  # contain every required section. Substring checks below are a
1872
1911
  # safety net for hand-edited or pre-v1.0 reports.
1912
+ task_type = effective_run_task_type(run_manifest, task_manifest)
1873
1913
  validate_final_report_data(report_path, failures)
1874
1914
  validate_report(report_path, contract["required_agent_status_entries"], failures)
1875
1915
  validate_team_state_usage(team_state, failures)
1876
1916
 
1877
- task_type = effective_run_task_type(run_manifest, task_manifest)
1878
1917
  validate_phase_boundary(task_type, report_path, failures)
1879
1918
  if task_type:
1880
1919
  validate_worker_results_audit(report_path, task_type, failures)
@@ -30,6 +30,10 @@ _NEXT_PHASES = ("requirements-discovery", "implementation-planning", "error-anal
30
30
  _CAND_ID_RE = re.compile(r"^I-\d{3}$")
31
31
  _SOURCE_WORKER_RE = re.compile(r"^([a-z-]+):([A-Za-z0-9._-]+)$")
32
32
  _CONSENSUS_VALUES = ("full", "partial", "contested", "worker-unique")
33
+ # The index/anchor post-pass (okstra-inject-report-index.py) injects an empty
34
+ # scroll anchor into ID-defining first cells (`<a id="i-001"></a>I-001`). Strip
35
+ # it during cell normalization so `_CAND_ID_RE` still matches the bare `I-NNN`.
36
+ _CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
33
37
 
34
38
 
35
39
  @dataclass
@@ -53,7 +57,7 @@ def _read_section_table(body: str, heading: str) -> list[list[str]]:
53
57
  s = line.strip()
54
58
  if not s.startswith("|") or not s.endswith("|"):
55
59
  continue
56
- cells = [c.strip() for c in s.strip("|").split("|")]
60
+ cells = [_CELL_ANCHOR_RE.sub("", c).strip() for c in s.strip("|").split("|")]
57
61
  if all(set(c) <= set("-: ") for c in cells):
58
62
  continue
59
63
  rows.append(cells)