okstra 0.189.2 → 0.189.4

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 (43) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +3 -3
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/lead/report-writer.md +8 -1
  6. package/runtime/prompts/profiles/_common-contract.md +1 -1
  7. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  8. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  9. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  10. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +5 -1
  11. package/runtime/python/okstra_ctl/plan_items_cli.py +12 -0
  12. package/runtime/python/okstra_ctl/render.py +24 -7
  13. package/runtime/python/okstra_ctl/report_assembly.py +10 -4
  14. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  15. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  16. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  17. package/runtime/python/okstra_ctl/report_html/render.py +54 -13
  18. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  19. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  20. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  21. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  22. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  23. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  24. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  25. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  26. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  27. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  28. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  29. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  30. package/runtime/python/okstra_ctl/run.py +6 -0
  31. package/runtime/python/okstra_ctl/worker_audit_check.py +23 -8
  32. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  33. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  34. package/runtime/templates/reports/html/base.template.html +13 -3
  35. package/runtime/templates/reports/html/i18n/en.json +43 -5
  36. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  37. package/runtime/templates/reports/html/macros/forms.html +4 -4
  38. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  39. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  40. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  41. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  42. package/runtime/validators/validate-report-views.py +30 -1
  43. package/runtime/validators/validate-run.py +220 -3
@@ -1,6 +1,9 @@
1
1
  """Common data transformations that carry no task-specific section order."""
2
2
  from __future__ import annotations
3
3
 
4
+ import re
5
+ from collections.abc import Iterator
6
+
4
7
 
5
8
  # 본문이 인용하지만 전용 섹션이 없는 행만 대장에 넣는다. 키 이름만으로
6
9
  # 블록을 고르면 `evidence.primary` 의 내용과 다른 블록의 출처 칸이 섞인다.
@@ -13,8 +16,8 @@ from __future__ import annotations
13
16
  #
14
17
  # `endStateCoverage` is deliberately absent. Its rows hold an id pair
15
18
  # (`EB-001` covered by `R-001`) rather than a statement, so the ledger rendered
16
- # them as an id with no body, and the requirement-coverage table already names
17
- # every one of them in its Source column.
19
+ # them as an id with no body. Those ids anchor in the end-state section the
20
+ # base template renders from the brief and this coverage table together.
18
21
  #
19
22
  # The kind each row carries is a vocabulary key, not the words the reader sees:
20
23
  # the labels live in the i18n `ledgerKind` table so they arrive in the reader's
@@ -30,6 +33,11 @@ _LEDGER_BLOCKS = (
30
33
  (("followUpTasks",), "title", "reason", "", "follow-up"),
31
34
  )
32
35
 
36
+ # The keys a row's identity may sit under. `agentActivity` numbers its rows
37
+ # `activityId`; a supersession entry names the answer it overturns by
38
+ # `clarificationId` and has no id of its own.
39
+ _ROW_ID_KEYS = ("id", "activityId", "clarificationId")
40
+
33
41
 
34
42
  def _dig(data: dict, path: tuple[str, ...]) -> list:
35
43
  node: object = data
@@ -63,34 +71,17 @@ def _ledger_row(
63
71
  }
64
72
 
65
73
 
66
- def _own_section_ids(data: dict, omitted_fields: tuple[str, ...] = ()) -> set[str]:
67
- """Ids a section of the report already renders and anchors.
68
-
69
- The ledger is the fallback home for a cited row, so it must not claim an id
70
- that has one — two elements with the same anchor send half the links to the
71
- wrong place. `crossVerification.consensus` numbers its rows `CV-001`, a
72
- namespace of its own, so a `C-NNN` in the report has exactly one home.
73
-
74
- `omitted_fields` names task-block fields the HTML template does not render,
75
- so their ids do not count as anchored.
76
- """
77
- from ..report_contract import TASK_TYPE_DATA_PROPERTY
78
-
79
- found: set[str] = set()
80
- _collect_ids(data.get("clarificationItems", []), found)
81
- _collect_ids(data.get("agentActivity", []), found)
82
- property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
83
- if property_name:
84
- block = data.get(property_name, {})
85
- if omitted_fields and isinstance(block, dict):
86
- block = {
87
- key: value for key, value in block.items() if key not in omitted_fields
88
- }
89
- _collect_ids(block, found)
90
- return found
74
+ def _row_id(row: object) -> str | None:
75
+ if not isinstance(row, dict):
76
+ return None
77
+ for key in _ROW_ID_KEYS:
78
+ value = row.get(key)
79
+ if isinstance(value, str) and value:
80
+ return value
81
+ return None
91
82
 
