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
@@ -12,12 +12,18 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
12
12
  from ..final_report_paths import final_report_data_path, translation_sidecar_path
13
13
  from ..final_report_schema import load_schema_for_data, validate
14
14
  from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
15
- from ..report_translation import overlay
15
+ from ..report_translation import overlay, rendered_english_lines
16
16
  from ..report_view_artifacts import user_responses_dir_for_report
17
17
  from ..usage_cells import format_duration_ms
18
18
  from ..json_boundary import load_owned_object
19
- from .common import anchor_index
20
- from .context_links import brief_end_states, carry_in_links
19
+ from .common import anchor_index, worker_finding_links
20
+ from .context_links import (
21
+ approved_plan_links,
22
+ brief_end_states,
23
+ carry_in_links,
24
+ end_state_table,
25
+ selected_direction_links,
26
+ )
21
27
  from .filters import (
22
28
  code_evidence,
23
29
  enum_label,
@@ -136,21 +142,41 @@ def render_v2_html_view(
136
142
  # report's clarifications only fill ids this page has no row for.
137
143
  links = {
138
144
  row_id: f"#{name}"
139
- for row_id, name in anchor_index(data, view.omitted_fields).items()
145
+ for row_id, name in anchor_index(
146
+ data, view.anchored_fields, view.scoped_anchor_fields
147
+ ).items()
140
148
  }
141
149
  brief_rows = brief_end_states(data_path)
142
150
  for row in brief_rows:
143
151
  links.setdefault(row["id"], f"#id-{row['id']}")
144
152
  links.update(carry_in_links(data, data_path, exclude=links))
153
+ links.update(selected_direction_links(data, data_path, exclude=links))
154
+ links.update(approved_plan_links(data, data_path, exclude=links))
155
+ # 워커 finding 번호(`F-NNN`)는 그 번호를 출처로 적은 승격 근거 행이 하나뿐일
156
+ # 때만 그 행으로 간다. 이 페이지가 정의한 id 가 우선이다.
157
+ links.update({
158
+ finding: href
159
+ for finding, href in worker_finding_links(data).items()
160
+ if finding not in links
161
+ })
145
162
  chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
146
163
  translate = make_jinja_global(chrome)
147
164
  env.globals["t"] = translate
148
165
  env.filters["code_evidence"] = code_evidence
149
166
  env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
150
167
  env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
151
- env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, links)
152
- env.filters["inline_code"] = lambda value: inline_code(value, links)
153
- env.filters["paragraphs"] = lambda value: paragraphs(value, links)
168
+ # `extra` is a card-scoped link map (`common.scoped_anchor_map`): a
169
+ # direction's own commitment rows outrank the page index for the ids its
170
+ # prose cites, and nothing else on the page can see them.
171
+ env.filters["evidence_refs"] = lambda refs, extra=None: evidence_refs(
172
+ refs, {**links, **(extra or {})}
173
+ )
174
+ env.filters["inline_code"] = lambda value, extra=None: inline_code(
175
+ value, {**links, **(extra or {})}
176
+ )
177
+ env.filters["paragraphs"] = lambda value, extra=None: paragraphs(
178
+ value, {**links, **(extra or {})}
179
+ )
154
180
  response_js = (root / "report.js").read_text(encoding="utf-8")
155
181
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
156
182
  source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
@@ -163,9 +189,15 @@ def render_v2_html_view(
163
189
  "sourceData": source_data,
164
190
  "dataSha256": _sha256(data_path),
165
191
  "clarificationItems": data.get("clarificationItems", []),
166
- # 브리프의 최종 상태 행. 모든 phase 가 EB/PB/EO 아이디를 인용하지만
167
- # 문장은 브리프에만 있으므로 여기서 한 번 그려 인용에 착지점을 준다.
168
- "briefEndStates": brief_rows,
192
+ # 브리프의 최종 상태 행과 이 run 의 `endStateCoverage` 판정을 한 표로.
193
+ # 모든 phase 가 EB/PB/EO 아이디를 인용하지만 문장은 브리프에만 있고,
194
+ # "이 run 이 그 상태를 덮었는가" 는 coverage 표에만 있다.
195
+ "endStates": end_state_table(brief_rows, data),
196
+ # 에이전트 활동 표. 계획 리포트의 결정 카드가 `#id-A-NNN` 으로 가리키고
197
+ # 다른 phase 의 산문도 인용하므로 모든 타입의 페이지가 같은 절을 그린다.
198
+ "agentActivity": [
199
+ row for row in data.get("agentActivity") or [] if isinstance(row, dict)
200
+ ],
169
201
  # 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
170
202
  # 감사 모드에만 두면 판정이 사용자에게 안 보인다.
171
203
  "crossVerification": data.get("crossVerification") or {},
@@ -178,9 +210,18 @@ def render_v2_html_view(
178
210
  }
179
211
  output_path = _html_path(data_path)
180
212
  document = env.get_template(route.template_name).render(**context)
181
- output_path.write_text(
182
- inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
183
- )
213
+ document = inject_report_index(document, label=translate("base.contents"))
214
+ output_path.write_text(document, encoding="utf-8")
215
+ if lang != "en":
216
+ # 사이드카 보고("left in English")는 추출 범위 안만 센다. 페이지에 실제로
217
+ # 남은 영어 문장은 여기서 세어 같은 stdout 에 낸다 — 두 숫자가 갈리면
218
+ # `PROSE_KEYS` 밖의 필드가 화면에 나가고 있다는 뜻이다.
219
+ english = rendered_english_lines(document)
220
+ sys.stdout.write(
221
+ f"rendered {len(english)} line(s) still in English on the {lang} page"
222
+ + (f" (e.g. {english[0][:80]!r})" if english else "")
223
+ + "\n"
224
+ )
184
225
  if context["clarificationItems"] or context.get("directionSelection"):
185
226
  # The footer tells the reader to drop the exported file here, so the
186
227
  # directory has to exist before they go looking for it.
@@ -5,6 +5,12 @@ from ..common import analysis_review_ids, evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
10
+ ANCHORED_FIELDS = (
11
+ "changeImpactAnalysis.impactItems",
12
+ )
13
+
8
14
 
9
15
  def _impact_figure(change: dict):
10
16
  nodes = [
@@ -39,11 +45,12 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
39
45
  "narrative": change["userNarrative"],
40
46
  "blastRadiusFigure": figure,
41
47
  "analysisReviewIds": analysis_review_ids(data),
42
- "evidenceIndex": evidence_index(data),
48
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
43
49
  }
44
50
  return HumanReportView(
45
51
  task_type="change-impact-analysis",
46
52
  template_name="html/tasks/change-impact-analysis.template.html",
47
53
  context=context,
48
54
  figures=(figure,),
55
+ anchored_fields=ANCHORED_FIELDS,
49
56
  )
@@ -7,6 +7,12 @@ from ..common import evidence_index
7
7
  from ..models import HumanReportView, VisualEdge, VisualNode
8
8
  from ..visualizations import cause_graph_figure
9
9
 
10
+ # Record fields this template anchors as `id-<row id>` (see
11
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
12
+ ANCHORED_FIELDS = (
13
+ "errorAnalysis.causeCandidates",
14
+ )
15
+
10
16
 
11
17
  _LEAD_CLAUSE = re.compile(r"\s[—–-]\s|(?<=[.。!?])\s")
12
18
  _MAX_LABEL_CHARS = 44
@@ -92,11 +98,12 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
92
98
  "error": error,
93
99
  "narrative": error["userNarrative"],
94
100
  "causeFigure": figure,
95
- "evidenceIndex": evidence_index(data),
101
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
96
102
  }
97
103
  return HumanReportView(
98
104
  "error-analysis",
99
105
  "html/tasks/error-analysis.template.html",
100
106
  context,
101
107
  (figure,),
108
+ anchored_fields=ANCHORED_FIELDS,
102
109
  )
@@ -5,6 +5,15 @@ from ..common import analysis_review_ids, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
10
+ ANCHORED_FIELDS = (
11
+ "featureAnalysis.flows",
12
+ "featureAnalysis.domainRules",
13
+ "featureAnalysis.stateChanges",
14
+ "featureAnalysis.testCoverage",
15
+ )
16
+
8
17
 
9
18
  _FLOW_KINDS = {"normal": "Normal path", "alternative": "Alternative path", "failure": "Failure path"}
10
19
 
@@ -31,11 +40,12 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
31
40
  "narrative": feature["userNarrative"],
32
41
  "flowFigure": figure,
33
42
  "analysisReviewIds": analysis_review_ids(data),
34
- "evidenceIndex": evidence_index(data),
43
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
35
44
  }
