okstra 0.189.0 → 0.189.2

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/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/architecture/storage-model.md +9 -0
  4. package/docs/architecture.md +2 -0
  5. package/docs/cli.md +4 -1
  6. package/docs/for-ai/skills/okstra-brief-gen.md +2 -0
  7. package/docs/project-structure-overview.md +3 -1
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/prompts/duties/direction-selection-worker.md +2 -2
  11. package/runtime/prompts/lead/report-writer.md +1 -1
  12. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  13. package/runtime/prompts/profiles/_common-contract.md +1 -0
  14. package/runtime/prompts/profiles/implementation-option-selection.md +2 -2
  15. package/runtime/python/okstra_ctl/analysis_packet.py +38 -0
  16. package/runtime/python/okstra_ctl/approval_decisions.py +85 -0
  17. package/runtime/python/okstra_ctl/group_context.py +223 -0
  18. package/runtime/python/okstra_ctl/implementation_options.py +35 -0
  19. package/runtime/python/okstra_ctl/model_io/renderers.py +12 -7
  20. package/runtime/python/okstra_ctl/recap.py +7 -1
  21. package/runtime/python/okstra_ctl/render.py +13 -0
  22. package/runtime/python/okstra_ctl/report_html/common.py +60 -4
  23. package/runtime/python/okstra_ctl/report_html/context_links.py +137 -0
  24. package/runtime/python/okstra_ctl/report_html/filters.py +22 -20
  25. package/runtime/python/okstra_ctl/report_html/render.py +18 -5
  26. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +7 -0
  27. package/runtime/python/okstra_ctl/report_synthesis_packet.py +5 -0
  28. package/runtime/python/okstra_ctl/report_translation.py +8 -0
  29. package/runtime/python/okstra_ctl/run.py +28 -0
  30. package/runtime/python/okstra_ctl/scope_provenance.py +28 -6
  31. package/runtime/python/okstra_ctl/timeline_runs.py +71 -0
  32. package/runtime/python/okstra_ctl/wizard.py +2 -1
  33. package/runtime/skills/okstra-brief-gen/SKILL.md +29 -1
  34. package/runtime/skills/okstra-inspect/facets/history.md +1 -0
  35. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  36. package/runtime/templates/reports/group-context.template.md +31 -0
  37. package/runtime/templates/reports/html/assets/base.css +10 -0
  38. package/runtime/templates/reports/html/base.template.html +10 -0
  39. package/runtime/templates/reports/html/i18n/en.json +71 -2
  40. package/runtime/templates/reports/html/i18n/ko.json +71 -2
  41. package/runtime/templates/reports/html/macros/forms.html +1 -0
  42. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +55 -11
  43. package/runtime/validators/validate-brief.py +9 -0