92
83
 
93
- _OMITTED_TEXT_KEYS = (
84
+ _ROW_TEXT_KEYS = (
94
85
  "subject",
95
86
  "check",
96
87
  "item",
@@ -100,47 +91,136 @@ _OMITTED_TEXT_KEYS = (
100
91
  "summary",
101
92
  "need",
102
93
  "title",
94
+ "commitment",
95
+ "condition",
103
96
  )
104
97
 
105
98
 
106
- def _omitted_row_text(row: dict) -> str:
107
- """생략된 행에서 독자가 읽을 한 줄을 고른다."""
108
- for key in _OMITTED_TEXT_KEYS:
99
+ def _row_text(row: dict) -> str:
100
+ """전용 섹션이 없는 행에서 독자가 읽을 한 줄을 고른다."""
101
+ for key in _ROW_TEXT_KEYS:
109
102
  value = row.get(key)
110
103
  if isinstance(value, str) and value.strip():
111
104
  return value.strip()
112
105
  return ""
113
106
 
114
107
 
115
- def _collect_row_dicts(value: object, rows: dict[str, dict]) -> None:
108
+ def _task_block(data: dict) -> dict:
109
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
110
+
111
+ property_name = TASK_TYPE_DATA_PROPERTY.get(
112
+ (data.get("header") or {}).get("taskType", "")
113
+ )
114
+ block = data.get(property_name) if property_name else None
115
+ return block if isinstance(block, dict) else {}
116
+
117
+
118
+ def rows_at(data: dict, path: str) -> list[dict]:
119
+ """Every row dict at a dotted path from the record root.
120
+
121
+ A list along the way is walked implicitly — `implementationPlanning.
122
+ optionCandidates.fileStructure` reaches the file rows of every candidate —
123
+ so a template's `{% for %}` nesting and this path read the same rows.
124
+ """
125
+ nodes: list[object] = [data]
126
+ for part in path.split("."):
127
+ next_nodes: list[object] = []
128
+ for node in nodes:
129
+ if not isinstance(node, dict):
130
+ continue
131
+ value = node.get(part)
132
+ if isinstance(value, list):
133
+ next_nodes.extend(value)
134
+ elif isinstance(value, dict):
135
+ next_nodes.append(value)
136
+ nodes = next_nodes
137
+ return [node for node in nodes if isinstance(node, dict)]
138
+
139
+
140
+ def scoped_anchor_map(data: dict, path: str) -> dict[str, dict[str, str]]:
141
+ """`{parent id: {child id: anchor}}` for rows that repeat under every parent.
142
+
143
+ A direction's scope commitments are numbered `IC-001` … inside each
144
+ direction, so the same id sits in every ranked option. One page-global
145
+ `#id-IC-001` would land on whichever card came first; instead each card
146
+ anchors its own rows as `id-<parent>-<child>` and links the ids its prose
147
+ cites to those.
148
+ """
149
+ parent_path, _, child_key = path.rpartition(".")
150
+ out: dict[str, dict[str, str]] = {}
151
+ for parent in rows_at(data, parent_path):
152
+ parent_id = _row_id(parent)
153
+ children = parent.get(child_key)
154
+ if not parent_id or not _anchorable(parent_id) or not isinstance(children, list):
155
+ continue
156
+ for child in children:
157
+ child_id = _row_id(child)
158
+ if child_id and _anchorable(child_id):
159
+ out.setdefault(parent_id, {})[child_id] = f"id-{parent_id}-{child_id}"
160
+ return out
161
+
162
+
163
+ def _scoped_ids(data: dict, scoped_fields: tuple[str, ...]) -> set[str]:
164
+ found: set[str] = set()
165
+ for path in scoped_fields:
166
+ for children in scoped_anchor_map(data, path).values():
167
+ found.update(children)
168
+ return found
169
+
170
+
171
+ def _own_section_ids(data: dict, anchored_fields: tuple[str, ...]) -> set[str]:
172
+ """Ids a section of the report renders under a page-global anchor.
173
+
174
+ The clarification articles and the base template's activity table anchor
175
+ their rows on every page; the task template anchors the rows of the
176
+ fields its view declares. The ledger is the fallback home for a cited row,
177
+ so it must not claim an id that has one — two elements with the same
178
+ anchor send half the links to the wrong place.
179
+ """
180
+ found: set[str] = set()
181
+ _collect_ids(data.get("clarificationItems", []), found)
182
+ _collect_ids(data.get("agentActivity", []), found)
183
+ for path in anchored_fields:
184
+ for row in rows_at(data, path):
185
+ row_id = _row_id(row)
186
+ if row_id:
187
+ found.add(row_id)
188
+ return found
189
+
190
+
191
+ def _task_block_rows(value: object) -> Iterator[dict]:
192
+ """Every dict carrying a row id anywhere in the task block, document order."""
116
193
  if isinstance(value, dict):
117
- row_id = value.get("id") or value.get("activityId") or value.get("clarificationId")
118
- if isinstance(row_id, str) and row_id:
119
- rows.setdefault(row_id, value)
194
+ if _row_id(value):
195
+ yield value
120
196
  for nested in value.values():
121
- _collect_row_dicts(nested, rows)
197
+ yield from _task_block_rows(nested)
122
198
  elif isinstance(value, list):
123
199
  for nested in value:
124
- _collect_row_dicts(nested, rows)
200
+ yield from _task_block_rows(nested)
125
201
 
126
202
 
127
- def _omitted_rows(data: dict, omitted_fields: tuple[str, ...]) -> dict[str, dict]:
128
- from ..report_contract import TASK_TYPE_DATA_PROPERTY
203
+ def _unanchored_rows(
204
+ data: dict, owned: set[str], scoped: set[str]
205
+ ) -> dict[str, dict]:
206
+ """Task-block rows no section anchors, keyed by id, first occurrence wins.
129
207
 
130
- property_name = TASK_TYPE_DATA_PROPERTY.get(
131
- (data.get("header") or {}).get("taskType", "")
132
- )
133
- block = data.get(property_name) if property_name else None
134
- if not isinstance(block, dict):
135
- return {}
208
+ Their ids are still cited by prose — a validation check, a rollback step,
209
+ a candidate's commitment — so the ledger gives each a line to land on.
210
+ """
136
211
  rows: dict[str, dict] = {}
137
- for field in omitted_fields:
138
- _collect_row_dicts(block.get(field), rows)
212
+ for row in _task_block_rows(_task_block(data)):
213
+ row_id = _row_id(row)
214
+ if row_id in owned or row_id in scoped or row_id in rows:
215
+ continue
216
+ rows[row_id] = row
139
217
  return rows
140
218
 
141
219
 
142
220
  def evidence_index(
143
- data: dict, omitted_fields: tuple[str, ...] = ()
221
+ data: dict,
222
+ anchored_fields: tuple[str, ...] = (),
223
+ scoped_fields: tuple[str, ...] = (),
144
224
  ) -> dict[str, object]:
145
225
  """The rows the ledger carries, keyed by id.
146
226
 
@@ -148,11 +228,12 @@ def evidence_index(
148
228
  ledger exists so a cited id resolves to something the reader can read, and
149
229
  an id above a blank line resolves to nothing.
150
230
 
151
- `omitted_fields` are task-block arrays the HTML template does not render
152
- as their own section. Their rows still have to land somewhere, because
153
- prose cites them without a ledger entry the id in the body is dead text.
231
+ `anchored_fields` and `scoped_fields` are the view's declarations of what
232
+ its template anchors (see `HumanReportView`). Every other task-block row
233
+ with an id lands here, so a citation of it resolves to its own text.
154
234
  """
155
- owned = _own_section_ids(data, omitted_fields)
235
+ owned = _own_section_ids(data, anchored_fields)
236
+ scoped = _scoped_ids(data, scoped_fields)
156
237
  rows: dict[str, object] = {}
157
238
  for path, text_key, source_key, confidence_key, kind in _LEDGER_BLOCKS:
158
239
  for row in _dig(data, path):
@@ -164,26 +245,103 @@ def evidence_index(
164
245
  entry = _ledger_row(row, text_key, source_key, confidence_key, kind)
165
246
  if entry["text"]:
166
247
  rows[row_id] = entry
167
- for row_id, row in _omitted_rows(data, omitted_fields).items():
248
+ for row_id, row in _unanchored_rows(data, owned, scoped).items():
249
+ if row_id in rows:
250
+ continue
251
+ text = _row_text(row)
252
+ if not text:
253
+ continue
254
+ rows[row_id] = {
255
+ "id": row_id,
256
+ "kind": "record-row",
257
+ "text": text,
258
+ "codeEvidence": row.get("currentCodeEvidence") or [],
259
+ "source": "",
260
+ "confidence": "",
261
+ }
262
+ for row_id, row in _cited_summary_rows(data).items():
168
263
  if row_id in owned or row_id in rows:
169
264
  continue
170
- text = _omitted_row_text(row)
265
+ text = _joined(row.get("summary"))
171
266
  if not text:
172
267
  continue
173
268
  rows[row_id] = {
174
269
  "id": row_id,
175
- "kind": "plan-item",
270
+ "kind": "summary-point",
176
271
  "text": text,
177
272
  "codeEvidence": [],
178
- "source": "",
273
+ "source": _joined(row.get("source")),
179
274
  "confidence": "",
180
275
  }
181
276
  return rows
182
277
 
183
278
 
279
+ _SUMMARY_ID_RE = re.compile(r"\bP-\d{3,}\b")
280
+ _FINDING_ID_RE = re.compile(r"\b(F-\d{3,})\b")
281
+
282
+
283
+ def _cited_summary_rows(data: dict) -> dict[str, dict]:
284
+ """`summary[]` rows some other field of the record cites by id.
285
+
286
+ The summary is the AI-facing digest and does not render, but a writer
287
+ who names `P-005` in prose sends the reader after it — 2026-09-05 audit:
288
+ requirements-discovery and release-handoff pages carried such bare ids. A
289
+ cited row lands in the ledger; an uncited one stays out, so the ledger does
290
+ not duplicate the digest.
291
+ """
292
+ rows = {
293
+ row["id"]: row
294
+ for row in data.get("summary") or []
295
+ if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]
296
+ }
297
+ if not rows:
298
+ return {}
299
+ cited: set[str] = set()
300
+
301
+ def walk(node: object) -> None:
302
+ if isinstance(node, dict):
303
+ for value in node.values():
304
+ walk(value)
305
+ elif isinstance(node, list):
306
+ for value in node:
307
+ walk(value)
308
+ elif isinstance(node, str):
309
+ cited.update(match for match in _SUMMARY_ID_RE.findall(node) if match in rows)
310
+
311
+ walk({key: value for key, value in data.items() if key != "summary"})
312
+ return {row_id: row for row_id, row in rows.items() if row_id in cited}
313
+
314
+
315
+ def worker_finding_links(data: dict) -> dict[str, str]:
316
+ """`F-NNN` → the promoted evidence row whose `sourceItems` cite it.
317
+
318
+ A worker numbers its own findings `F-001` …; the record promotes the ones
319
+ the workers agreed on into `evidence.primary[]`, each naming its sources as
320
+ `<worker>:F-NNN`. Prose keeps citing the worker number — 15 to 20 times a
321
+ report in the 2026-09-05 audit — so when exactly one evidence row carries
322
+ that number, the citation links there. A number two rows share, or none
323
+ does, stays plain text: a guess would send the reader to the wrong row.
324
+ """
325
+ homes: dict[str, set[str]] = {}
326
+ for row in _dig(data, ("evidence", "primary")):
327
+ if not isinstance(row, dict):
328
+ continue
329
+ row_id = row.get("id")
330
+ if not isinstance(row_id, str) or not _anchorable(row_id):
331
+ continue
332
+ for item in row.get("sourceItems") or []:
333
+ for finding in _FINDING_ID_RE.findall(str(item)):
334
+ homes.setdefault(finding, set()).add(row_id)
335
+ return {
336
+ finding: f"#id-{next(iter(rows))}"
337
+ for finding, rows in homes.items()
338
+ if len(rows) == 1
339
+ }
340
+
341
+
184
342
  def _collect_ids(value: object, found: set[str]) -> None:
185
343
  if isinstance(value, dict):
186
- for key in ("id", "activityId", "clarificationId"):
344
+ for key in _ROW_ID_KEYS:
187
345
  row_id = value.get(key)
188
346
  if isinstance(row_id, str) and row_id:
189
347
  found.add(row_id)
@@ -199,61 +357,35 @@ def _anchorable(row_id: str) -> bool:
199
357
  return bool(row_id) and " " not in row_id and "/" not in row_id
200
358
 
201
359
 
202
- def _count_ids(value: object, counts: dict[str, int]) -> None:
203
- if isinstance(value, dict):
204
- for key in ("id", "activityId", "clarificationId"):
205
- row_id = value.get(key)
206
- if isinstance(row_id, str) and row_id:
207
- counts[row_id] = counts.get(row_id, 0) + 1
208
- for nested in value.values():
209
- _count_ids(nested, counts)
210
- elif isinstance(value, list):
211
- for nested in value:
212
- _count_ids(nested, counts)
213
-
214
-
215
- def _shared_task_block_ids(data: dict, omitted_fields: tuple[str, ...]) -> set[str]:
216
- """Ids several rows of the task block carry, so no single row owns them.
217
-
218
- A direction's scope commitments are numbered `IC-001` … inside each
219
- direction, and its planning invariants `PI-001` … likewise; the same id
220
- sits in every ranked option and every audited candidate. A link to
221
- `#id-IC-001` would land on whichever card came first, so the id stays
222
- plain text. A clarification id the block repeats (a requirements report
223
- lists `C-001` under two unresolved requirements) is not affected: the
224
- clarification article is its one home and keeps the anchor.
225
- """
226
- from ..report_contract import TASK_TYPE_DATA_PROPERTY
227
-
228
- property_name = TASK_TYPE_DATA_PROPERTY.get(
229
- (data.get("header") or {}).get("taskType", "")
230
- )
231
- block = data.get(property_name) if property_name else None
232
- if not isinstance(block, dict):
233
- return set()
234
- counts: dict[str, int] = {}
235
- _count_ids(
236
- {key: value for key, value in block.items() if key not in omitted_fields},
237
- counts,
238
- )
239
- owned_elsewhere: set[str] = set()
240
- _collect_ids(data.get("clarificationItems", []), owned_elsewhere)
241
- _collect_ids(data.get("agentActivity", []), owned_elsewhere)
242
- return {row_id for row_id, n in counts.items() if n > 1} - owned_elsewhere
360
+ def end_state_ids(data: dict) -> list[str]:
361
+ """The end-state ids this run judged, in `endStateCoverage` order."""
362
+ out: list[str] = []
363
+ for row in data.get("endStateCoverage") or []:
364
+ row_id = _row_id(row)
365
+ if row_id and _anchorable(row_id) and row_id not in out:
366
+ out.append(row_id)
367
+ return out
243
368
 
244
369
 
245
- def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
370
+ def anchor_index(
371
+ data: dict,
372
+ anchored_fields: tuple[str, ...] = (),
373
+ scoped_fields: tuple[str, ...] = (),
374
+ ) -> dict[str, str]:
246
375
  """Map every row a reader can reach to the anchor name that lands on it.
247
376
 
248
377
  Prose cites ids across section boundaries — a hotspot names a
249
378
  cross-verification finding, a quality row names a difference — so the
250
- target set spans the whole reader-facing report: the task's own sections,
251
- the clarifications, and every block the ledger takes in, including rows
252
- whose section the template left out.
379
+ target set spans the whole reader-facing report: the sections the view
380
+ declares anchored, the clarifications, the activity table, the end-state
381
+ section, and every row the ledger takes in.
253
382
 
254
- It stops there. `summary` is the AI-facing digest and
383
+ It stops there. `summary` is the AI-facing digest and does not render —
384
+ only a summary row some other field cites reaches the ledger — and
255
385
  `analysisCommon.scope` describes the analysis target rather than listing
256
- rows; neither renders, so a link to one would land nowhere.
386
+ rows, so a link to it would land nowhere. Rows under a
387
+ scoped field anchor inside their parent's card (`scoped_anchor_map`) and
388
+ take no page-global name.
257
389
 
258
390
  Cross-check rows are anchored `id-xv-<id>` by the base template — the
259
391
  prefix keeps a legacy consensus row still numbered `C-NNN` from sharing
@@ -262,9 +394,10 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
262
394
  pointing at the clarification.
263
395
  """
264
396
  found = (
265
- _own_section_ids(data, omitted_fields)
266
- - _shared_task_block_ids(data, omitted_fields)
267
- ) | set(evidence_index(data, omitted_fields))
397
+ _own_section_ids(data, anchored_fields)
398
+ | set(evidence_index(data, anchored_fields, scoped_fields))
399
+ | set(end_state_ids(data))
400
+ )
268
401
  index = {
269
402
  row_id: f"id-{row_id}"
270
403
  for row_id in sorted(found)
@@ -281,10 +414,5 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
281
414
  def analysis_review_ids(data: dict) -> tuple[str, ...]:
282
415
  found: set[str] = set()
283
416
  _collect_ids(data.get("analysisCommon", {}), found)
284
- task_type = data.get("header", {}).get("taskType", "")
285
- from ..report_contract import TASK_TYPE_DATA_PROPERTY
286
-
287
- property_name = TASK_TYPE_DATA_PROPERTY.get(task_type)
288
- if property_name:
289
- _collect_ids(data.get(property_name, {}), found)
417
+ _collect_ids(_task_block(data), found)
290
418
  return tuple(sorted(found))
@@ -26,6 +26,7 @@ import re
26
26
  from collections.abc import Container
27
27
  from pathlib import Path
28
28
 
29
+ from ..final_report_paths import final_report_data_path
29
30
  from ..json_boundary import JsonBoundaryError, load_owned_object
30
31
  from ..report_view_artifacts import html_view_path
31
32
  from ..scope_provenance import brief_end_state_rows
@@ -80,6 +81,9 @@ def _carry_in_record(source: Path) -> Path | None:
80
81
  """
81
82
  if source.name.endswith(".data.json"):
82
83
  return source
84
+ if source.name.startswith("final-report-") and source.name.endswith(".md"):
85
+ # 승인 계획은 열람본(`.md`)으로 가리킨다; 레코드는 그 형제다.
86
+ return final_report_data_path(source)
83
87
  match = _USER_RESPONSE_RE.match(source.name)
84
88
  if match is None or source.parent.name != "user-responses":
85
89
  return None
@@ -135,3 +139,120 @@ def carry_in_links(
135
139
  for cid in _clarification_ids(record)
136
140
  if cid not in exclude
137
141
  }
142
+
143
+
144
+ # The brief headings `scope_provenance.brief_end_state_rows` records as `section`;
145
+ # a coverage-only row gets the heading its id family belongs to.
146
+ _END_STATE_SECTIONS = {"EB": "Expected Behavior", "PB": "Preserved Behavior", "EO": "Expected Outcome"}
147
+
148
+
149
+ def end_state_table(brief_rows: list[dict[str, str]], data: dict) -> list[dict[str, object]]:
150
+ """The end-state section's rows: the brief's statements joined with this
151
+ run's `endStateCoverage` verdicts.
152
+
153
+ Brief order first, then ids the coverage table judges that the brief (or a
154
+ task with no brief file) did not list, so every `EB`/`PB`/`EO` the record
155
+ cites has a row here whether or not the brief was readable. A row without
156
+ a brief statement carries the coverage rationale as its text.
157
+ """
158
+ coverage: dict[str, dict] = {}
159
+ for row in data.get("endStateCoverage") or []:
160
+ if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]:
161
+ coverage.setdefault(row["id"], row)
162
+ out: list[dict[str, object]] = []
163
+ seen: set[str] = set()
164
+ for row in brief_rows:
165
+ seen.add(row["id"])
166
+ out.append({**row, "coverage": coverage.get(row["id"])})
167
+ for row_id, row in coverage.items():
168
+ if row_id in seen:
169
+ continue
170
+ out.append({
171
+ "id": row_id,
172
+ "section": _END_STATE_SECTIONS.get(row_id.split("-")[0], ""),
173
+ "statement": "",
174
+ "coverage": row,
175
+ })
176
+ return out
177
+
178
+
179
+ def selected_direction_links(
180
+ data: dict, data_path: Path, *, exclude: Container[str] = ()
181
+ ) -> dict[str, str]:
182
+ """Map the planning report's selected direction id to its card on the
183
+ option-selection page it came from.
184
+
185
+ A plan built with `--selected-direction` names that report in
186
+ `implementationPlanning.selectedDirectionRef.sourceReport` (task-relative)
187
+ and cites the direction as `IO-NNN` throughout — four bare mentions on
188
+ the 2026-09-05 dev-10626 planning page. The link exists only when the
189
+ source record is on disk; a plan whose source moved keeps the id as text.
190
+ """
191
+ reference = (data.get("implementationPlanning") or {}).get("selectedDirectionRef")
192
+ if not isinstance(reference, dict):
193
+ return {}
194
+ option_id = reference.get("optionId")
195
+ source = reference.get("sourceReport")
196
+ if not (isinstance(option_id, str) and option_id and isinstance(source, str) and source.strip()):
197
+ return {}
198
+ if option_id in exclude:
199
+ return {}
200
+ task_dir = _task_dir(data_path)
201
+ if task_dir is None:
202
+ return {}
203
+ # `sourceReport` names the reading copy (`.md`); the record is its sibling.
204
+ pointer = task_dir / source.strip()
205
+ record = pointer if pointer.name.endswith(".data.json") else final_report_data_path(pointer)
206
+ if not record.is_file():
207
+ return {}
208
+ page = html_view_path(record)
209
+ href = Path(os.path.relpath(page, data_path.resolve().parent)).as_posix()
210
+ return {option_id: f"{href}#id-{option_id}"}
211
+
212
+
213
+ def approved_plan_links(
214
+ data: dict, data_path: Path, *, exclude: Container[str] = ()
215
+ ) -> dict[str, str]:
216
+ """Map the ids of the approved plan an implementation report executed to
217
+ their anchors on that plan's page.
218
+
219
+ An implementation report cites the plan's checklist rows (`VC-NNN`), its
220
+ invariants (`PI-NNN`) and its steps throughout — the 2026-09-05 dev-10626
221
+ stage-1 page had eight such bare mentions — while defining none of them.
222
+ `implementation.approvedPlanReference.planFile` names the plan (project-
223
+ or task-relative, reading copy or record); when that record exists, every
224
+ id it defines that this page does not links to the plan's page.
225
+ """
226
+ reference = (data.get("implementation") or {}).get("approvedPlanReference")
227
+ plan_file = reference.get("planFile") if isinstance(reference, dict) else None
228
+ if not isinstance(plan_file, str) or not plan_file.strip():
229
+ return {}
230
+ pointer = Path(plan_file.strip())
231
+ if not pointer.name.endswith(".data.json"):
232
+ pointer = final_report_data_path(pointer)
233
+ task_dir = _task_dir(data_path)
234
+ project_root = _project_root(data_path)
235
+ record = next(
236
+ (
237
+ root / pointer
238
+ for root in (project_root, task_dir)
239
+ if root is not None and (root / pointer).is_file()
240
+ ),
241
+ None,
242
+ )
243
+ if record is None:
244
+ return {}
245
+ try:
246
+ plan = load_owned_object(record, artifact="approved plan record")
247
+ except JsonBoundaryError:
248
+ return {}
249
+ from .common import anchor_index
250
+ from .view_models.implementation_planning import ANCHORED_FIELDS
251
+
252
+ page = html_view_path(record)
253
+ href = Path(os.path.relpath(page, data_path.resolve().parent)).as_posix()
254
+ return {
255
+ row_id: f"{href}#{anchor}"
256
+ for row_id, anchor in anchor_index(plan, ANCHORED_FIELDS).items()
257
+ if row_id not in exclude
258
+ }
@@ -66,11 +66,17 @@ class HumanReportView:
66
66
  template_name: str
67
67
  context: dict[str, object]
68
68
  figures: tuple[FigureModel, ...]
69
- # Task-block fields this template leaves out of the HTML. Their rows still
70
- # exist in the data and the markdown, so prose keeps citing their ids — but
71
- # an anchor to a row this document never renders scrolls nowhere, which
72
- # reads as a broken report rather than as a deliberate omission.
73
- omitted_fields: tuple[str, ...] = ()
69
+ # Record fields (dotted paths from the record root, list steps implicit)
70
+ # whose rows this template anchors as `id-<row id>`. The anchor index is
71
+ # built from this declaration, not from a guess about the template: an id
72
+ # the record defines outside these paths lands in the evidence ledger, so
73
+ # a citation still resolves, and an id declared here without an anchor in
74
+ # the rendered page is what the index tests fail on.
75
+ anchored_fields: tuple[str, ...] = ()
76
+ # Fields whose rows repeat under every parent row (a direction's scope
77
+ # commitments). They anchor as `id-<parent>-<row>` inside the parent's
78
+ # card (`common.scoped_anchor_map`) and never take a page-global anchor.
79
+ scoped_anchor_fields: tuple[str, ...] = ()
74
80
 
75
81
 
76
82
  @dataclass(frozen=True)