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.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/report-writer.md +8 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +5 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +12 -0
- package/runtime/python/okstra_ctl/render.py +24 -7
- package/runtime/python/okstra_ctl/report_assembly.py +10 -4
- package/runtime/python/okstra_ctl/report_html/common.py +241 -113
- package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
- package/runtime/python/okstra_ctl/report_html/models.py +11 -5
- package/runtime/python/okstra_ctl/report_html/render.py +54 -13
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
- package/runtime/python/okstra_ctl/report_translation.py +58 -1
- package/runtime/python/okstra_ctl/run.py +6 -0
- package/runtime/python/okstra_ctl/worker_audit_check.py +23 -8
- package/runtime/schemas/final-report-v2.0.schema.json +4 -0
- package/runtime/schemas/final-report-v3.0.schema.json +4 -0
- package/runtime/templates/reports/html/base.template.html +13 -3
- package/runtime/templates/reports/html/i18n/en.json +43 -5
- package/runtime/templates/reports/html/i18n/ko.json +43 -5
- package/runtime/templates/reports/html/macros/forms.html +4 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
- package/runtime/validators/validate-report-views.py +30 -1
- 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
|
|
17
|
-
#
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
"""
|
|
108
|
-
for key in
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
rows.setdefault(row_id, value)
|
|
194
|
+
if _row_id(value):
|
|
195
|
+
yield value
|
|
120
196
|
for nested in value.values():
|
|
121
|
-
|
|
197
|
+
yield from _task_block_rows(nested)
|
|
122
198
|
elif isinstance(value, list):
|
|
123
199
|
for nested in value:
|
|
124
|
-
|
|
200
|
+
yield from _task_block_rows(nested)
|
|
125
201
|
|
|
126
202
|
|
|
127
|
-
def
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
138
|
-
|
|
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,
|
|
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
|
-
`
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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": "
|
|
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
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(
|
|
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
|
|
251
|
-
the clarifications,
|
|
252
|
-
|
|
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
|
|
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,
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
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)
|