@@ -0,0 +1,223 @@
1
+ """task-group 맥락 문서 — 그룹의 모든 task 가 공유하는 배경을 워커 입력에 싣는다.
2
+
3
+ `.okstra/briefs/<task-group>/group-context.md` 한 파일이 그룹이 존재하는 이유,
4
+ 성과 척도, 그룹 전체 금지선, 티켓 간 관계를 담는다. 브리프는 티켓 하나의 범위를
5
+ 말하므로 그룹 단위의 "왜" 는 어느 브리프에도 없다(2026-09-04 실측, fontsninja
6
+ `cache` 그룹: 브리프 15개 중 가용성 사고를 말한 것이 0개였고 option-selection 이
7
+ 분모를 페이지당 요청 수로 바꿔 판단했다).
8
+
9
+ 세 소비자가 이 모듈 하나를 쓴다.
10
+
11
+ - `okstra group-context init` — 템플릿에서 뼈대를 쓴다(있으면 거절).
12
+ - `validators/validate-brief.py` — 프론트매터 `type: group-context` 를 만나면
13
+ `validate_group_context` 로 검사한다.
14
+ - prepare(`run.py`) — 파일이 있으면 같은 검사를 통과해야 진행하고, 통과하면
15
+ `instruction-set/task-group-context.md` 로 복사해 analysis packet 에 싣는다.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import datetime as dt
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from .brief_frontmatter import read_brief_frontmatter
26
+ from .ids import slugify_task_segment
27
+ from .paths import find_asset_root
28
+
29
+ GROUP_CONTEXT_FILENAME = "group-context.md"
30
+ GROUP_CONTEXT_TYPE = "group-context"
31
+ GROUP_CONTEXT_GENERATOR = "okstra-brief-gen"
32
+ INSTRUCTION_SET_FILENAME = "task-group-context.md"
33
+ REQUIRED_FRONTMATTER_KEYS = ("type", "task-group", "created", "generator")
34
+ SECTIONS = (
35
+ "Why This Group Exists",
36
+ "Definition of Better",
37
+ "Group-Wide Constraints",
38
+ "Ticket Relations",
39
+ )
40
+ # 나머지 두 절은 `_(none)_` 이 허용된다 — 금지선이나 관계가 없는 그룹도 있다.
41
+ MUST_BE_FILLED = SECTIONS[:2]
42
+ TEMPLATE_RELATIVE = ("templates", "reports", "group-context.template.md")
43
+
44
+ _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
45
+ _NONE_MARKERS = {"_(none)_", ""}
46
+
47
+
48
+ def briefs_root(project_root: Path) -> Path:
49
+ return Path(project_root) / ".okstra" / "briefs"
50
+
51
+
52
+ def group_context_file(project_root: Path, task_group: str) -> Path:
53
+ """그룹 맥락 문서의 정본 경로. 디렉터리는 브리프와 같은 slug 를 쓴다."""
54
+ return briefs_root(project_root) / slugify_task_segment(task_group) / GROUP_CONTEXT_FILENAME
55
+
56
+
57
+ def is_group_context_file(path: Path) -> bool:
58
+ return path.name == GROUP_CONTEXT_FILENAME
59
+
60
+
61
+ def validate_group_context(path: Path, root: Path) -> list[str]:
62
+ """결함 목록. 비어 있으면 통과. 결함마다 사람이 고칠 자리를 이름한다."""
63
+ text = _HTML_COMMENT_RE.sub("", path.read_text(encoding="utf-8"))
64
+ errors: list[str] = []
65
+ frontmatter = read_brief_frontmatter(path)
66
+ if not frontmatter:
67
+ return ["frontmatter: missing or malformed (a `---` block must open line 1)"]
68
+ missing = [key for key in REQUIRED_FRONTMATTER_KEYS if key not in frontmatter]
69
+ if missing:
70
+ errors.append(f"frontmatter missing keys: {missing}")
71
+ if frontmatter.get("type") != GROUP_CONTEXT_TYPE:
72
+ errors.append(
73
+ f"frontmatter type must be {GROUP_CONTEXT_TYPE!r}, got {frontmatter.get('type')!r}"
74
+ )
75
+ if frontmatter.get("generator") != GROUP_CONTEXT_GENERATOR:
76
+ errors.append(
77
+ f"frontmatter generator must be {GROUP_CONTEXT_GENERATOR!r}, "
78
+ f"got {frontmatter.get('generator')!r}"
79
+ )
80
+ errors.extend(_path_errors(path, root, frontmatter.get("task-group", "")))
81
+ errors.extend(_section_errors(text))
82
+ return errors
83
+
84
+
85
+ def _path_errors(path: Path, root: Path, task_group: str) -> list[str]:
86
+ errors: list[str] = []
87
+ if path.name != GROUP_CONTEXT_FILENAME:
88
+ errors.append(f"file must be named {GROUP_CONTEXT_FILENAME!r}, got {path.name!r}")
89
+ try:
90
+ relative = path.resolve().relative_to(Path(root).resolve())
91
+ except ValueError:
92
+ return errors + [f"file is not under the briefs root {root}"]
93
+ if len(relative.parts) != 2:
94
+ errors.append(
95
+ "file must sit directly under its task-group directory "
96
+ f"(`<briefs>/<task-group>/{GROUP_CONTEXT_FILENAME}`), got {relative}"
97
+ )
98
+ return errors
99
+ expected = slugify_task_segment(task_group)
100
+ if task_group and relative.parts[0] != expected:
101
+ errors.append(
102
+ f"task-group directory segment {relative.parts[0]!r} does not match the "
103
+ f"slugified frontmatter task-group {expected!r}"
104
+ )
105
+ return errors
106
+
107
+
108
+ def _section_errors(text: str) -> list[str]:
109
+ bodies = _section_bodies(text)
110
+ errors: list[str] = []
111
+ for heading in SECTIONS:
112
+ if heading not in bodies:
113
+ errors.append(f"missing section `## {heading}`")
114
+ continue
115
+ lines = [line.strip() for line in bodies[heading].splitlines() if line.strip()]
116
+ placeholders = [line for line in lines if _is_template_placeholder(line)]
117
+ if placeholders:
118
+ errors.append(
119
+ f"section `## {heading}` still carries a template placeholder "
120
+ f"({placeholders[0][:40]}...); fill it or delete the file"
121
+ )
122
+ continue
123
+ if heading in MUST_BE_FILLED and all(line in _NONE_MARKERS for line in lines):
124
+ errors.append(f"section `## {heading}` must be filled; `_(none)_` is not accepted here")
125
+ return errors
126
+
127
+
128
+ def _section_bodies(text: str) -> dict[str, str]:
129
+ """`## ` 제목 → 본문. `## ` 만 절을 닫는다(validate-brief `section_body` 와 같은 규칙)."""
130
+ bodies: dict[str, str] = {}
131
+ current: str | None = None
132
+ buffer: list[str] = []
133
+ for line in text.splitlines():
134
+ if line.startswith("## "):
135
+ if current is not None:
136
+ bodies[current] = "\n".join(buffer)
137
+ current = line[3:].strip()
138
+ buffer = []
139
+ elif current is not None:
140
+ buffer.append(line)
141
+ if current is not None:
142
+ bodies[current] = "\n".join(buffer)
143
+ return bodies
144
+
145
+
146
+ def _is_template_placeholder(line: str) -> bool:
147
+ bare = line.lstrip("-").strip()
148
+ return bare.startswith("<") and bare.endswith(">")
149
+
150
+
151
+ def render_skeleton(task_group: str, created: str) -> str:
152
+ root = find_asset_root(TEMPLATE_RELATIVE)
153
+ if root is None:
154
+ raise FileNotFoundError(
155
+ "group-context template not found in any okstra asset root: "
156
+ + "/".join(TEMPLATE_RELATIVE)
157
+ )
158
+ template = root.joinpath(*TEMPLATE_RELATIVE).read_text(encoding="utf-8")
159
+ slug = slugify_task_segment(task_group)
160
+ return template.replace("<task-group>", slug).replace("<YYYY-MM-DD>", created)
161
+
162
+
163
+ def init_group_context(project_root: Path, task_group: str, *, today: dt.date) -> Path:
164
+ """뼈대를 쓴다. 이미 있으면 `FileExistsError` — 채운 문서를 덮어쓰지 않는다."""
165
+ target = group_context_file(project_root, task_group)
166
+ if target.exists():
167
+ raise FileExistsError(str(target))
168
+ target.parent.mkdir(parents=True, exist_ok=True)
169
+ target.write_text(render_skeleton(task_group, today.isoformat()), encoding="utf-8")
170
+ return target
171
+
172
+
173
+ def _init_command(args: argparse.Namespace) -> int:
174
+ project_root = Path(args.project_root).resolve()
175
+ try:
176
+ target = init_group_context(project_root, args.task_group, today=dt.date.today())
177
+ except FileExistsError as exc:
178
+ print(
179
+ f"group-context: already exists: {exc}\n"
180
+ "Edit that file instead; delete it first if you want a fresh skeleton.",
181
+ file=sys.stderr,
182
+ )
183
+ return 2
184
+ print(f"group context skeleton: {target}")
185
+ print("Fill these sections (one `<...>` placeholder line each):")
186
+ for heading in SECTIONS:
187
+ note = "required" if heading in MUST_BE_FILLED else "`_(none)_` allowed"
188
+ print(f" - ## {heading} ({note})")
189
+ print(
190
+ "Validate with: python3 ~/.okstra/lib/validators/validate-brief.py "
191
+ f"{target} --briefs-root {briefs_root(project_root)}"
192
+ )
193
+ print(
194
+ "Every run of task-group "
195
+ f"`{slugify_task_segment(args.task_group)}` refuses to prepare while a placeholder "
196
+ "line remains; delete the file if the group needs no context."
197
+ )
198
+ return 0
199
+
200
+
201
+ def build_parser() -> argparse.ArgumentParser:
202
+ parser = argparse.ArgumentParser(
203
+ prog="okstra group-context",
204
+ description=(
205
+ "Create the task-group context skeleton beside the group's briefs "
206
+ "(`.okstra/briefs/<task-group>/group-context.md`)."
207
+ ),
208
+ )
209
+ sub = parser.add_subparsers(dest="command", required=True)
210
+ init = sub.add_parser("init", help="write the skeleton from the template; refuses an existing file")
211
+ init.add_argument("--project-root", required=True, help="project root that holds `.okstra/`")
212
+ init.add_argument("--task-group", required=True, help="task-group name; slugified for the directory")
213
+ init.set_defaults(func=_init_command)
214
+ return parser
215
+
216
+
217
+ def main(argv: list[str] | None = None) -> int:
218
+ args = build_parser().parse_args(argv)
219
+ return args.func(args)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
@@ -244,14 +244,49 @@ def _validate_option_feasibility(
244
244
  errors.append(f"{option_id} safetyBlockers must be empty")
245
245
  if require_valid and option.get("unresolvedFeasibilityFacts"):
246
246
  errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
247
+ copied = _copied_votes(votes)
248
+ for first, second in copied:
249
+ errors.append(
250
+ f"{option_id} feasibilityVotes for {first} and {second} are identical; "
251
+ "each vote carries that analyser's own rationale and counterevidence"
252
+ )
247
253
  return (
248
254
  all_participated
249
255
  and feasible >= MIN_FEASIBLE_VOTES
250
256
  and not option.get("safetyBlockers")
251
257
  and not option.get("unresolvedFeasibilityFacts")
258
+ and not copied
252
259
  )
253
260
 
254
261
 
262
+ def _copied_votes(votes: Sequence[object]) -> list[tuple[str, str]]:
263
+ """Pairs of analysers whose votes share one rationale and counterevidence.
264
+
265
+ A vote is that analyser's own finding. The writer synthesizes the row from
266
+ each result, and a run (2026-09-04, dev-10626) shipped three votes whose
267
+ sentences matched to the letter — one worker's text copied under the other
268
+ two names — so the votes said nothing a single vote did not. `uncertain`
269
+ votes are exempt: two analysers that never evaluated a candidate say so in
270
+ the same words legitimately.
271
+ """
272
+ seen: dict[tuple[str, str], str] = {}
273
+ copied: list[tuple[str, str]] = []
274
+ for vote in votes:
275
+ if not isinstance(vote, Mapping) or vote.get("verdict") == "uncertain":
276
+ continue
277
+ key = (
278
+ str(vote.get("rationale") or "").strip(),
279
+ str(vote.get("counterevidence") or "").strip(),
280
+ )
281
+ if not any(key):
282
+ continue
283
+ worker = str(vote.get("worker") or "?")
284
+ first = seen.setdefault(key, worker)
285
+ if first != worker:
286
+ copied.append((first, worker))
287
+ return copied
288
+
289
+
255
290
  def _validate_candidate(
256
291
  option: Mapping[str, object],
257
292
  original_ids: Sequence[str],
@@ -20,6 +20,7 @@ from ..fixed_text import line as _line, scalar as _value
20
20
  from ..json_boundary import JsonBoundaryError, load_owned_object
21
21
  from ..paths import okstra_home
22
22
  from ..recap import assemble_recap
23
+ from ..timeline_runs import current_run_facts
23
24
  from ..worker_artifacts import worker_provider_id
24
25
  from .lines import (
25
26
  _assignment_line,
@@ -51,7 +52,7 @@ from .references import (
51
52
  )
52
53
 
53
54
 
54
- def _latest_timeline_run(manifest_path: Path) -> Mapping[str, Any]:
55
+ def _latest_timeline_run(project_root: Path, manifest_path: Path) -> Mapping[str, Any]:
55
56
  timeline_path = manifest_path.parent / "history" / "timeline.json"
56
57
  if not timeline_path.is_file():
57
58
  return {}
@@ -59,10 +60,11 @@ def _latest_timeline_run(manifest_path: Path) -> Mapping[str, Any]:
59
60
  runs = timeline.get("runs")
60
61
  if not isinstance(runs, list):
61
62
  return {}
62
- return next(
63
+ latest = next(
63
64
  (run for run in reversed(runs) if isinstance(run, Mapping)),
64
- {},
65
+ None,
65
66
  )
67
+ return current_run_facts(project_root, latest) if latest is not None else {}
66
68
 
67
69
 
68
70
  def _overview_rows(
@@ -79,15 +81,18 @@ def _overview_rows(
79
81
  project_root, manifest_path, expected_task_key=task_key, artifact="task catalog"
80
82
  )
81
83
  manifest = load_owned_object(manifest_path, artifact="task manifest")
82
- latest_run = _latest_timeline_run(manifest_path)
84
+ latest_run = _latest_timeline_run(project_root, manifest_path)
83
85
  _, group, _ = parse_task_key(task_key)
84
86
  workflow = _mapping(manifest, "workflow")
85
87
  next_phase = _mapping(workflow, "nextRecommendedPhase")
88
+ # task 단위 현재 사실은 task-manifest 가 권위다(validate-run 이 run 종료
89
+ # 시 거기에 쓴다). timeline 의 마지막 run 은 task-manifest 에 그 필드가
90
+ # 없을 때의 폴백이고, 그 값도 run-manifest 로 덮어쓴 현재 사실이다.
86
91
  row = {
87
92
  "taskKey": task_key,
88
93
  "taskGroup": manifest.get("taskGroup") or task.get("taskGroup") or group,
89
94
  "taskType": manifest.get("taskType") or latest_run.get("taskType"),
90
- "latestRunStatus": latest_run.get("status") or manifest.get("latestRunStatus"),
95
+ "latestRunStatus": manifest.get("latestRunStatus") or latest_run.get("status"),
91
96
  "updatedAt": manifest.get("updatedAt") or task.get("updatedAt") or latest_run.get("runTimestamp"),
92
97
  "latestRunManifestPath": pointer.get("latestRunManifestPath"),
93
98
  "workCategory": manifest.get("workCategory"),
@@ -98,7 +103,7 @@ def _overview_rows(
98
103
  "nextPhaseStatus": next_phase.get("status"),
99
104
  "nextPhaseRationale": next_phase.get("rationale"),
100
105
  "awaitingApproval": workflow.get("awaitingApproval"),
101
- "latestReportRecordPath": latest_run.get("reportRecordPath") or manifest.get("latestReportRecordPath"),
106
+ "latestReportRecordPath": manifest.get("latestReportRecordPath") or latest_run.get("reportRecordPath"),
102
107
  "latestResumeCommandPath": latest_run.get("resumeCommandPath") or manifest.get("latestResumeCommandPath"),
103
108
  "workStatus": manifest.get("workStatus"),
104
109
  "lastRun": latest_run.get("runTimestamp"),
@@ -336,7 +341,7 @@ def render_history_input(
336
341
  )
337
342
  manifest, runs = _selected_task_data(project_root, task_ref)
338
343
  blocks: list[str] = []
339
- for index, run in enumerate(runs, 1):
344
+ for index, run in enumerate((current_run_facts(project_root, run) for run in runs), 1):
340
345
  blocks.append(
341
346
  f"\n## Run {index}\n\n"
342
347
  + _line("Run timestamp", run.get("runTimestamp"))
@@ -22,6 +22,7 @@ from okstra_project import read_task_key
22
22
  from okstra_ctl.run_context import dir_flock
23
23
  from okstra_ctl.final_report_paths import timeline_report_record_rel
24
24
  from okstra_ctl.task_target import resolve_task_root, project_rel
25
+ from okstra_ctl.timeline_runs import current_run_facts
25
26
 
26
27
  NOTE_KINDS = ("verification-evidence", "decision-draft", "analysis-note")
27
28
 
@@ -97,7 +98,12 @@ def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
97
98
 
98
99
 
99
100
  def assemble_recap(task_root: Path, project_root: Path) -> dict:
100
- runs = _load_timeline(task_root)
101
+ # timeline 항목의 status / workflowSnapshot / reportRecordPath 는 준비 시점
102
+ # 값이다. 각 run 의 종료 상태는 그 run 의 run-manifest 에서 덮어쓴다.
103
+ runs = [
104
+ current_run_facts(project_root, run) if isinstance(run, dict) else run
105
+ for run in _load_timeline(task_root)
106
+ ]
101
107
  transitions = []
102
108
  prev_phase = ""
103
109
  latest_states: dict = {}
@@ -1962,6 +1962,19 @@ def _initialize_report_ledgers(ctx: Mapping[str, Any], manifest: Mapping[str, An
1962
1962
  "activeClarifications": [],
1963
1963
  "carriedDecisions": [],
1964
1964
  })
1965
+ # 이월 결정은 여기서 심는다. 리드가 손으로 carry 하던 동안 대부분의
1966
+ # run 이 빈 채로 갔고, 리포트는 행 없는 C-NNN 을 인용했다.
1967
+ response_value = str(ctx.get("CLARIFICATION_RESPONSE_PATH") or "")
1968
+ if response_value:
1969
+ from .approval_decisions import seed_carried_decisions
1970
+
1971
+ seed_carried_decisions(
1972
+ approval_path,
1973
+ Path(response_value),
1974
+ source_run_ref=str(
1975
+ ctx.get("CLARIFICATION_RESPONSE_RELATIVE_PATH") or response_value
1976
+ ),
1977
+ )
1965
1978
  activity_value = str(ctx.get("LEAD_EVENTS_PATH") or "")
1966
1979
  activity_path = Path(activity_value)
1967
1980
  if activity_value and not activity_path.is_file():
@@ -199,6 +199,49 @@ def _anchorable(row_id: str) -> bool:
199
199
  return bool(row_id) and " " not in row_id and "/" not in row_id
200
200
 
201
201
 
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
243
+
244
+
202
245
  def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
203
246
  """Map every row a reader can reach to the anchor name that lands on it.