36
45
  return HumanReportView(
37
46
  task_type="feature-analysis",
38
47
  template_name="html/tasks/feature-analysis.template.html",
39
48
  context=context,
40
49
  figures=(figure,),
50
+ anchored_fields=ANCHORED_FIELDS,
41
51
  )
@@ -6,6 +6,16 @@ from ..common import evidence_index
6
6
  from ..models import HumanReportView, VisualNode
7
7
  from ..visualizations import coverage_figure
8
8
 
9
+ # Record fields this template anchors as `id-<row id>` (see
10
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
11
+ ANCHORED_FIELDS = (
12
+ "finalVerification.validationEvidence",
13
+ "finalVerification.addedSurfaceAudit",
14
+ "finalVerification.acceptanceBlockers",
15
+ "finalVerification.residualRisk",
16
+ "finalVerdict.conditionalAcceptanceConditions",
17
+ )
18
+
9
19
 
10
20
  def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
11
21
  return tuple(
@@ -31,15 +41,17 @@ def build_final_verification_view(data: dict) -> HumanReportView:
31
41
  "humanSummary": data["humanSummary"],
32
42
  "verdict": data["verdictCard"],
33
43
  "verdictToken": verdict_token,
44
+ "finalVerdict": data["finalVerdict"],
34
45
  "final": final,
35
46
  "narrative": final["userNarrative"],
36
47
  "coverageFigure": figure,
37
48
  "releaseAllowed": release_handoff_allowed(data),
38
- "evidenceIndex": evidence_index(data),
49
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
39
50
  }
