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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-inject-report-index.py +66 -0
- package/runtime/prompts/profiles/improvement-discovery.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +10 -1
- package/runtime/python/okstra_ctl/render_final_report.py +221 -2
- package/runtime/python/okstra_ctl/report_views.py +23 -4
- package/runtime/templates/reports/i18n/en.json +6 -0
- package/runtime/templates/reports/i18n/ko.json +6 -0
- package/runtime/validators/lib/fixtures.sh +9 -0
- package/runtime/validators/validate-run.py +40 -1
- package/runtime/validators/validate_improvement_report.py +5 -1
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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"
|
|
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
|
|
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)
|