204
247
 
@@ -211,15 +254,28 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
211
254
  It stops there. `summary` is the AI-facing digest and
212
255
  `analysisCommon.scope` describes the analysis target rather than listing
213
256
  rows; neither renders, so a link to one would land nowhere.
257
+
258
+ Cross-check rows are anchored `id-xv-<id>` by the base template — the
259
+ prefix keeps a legacy consensus row still numbered `C-NNN` from sharing
260
+ an element id with the clarification of that number — so they carry that
261
+ name here, and a legacy row whose id a clarification already owns keeps
262
+ pointing at the clarification.
214
263
  """
215
- found = _own_section_ids(data, omitted_fields) | set(
216
- evidence_index(data, omitted_fields)
217
- )
218
- return {
264
+ found = (
265
+ _own_section_ids(data, omitted_fields)
266
+ - _shared_task_block_ids(data, omitted_fields)
267
+ ) | set(evidence_index(data, omitted_fields))
268
+ index = {
219
269
  row_id: f"id-{row_id}"
220
270
  for row_id in sorted(found)
221
271
  if isinstance(row_id, str) and _anchorable(row_id)
222
272
  }
273
+ for block in ("consensus", "differences"):
274
+ for row in _dig(data, ("crossVerification", block)):
275
+ row_id = row.get("id") if isinstance(row, dict) else None
276
+ if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
277
+ index[row_id] = f"id-xv-{row_id}"
278
+ return index
223
279
 
224
280
 
225
281
  def analysis_review_ids(data: dict) -> tuple[str, ...]:
@@ -0,0 +1,137 @@
1
+ """Rows and links for the ids a report cites but does not define.
2
+
3
+ The human HTML renders from ``data.json``, and every id the record defines
4
+ gets an anchor there. Two id families are cited on almost every page and
5
+ defined on none of them:
6
+
7
+ * the brief's end-state ids (``EB-001``, ``PB-001``, ``EO-001``) — the
8
+ denominator of every requirement-coverage table. The sentence behind each
9
+ id exists only in the task brief; no phase repeats it.
10
+ * the clarification ids a previous run settled (``C-005``) — the record points
11
+ at that run through ``clarificationCarryIn.sourceFile`` and cites the ids
12
+ in its prose, but only the lead's ``carriedDecisions`` put a row for them
13
+ in this record, and a run that carried none leaves the ids as dead text.
14
+
15
+ Both homes are okstra-owned files inside the same task directory, so the
16
+ renderer follows the two pointers the run pinned — ``taskBriefPath`` is
17
+ always ``<task>/instruction-set/task-brief.md`` (``path_hints.py``), and the
18
+ carry-in record is named by the report itself — and only to give a cited id
19
+ a place to land. Neither read is a precondition: a missing or unreadable file
20
+ yields no rows and no links, never a render failure.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ from collections.abc import Container
27
+ from pathlib import Path
28
+
29
+ from ..json_boundary import JsonBoundaryError, load_owned_object
30
+ from ..report_view_artifacts import html_view_path
31
+ from ..scope_provenance import brief_end_state_rows
32
+
33
+ _USER_RESPONSE_RE = re.compile(r"^user-response-(?P<task_type>.+)-(?P<seq>\d{3,})\.md$")
34
+
35
+
36
+ def _task_dir(data_path: Path) -> Path | None:
37
+ """The task directory a report lives under, or None outside the layout.
38
+
39
+ A report sits at ``<task>/runs/<type>/reports/`` — or one level deeper
40
+ for an implementation stage, ``runs/implementation/stage-<N>/reports/`` —
41
+ so the nearest ``runs`` ancestor names the task directory either way.
42
+ """
43
+ for parent in data_path.resolve().parents:
44
+ if parent.name == "runs":
45
+ return parent.parent
46
+ return None
47
+
48
+
49
+ def _project_root(data_path: Path) -> Path | None:
50
+ """The project root, or None when the report is not under ``.okstra/``.
51
+
52
+ Every okstra-owned artifact lives under ``<PROJECT_ROOT>/.okstra/``, and a
53
+ carry-in ``sourceFile`` is recorded relative to that root.
54
+ """
55
+ for parent in data_path.resolve().parents:
56
+ if parent.name == ".okstra":
57
+ return parent.parent
58
+ return None
59
+
60
+
61
+ def brief_end_states(data_path: Path) -> list[dict[str, str]]:
62
+ """The brief's end-state rows, in brief order, as template-ready dicts."""
63
+ task_dir = _task_dir(data_path)
64
+ if task_dir is None:
65
+ return []
66
+ brief = task_dir / "instruction-set" / "task-brief.md"
67
+ return [
68
+ {"id": row.id, "section": row.section, "statement": row.statement}
69
+ for row in brief_end_state_rows(brief)
70
+ ]
71
+
72
+
73
+ def _carry_in_record(source: Path) -> Path | None:
74
+ """The report record a carry-in pointer resolves to.
75
+
76
+ The pointer names either the prior run's record itself or the
77
+ user-responses sidecar exported from that run's page; the sidecar sits in
78
+ ``runs/<type>/user-responses/`` beside the run's ``reports/`` directory
79
+ and carries the run's task type and seq in its name.
80
+ """
81
+ if source.name.endswith(".data.json"):
82
+ return source
83
+ match = _USER_RESPONSE_RE.match(source.name)
84
+ if match is None or source.parent.name != "user-responses":
85
+ return None
86
+ task_type, seq = match.group("task_type"), match.group("seq")
87
+ return source.parent.parent / "reports" / f"final-report-{task_type}-{seq}.data.json"
88
+
89
+
90
+ def _clarification_ids(record: Path) -> list[str]:
91
+ """The clarification ids the carry-in record defines, or none.
92
+
93
+ The record is read through the owned-JSON boundary like every report
94
+ record; a record that fails it is a record this page cannot link into,
95
+ not a reason to refuse this page.
96
+ """
97
+ try:
98
+ payload = load_owned_object(record, artifact="carry-in report record")
99
+ except JsonBoundaryError:
100
+ return []
101
+ rows = payload.get("clarificationItems")
102
+ if not isinstance(rows, list):
103
+ return []
104
+ return [
105
+ row["id"]
106
+ for row in rows
107
+ if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]
108
+ ]
109
+
110
+
111
+ def carry_in_links(
112
+ data: dict, data_path: Path, *, exclude: Container[str] = ()
113
+ ) -> dict[str, str]:
114
+ """Map each clarification id the carry-in record defines to its anchor
115
+ on that record's HTML page, as an href relative to this report's page.
116
+
117
+ ``exclude`` names the ids this document already anchors — a row the lead
118
+ did carry keeps its in-page link, and the prior run's page is only for
119
+ the ids this page has no row for.
120
+ """
121
+ carry_in = data.get("clarificationCarryIn")
122
+ source_value = carry_in.get("sourceFile") if isinstance(carry_in, dict) else None
123
+ if not isinstance(source_value, str) or not source_value.strip():
124
+ return {}
125
+ root = _project_root(data_path)
126
+ if root is None:
127
+ return {}
128
+ record = _carry_in_record(root / source_value.strip())
129
+ if record is None or not record.is_file():
130
+ return {}
131
+ page = html_view_path(record)
132
+ href = Path(os.path.relpath(page, data_path.resolve().parent)).as_posix()
133
+ return {
134
+ cid: f"{href}#id-{cid}"
135
+ for cid in _clarification_ids(record)
136
+ if cid not in exclude
137
+ }