40
51
  return HumanReportView(
41
52
  "final-verification",
42
53
  "html/tasks/final-verification.template.html",
43
54
  context,
44
55
  (figure,),
56
+ anchored_fields=ANCHORED_FIELDS,
45
57
  )
@@ -5,6 +5,13 @@ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import change_map_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
10
+ ANCHORED_FIELDS = (
11
+ "implementation.requirementCoverage",
12
+ "implementation.outOfPlanEdits",
13
+ )
14
+
8
15
 
9
16
  _FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
10
17
 
@@ -33,11 +40,12 @@ def build_implementation_view(data: dict) -> HumanReportView:
33
40
  "implementation": implementation,
34
41
  "narrative": implementation["userNarrative"],
35
42
  "changeFigure": figure,
36
- "evidenceIndex": evidence_index(data),
43
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
37
44
  }
38
45
  return HumanReportView(
39
46
  "implementation",
40
47
  "html/tasks/implementation.template.html",
41
48
  context,
42
49
  (figure,),
50
+ anchored_fields=ANCHORED_FIELDS,
43
51
  )
@@ -1,9 +1,31 @@
1
1
  """Human-first implementation-direction comparison view."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import evidence_index
4
+ from ..common import evidence_index, scoped_anchor_map
5
5
  from ..models import HumanReportView
6
6
 
7
+ # Record fields this template anchors as `id-<row id>` (see
8
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
9
+ ANCHORED_FIELDS = (
10
+ "implementationOptionSelection.rankedOptions",
11
+ "implementationOptionSelection.candidateAudit",
12
+ )
13
+ SCOPED_ANCHOR_FIELDS = (
14
+ "implementationOptionSelection.rankedOptions.scopeCommitments",
15
+ "implementationOptionSelection.rankedOptions.planningInvariants",
16
+ "implementationOptionSelection.candidateAudit.scopeCommitments",
17
+ )
18
+
19
+
20
+ def _card_links(data: dict) -> dict[str, dict[str, str]]:
21
+ links: dict[str, dict[str, str]] = {}
22
+ for path in SCOPED_ANCHOR_FIELDS:
23
+ for parent_id, children in scoped_anchor_map(data, path).items():
24
+ links.setdefault(parent_id, {}).update(
25
+ {child_id: f"#{anchor}" for child_id, anchor in children.items()}
26
+ )
27
+ return links
28
+
7
29
 
8
30
  def build_implementation_option_selection_view(data: dict) -> HumanReportView:
9
31
  selection = data["implementationOptionSelection"]
@@ -29,11 +51,16 @@ def build_implementation_option_selection_view(data: dict) -> HumanReportView:
29
51
  ),
30
52
  None,
31
53
  ),
32
- "evidenceIndex": evidence_index(data),
54
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS, SCOPED_ANCHOR_FIELDS),
55
+ # 카드별 링크 맵. 방향마다 `IC-001` 이 다시 시작하므로 그 카드 안의
56
+ # 인용은 그 카드의 행으로만 간다.
57
+ "cardLinks": _card_links(data),
33
58
  }
34
59
  return HumanReportView(
35
60
  task_type="implementation-option-selection",
36
61
  template_name="html/tasks/implementation-option-selection.template.html",
37
62
  context=context,
38
63
  figures=(),
64
+ anchored_fields=ANCHORED_FIELDS,
65
+ scoped_anchor_fields=SCOPED_ANCHOR_FIELDS,
39
66
  )
@@ -89,13 +89,18 @@ def _stage_figure(planning: dict):
89
89
  return stage_map_figure(nodes=nodes, edges=edges, title="Implementation stage dependencies")
90
90
 
91
91
 
92
- # 승인자가 보지 않는 구현자·사고 대응 표. 계획 본문 검증은 판정과 근거가
93
- # 승인 판단이라 여기 두지 않는다.
94
- _OMITTED_FIELDS = (
95
- "validationChecklist",
96
- "crossProjectDependencies",
97
- "dependencyMigrationRisk",
98
- "rollbackStrategy",
92
+ # Record fields this template anchors as `id-<row id>` (see
93
+ # `HumanReportView.anchored_fields`). The implementer-facing tables the
94
+ # approver does not read — validation checklist, cross-project dependencies,
95
+ # migration risk, rollback — are not drawn, so their rows land in the ledger.
96
+ ANCHORED_FIELDS = (
97
+ "implementationPlanning.directionRealization.fileStructure",
98
+ "implementationPlanning.directionRealization.planningInvariants",
99
+ "implementationPlanning.optionCandidates.fileStructure",
100
+ "implementationPlanning.designPreparation.items",
101
+ "implementationPlanning.requirementCoverage",
102
+ "implementationPlanning.planBodyVerification.planItems",
103
+ "implementationPlanning.supersessionLedger",
99
104
  )
100
105
 
101
106
 
@@ -131,12 +136,12 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
131
136
  row["activityId"]: row for row in activities if row.get("activityId")
132
137
  },
133
138
  "decisionCards": decision_cards,
134
- "evidenceIndex": evidence_index(data, _OMITTED_FIELDS),
139
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
135
140
  }
136
141
  return HumanReportView(
137
142
  "implementation-planning",
138
143
  "html/tasks/implementation-planning.template.html",
139
144
  context,
140
145
  (figure,) if figure is not None else (),
141
- _OMITTED_FIELDS,
146
+ anchored_fields=ANCHORED_FIELDS,
142
147
  )
@@ -5,6 +5,12 @@ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import matrix_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
10
+ ANCHORED_FIELDS = (
11
+ "improvementDiscovery.candidates",
12
+ )
13
+
8
14
 
9
15
  def _candidate_nodes(improvement: dict) -> tuple[VisualNode, ...]:
10
16
  return tuple(
@@ -32,11 +38,12 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
32
38
  "improvement": improvement,
33
39
  "narrative": improvement["userNarrative"],
34
40
  "matrixFigure": figure,
35
- "evidenceIndex": evidence_index(data),
41
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
36
42
  }
37
43
  return HumanReportView(
38
44
  "improvement-discovery",
39
45
  "html/tasks/improvement-discovery.template.html",
40
46
  context,
41
47
  (figure,),
48
+ anchored_fields=ANCHORED_FIELDS,
42
49
  )
@@ -5,6 +5,19 @@ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import dependency_figure, infrastructure_figure, workflow_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger. The
10
+ # architecture figure anchors component nodes; the infrastructure figure's
11
+ # store and external nodes carry synthetic ids and anchor nothing.
12
+ ANCHORED_FIELDS = (
13
+ "projectAnalysis.components",
14
+ "projectAnalysis.internalInterfaces",
15
+ "projectAnalysis.workflows",
16
+ "projectAnalysis.rankedHotspots",
17
+ "projectAnalysis.qualityCoverage",
18
+ "projectAnalysis.featureIndex",
19
+ )
20
+
8
21
 
9
22
  def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
10
23
  return tuple(
@@ -130,11 +143,12 @@ def build_project_analysis_view(data: dict) -> HumanReportView:
130
143
  "workflowFigure": workflows,
131
144
  "narrative": project["userNarrative"],
132
145
  "architectureFigure": architecture,
133
- "evidenceIndex": evidence_index(data),
146
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
134
147
  }
135
148
  return HumanReportView(
136
149
  task_type="project-analysis",
137
150
  template_name="html/tasks/project-analysis.template.html",
138
151
  context=context,
139
152
  figures=tuple(f for f in (architecture, infrastructure, workflows) if f),
153
+ anchored_fields=ANCHORED_FIELDS,
140
154
  )
@@ -7,6 +7,11 @@ from ..common import evidence_index
7
7
  from ..models import HumanReportView, VisualNode
8
8
  from ..visualizations import timeline_figure
9
9
 
10
+ # Record fields this template anchors as `id-<row id>` (see
11
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
12
+ ANCHORED_FIELDS = (
13
+ )
14
+
10
15
 
11
16
  def _safe_http_url(value: str) -> str:
12
17
  parsed = urlparse(value)
@@ -37,11 +42,12 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
37
42
  "narrative": handoff["userNarrative"],
38
43
  "handoffFigure": figure,
39
44
  "prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
40
- "evidenceIndex": evidence_index(data),
45
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
41
46
  }
42
47
  return HumanReportView(
43
48
  "release-handoff",
44
49
  "html/tasks/release-handoff.template.html",
45
50
  context,
46
51
  (figure,),
52
+ anchored_fields=ANCHORED_FIELDS,
47
53
  )
@@ -5,6 +5,13 @@ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import decision_flow_figure
7
7
 
8
+ # Record fields this template anchors as `id-<row id>` (see
9
+ # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
10
+ ANCHORED_FIELDS = (
11
+ "requirementsDiscovery.confirmedRequirements",
12
+ "requirementsDiscovery.unresolvedRequirements",
13
+ )
14
+
8
15
 
9
16
  def _routing_figure(data: dict, requirements: dict):
10
17
  current = data["header"]["taskKey"]
@@ -47,11 +54,12 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
47
54
  "narrative": requirements["userNarrative"],
48
55
  "routingFigure": figure,
49
56
  "clarificationItems": data.get("clarificationItems", []),
50
- "evidenceIndex": evidence_index(data),
57
+ "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
51
58
  }
52
59
  return HumanReportView(
53
60
  "requirements-discovery",
54
61
  "html/tasks/requirements-discovery.template.html",
55
62
  context,
56
63
  (figure,),
64
+ anchored_fields=ANCHORED_FIELDS,
57
65
  )
@@ -24,6 +24,8 @@ from typing import Any, Iterator, Mapping, NamedTuple
24
24
  # Values the renderer reads as text and nothing else.
25
25
  PROSE_KEYS = frozenset({
26
26
  "acceptance",
27
+ "action",
28
+ "actions",
27
29
  "addedWork",
28
30
  "alternativesConsidered",
29
31
  "answer",
@@ -31,18 +33,23 @@ PROSE_KEYS = frozenset({
31
33
  "approvalDisposition",
32
34
  "approvalEvidence",
33
35
  "architectureBoundaries",
36
+ "assumptions",
34
37
  "behavior",
35
38
  "blastRadius",
36
39
  "blockReason",
37
40
  "blockedReason",
41
+ "blockers",
38
42
  "body",
43
+ "boundary",
39
44
  "briefEvidence",
45
+ "canonical",
40
46
  "carryIn",
41
47
  "causeSummary",
42
48
  "change",
43
49
  "check",
44
50
  "claim",
45
51
  "commitment",
52
+ "complexity",
46
53
  "condition",
47
54
  "confirmingSignal",
48
55
  "conformanceExemption",
@@ -53,6 +60,7 @@ PROSE_KEYS = frozenset({
53
60
  "coreReason",
54
61
  "counterevidence",
55
62
  "decision",
63
+ "decisions",
56
64
  "declinedFixRecommendations",
57
65
  "description",
58
66
  "details",
@@ -75,15 +83,22 @@ PROSE_KEYS = frozenset({
75
83
  "expectedOutcome",
76
84
  "expectedResult",
77
85
  "expectedVerification",
86
+ "explanation",
78
87
  "fact",
79
88
  "failureHandling",
89
+ "falsifyingEvidenceChecked",
80
90
  "finalConclusion",
81
91
  "goal",
92
+ "guardrails",
82
93
  "headline",
83
94
  "howToStart",
84
95
  "hypothesis",
96
+ "ifStillOpen",
97
+ "injectedAs",
98
+ "interfaces",
85
99
  "item",
86
100
  "justification",
101
+ "knownFacts",
87
102
  "label",
88
103
  "mitigation",
89
104
  "motivation",
@@ -108,15 +123,23 @@ PROSE_KEYS = frozenset({
108
123
  "reason",
109
124
  "rejectedSummary",
110
125
  "rejectingSignal",
126
+ "relation",
127
+ "replacedInTest",
128
+ "replacedWith",
129
+ "requestKind",
111
130
  "requiredDecision",
112
131
  "requiredWork",
113
132
  "requirement",
114
133
  "resolution",
115
134
  "responsibility",
135
+ "reversibility",
136
+ "risk",
116
137
  "rolloutCost",
117
138
  "routingRecommendation",
118
- "selfFixNote",
119
139
  "satisfaction",
140
+ "scope",
141
+ "selfFixNote",
142
+ "sliceValue",
120
143
  "stageTitle",
121
144
  "stageValidation",
122
145
  "statement",
@@ -136,6 +159,7 @@ PROSE_KEYS = frozenset({
136
159
  "triggerSignal",
137
160
  "unblockCondition",
138
161
  "userChoice",
162
+ "userConstraints",
139
163
  "userInput",
140
164
  "userText",
141
165
  "verification",
@@ -426,6 +450,39 @@ def overlay(
426
450
  HANGUL_PROSE_LIMIT = 0.20
427
451
 
428
452
 
453
+ _RENDERED_SKIP_RE = re.compile(
454
+ r"<script.*?</script>|<style.*?</style>|<svg.*?</svg>|<pre.*?</pre>|<option.*?</option>",
455
+ re.DOTALL,
456
+ )
457
+ _TEXT_NODE_RE = re.compile(r">([^<>]{40,})<")
458
+ _ENTITY_RE = re.compile(r"&[#A-Za-z0-9]+;")
459
+
460
+
461
+ def rendered_english_lines(html: str, *, min_words: int = 6) -> list[str]:
462
+ """Text nodes of a rendered page that carry no Hangul and read as a sentence.
463
+
464
+ `OverlayReport.untranslated` only sees the pointers the extractor offered,
465
+ so a reader-facing field outside `PROSE_KEYS` never shows up there — the
466
+ 2026-09-05 audit found 40 plan-item explanations and 29 step actions in
467
+ English on a page that reported "0 left in English". This reads the page
468
+ itself. Scripts, styles, figures, code blocks and `<option>` values are
469
+ skipped: figure labels and response values are meant to stay verbatim.
470
+ Inline `<code>` is stripped so a sentence around a path still counts.
471
+ """
472
+ body = html.split("<main", 1)[-1]
473
+ body = _RENDERED_SKIP_RE.sub(" ", body)
474
+ body = re.sub(r"</?code>", "", body)
475
+ lines: list[str] = []
476
+ for match in _TEXT_NODE_RE.finditer(body):
477
+ text = _ENTITY_RE.sub(" ", match.group(1)).strip()
478
+ if len(text.split()) < min_words:
479
+ continue
480
+ if any("가" <= char <= "힣" for char in text):
481
+ continue
482
+ lines.append(text)
483
+ return lines
484
+
485
+
429
486
  def hangul_share(data: Mapping[str, Any]) -> tuple[float, int]:
430
487
  """Return the Hangul share of the report's authored prose, and its length."""
431
488
  text = "".join(extract(data).values())
@@ -4742,6 +4742,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4742
4742
  relative_to_project_root(Path(inp.selected_direction_path), project_root)
4743
4743
  if inp.selected_direction_path else ""
4744
4744
  )
4745
+ approved_plan_relative = (
4746
+ relative_to_project_root(Path(inp.approved_plan_path), project_root)
4747
+ if inp.approved_plan_path else ""
4748
+ )
4745
4749
 
4746
4750
  forbidden_by_phase = load_phase_forbidden(workspace_root)
4747
4751
 
@@ -4761,6 +4765,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4761
4765
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
4762
4766
  "SELECTED_DIRECTION_PATH": inp.selected_direction_path,
4763
4767
  "SELECTED_DIRECTION_RELATIVE_PATH": selected_direction_relative,
4768
+ "APPROVED_PLAN_PATH": inp.approved_plan_path,
4769
+ "APPROVED_PLAN_RELATIVE_PATH": approved_plan_relative,
4764
4770
  **_reverify_scope_ctx(inp.reverify_scope),
4765
4771
  "BRIEF_FILE_PATH": str(inp.brief_path),
4766
4772
  "BRIEF_RELATIVE_PATH": brief_relative,