okstra 0.189.3 → 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 (38) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +1 -1
  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-planning.md +1 -1
  8. package/runtime/python/okstra_ctl/render.py +24 -7
  9. package/runtime/python/okstra_ctl/report_assembly.py +10 -4
  10. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  11. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  12. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  13. package/runtime/python/okstra_ctl/report_html/render.py +54 -13
  14. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  15. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  16. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  17. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  18. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  19. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  20. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  21. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  22. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  23. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  24. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  25. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  26. package/runtime/python/okstra_ctl/run.py +6 -0
  27. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  28. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  29. package/runtime/templates/reports/html/base.template.html +13 -3
  30. package/runtime/templates/reports/html/i18n/en.json +43 -5
  31. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  32. package/runtime/templates/reports/html/macros/forms.html +4 -4
  33. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  34. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  35. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  36. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  37. package/runtime/validators/validate-report-views.py +30 -1
  38. package/runtime/validators/validate-run.py +113 -3
@@ -44,13 +44,13 @@
44
44
  </header>
45
45
  <main id="main-content" data-report-role="human-main">
46
46
  {% block human_content %}{% endblock %}
47
- {% if briefEndStates %}
47
+ {% if endStates %}
48
48
  <section data-report-section="brief-end-states">
49
49
  <h2>{{ t('base.brief-end-states') }}</h2>
50
50
  <p class="section-lede">{{ t('base.brief-end-states-intro') }}</p>
51
51
  <table>
52
- <thead><tr><th scope="col">{{ t('macros.layout.id') }}</th><th scope="col">{{ t('base.brief-statement') }}</th></tr></thead>
53
- <tbody>{% for row in briefEndStates %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('base.brief-section'), row.section | enum_label('briefSection'))]) }}<td>{{ row.statement | inline_code }}</td></tr>{% endfor %}</tbody>
52
+ <thead><tr><th scope="col">{{ t('macros.layout.id') }}</th><th scope="col">{{ t('base.brief-statement') }}</th><th scope="col">{{ t('base.end-state-coverage') }}</th></tr></thead>
53
+ <tbody>{% for row in endStates %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('base.brief-section'), row.section | enum_label('briefSection'))]) }}<td>{{ row.statement | inline_code }}</td><td>{% if row.coverage %}<span class="status status-{{ row.coverage.disposition }}">{{ row.coverage.disposition | enum_label('endStateDisposition') }}</span>{% if row.coverage.get("coveredBy") %} · {{ t('base.end-state-covered-by') }} {{ row.coverage.coveredBy | inline_code }}{% endif %}{% if row.coverage.get("blockedBy") %} · {{ t('base.end-state-blocked-by') }} {{ (row.coverage.blockedBy.get("kind") if row.coverage.blockedBy is mapping else row.coverage.blockedBy) | inline_code }}{% if row.coverage.blockedBy is mapping and row.coverage.blockedBy.get("ref") %} {{ row.coverage.blockedBy.ref | inline_code }}{% endif %}{% endif %}{% if row.coverage.get("rationale") %}<p class="evidence-refs">{{ row.coverage.rationale | inline_code }}</p>{% endif %}{% else %}{{ t('base.end-state-not-judged') }}{% endif %}</td></tr>{% endfor %}</tbody>
54
54
  </table>
55
55
  </section>
56
56
  {% endif %}
@@ -67,6 +67,16 @@
67
67
  {% endif %}
68
68
  </section>
69
69
  {% endif %}
70
+ {% if agentActivity %}
71
+ <section data-report-section="agent-activity" data-reader-kind="audit">
72
+ <h2>{{ t('base.agent-activity') }}</h2>
73
+ <p class="section-lede">{{ t('base.agent-activity-intro') }}</p>
74
+ <table>
75
+ <thead><tr><th scope="col">{{ t('base.agent') }}</th><th scope="col">{{ t('base.activity') }}</th></tr></thead>
76
+ <tbody>{% for row in agentActivity %}<tr id="id-{{ row.activityId }}">{{ row_key(pairs=[(t('macros.layout.id'), row.activityId), (t('base.agent'), row.agent), (t('macros.layout.kind'), row.kind | enum_label('activityKind'))], status_name=t('macros.layout.status'), status_raw=row.outcome, status_text=row.outcome | enum_label('activityOutcome')) }}<td>{{ row.summary | inline_code }}{% set refs = (row.get("evidenceRefs") or []) + (row.get("clarificationRefs") or []) %}{% if refs %}<p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ refs | evidence_refs }}</p>{% endif %}</td></tr>{% endfor %}</tbody>
77
+ </table>
78
+ </section>
79
+ {% endif %}
70
80
  {{ clarification_responses(clarificationItems) }}
71
81
  {% if evidenceIndex %}
72
82
  <section data-report-section="evidence-ledger" data-reader-kind="audit">
@@ -88,7 +88,8 @@
88
88
  "cross-check-dissent": "Workers disagreed",
89
89
  "missing-information": "Missing information",
90
90
  "follow-up": "Follow-up",
91
- "plan-item": "Plan item"
91
+ "record-row": "Record row",
92
+ "summary-point": "Summary point"
92
93
  },
93
94
  "optionCriterion": {
94
95
  "requirement-fit": "Requirement fit",
@@ -128,6 +129,28 @@
128
129
  "Expected Behavior": "Expected behavior",
129
130
  "Preserved Behavior": "Preserved behavior",
130
131
  "Expected Outcome": "Expected outcome"
132
+ },
133
+ "activityKind": {
134
+ "worker-dispatched": "Worker dispatched",
135
+ "worker-completed": "Worker completed",
136
+ "verification-round-completed": "Verification round completed",
137
+ "self-fix-applied": "Self-fix applied",
138
+ "lead-correction-applied": "Lead correction applied",
139
+ "user-decision-required": "User decision required",
140
+ "user-decision-evaluated": "User decision evaluated"
141
+ },
142
+ "activityOutcome": {
143
+ "pending": "Pending",
144
+ "completed": "Completed",
145
+ "failed": "Failed",
146
+ "blocked": "Blocked",
147
+ "resolved": "Resolved"
148
+ },
149
+ "endStateDisposition": {
150
+ "addressed": "Addressed",
151
+ "deferred": "Deferred",
152
+ "not-applicable": "Not applicable",
153
+ "blocked": "Blocked"
131
154
  }
132
155
  },
133
156
  "enumHint": {
@@ -163,9 +186,17 @@
163
186
  "drop-that-file-into": ". Drop that file into",
164
187
  "and-the-next-run-picks-your-answers-up-on-it": "and the next run picks your answers up on its own.",
165
188
  "brief-end-states": "End states the brief pinned",
166
- "brief-end-states-intro": "The requirement ids this report cites (EB, PB, EO) name these brief items, quoted verbatim from the task brief.",
189
+ "brief-end-states-intro": "The end states this report cites (EB, PB, EO): the brief's own wording, and what this run concluded about each.",
167
190
  "brief-section": "Section",
168
- "brief-statement": "Statement"
191
+ "brief-statement": "Statement",
192
+ "end-state-coverage": "This run's verdict",
193
+ "end-state-covered-by": "covered by",
194
+ "end-state-blocked-by": "blocked by",
195
+ "end-state-not-judged": "not judged in this run",
196
+ "agent-activity": "Agent activity",
197
+ "agent-activity-intro": "What each agent was asked to do in this run and how it ended. Decision cards and prose cite these rows by id.",
198
+ "agent": "Agent",
199
+ "activity": "Activity"
169
200
  },
170
201
  "runUsage": {
171
202
  "heading": "What this run cost",
@@ -359,7 +390,13 @@
359
390
  "uncommitted-state": "Uncommitted state",
360
391
  "per-stage-reports": "Per-stage reports",
361
392
  "release-path": "Release path",
362
- "the-current-verdict-does-not-allow-release-h": "The current verdict does not allow release-handoff to start. Clear the blocker or the condition first."
393
+ "the-current-verdict-does-not-allow-release-h": "The current verdict does not allow release-handoff to start. Clear the blocker or the condition first.",
394
+ "next-step": "Next step",
395
+ "conditions-for-acceptance": "Conditions for acceptance",
396
+ "conditions-for-acceptance-intro": "Acceptance stands only while each condition below is met; a row that blocks release-handoff must be evidenced before handoff.",
397
+ "condition": "Condition",
398
+ "evidence-required": "Evidence required",
399
+ "blocks-release-handoff": "Blocks release-handoff"
363
400
  },
364
401
  "implementation-option-selection": {
365
402
  "original-requirements": "Original requirement coverage",
@@ -513,7 +550,8 @@
513
550
  "stages": "Stages",
514
551
  "status": "Status",
515
552
  "extract": "Extract",
516
- "keep": "Keep as-is"
553
+ "keep": "Keep as-is",
554
+ "planning-invariants": "Planning invariants"
517
555
  },
518
556
  "implementation": {
519
557
  "what-was-delivered": "What was delivered",
@@ -88,7 +88,8 @@
88
88
  "cross-check-dissent": "작업자 간 이견",
89
89
  "missing-information": "빠진 정보",
90
90
  "follow-up": "후속 작업",
91
- "plan-item": "계획 항목"
91
+ "record-row": "레코드 행",
92
+ "summary-point": "요약 항목"
92
93
  },
93
94
  "optionCriterion": {
94
95
  "requirement-fit": "요구사항 적합",
@@ -128,6 +129,28 @@
128
129
  "Expected Behavior": "기대 동작",
129
130
  "Preserved Behavior": "보존 동작",
130
131
  "Expected Outcome": "기대 산출"
132
+ },
133
+ "activityKind": {
134
+ "worker-dispatched": "워커 배치",
135
+ "worker-completed": "워커 완료",
136
+ "verification-round-completed": "검증 라운드 완료",
137
+ "self-fix-applied": "자체 수정 적용",
138
+ "lead-correction-applied": "리드 교정 적용",
139
+ "user-decision-required": "사용자 결정 필요",
140
+ "user-decision-evaluated": "사용자 결정 평가"
141
+ },
142
+ "activityOutcome": {
143
+ "pending": "대기",
144
+ "completed": "완료",
145
+ "failed": "실패",
146
+ "blocked": "차단",
147
+ "resolved": "해결"
148
+ },
149
+ "endStateDisposition": {
150
+ "addressed": "충족",
151
+ "deferred": "보류",
152
+ "not-applicable": "해당 없음",
153
+ "blocked": "차단"
131
154
  }
132
155
  },
133
156
  "enumHint": {
@@ -163,9 +186,17 @@
163
186
  "drop-that-file-into": ". 그 파일을 여기에 두면",
164
187
  "and-the-next-run-picks-your-answers-up-on-it": "다음 run 이 알아서 답변을 읽어 갑니다.",
165
188
  "brief-end-states": "브리프가 고정한 최종 상태",
166
- "brief-end-states-intro": "이 리포트가 인용하는 요구사항 아이디(EB·PB·EO)는 브리프의 다음 항목입니다. 문장은 태스크 브리프 원문 그대로입니다.",
189
+ "brief-end-states-intro": "이 리포트가 인용하는 최종 상태 아이디(EB·PB·EO)입니다. 문장은 태스크 브리프 원문이고, 오른쪽은 이번 run 이 그 상태에 내린 판정입니다.",
167
190
  "brief-section": "절",
168
- "brief-statement": "내용"
191
+ "brief-statement": "내용",
192
+ "end-state-coverage": "이번 run 의 판정",
193
+ "end-state-covered-by": "덮은 항목",
194
+ "end-state-blocked-by": "막힌 사유",
195
+ "end-state-not-judged": "이번 run 에서 판정하지 않음",
196
+ "agent-activity": "에이전트 활동",
197
+ "agent-activity-intro": "이번 run 에서 각 에이전트가 맡은 일과 그 결과입니다. 결정 카드와 본문이 이 행을 아이디로 인용합니다.",
198
+ "agent": "에이전트",
199
+ "activity": "활동"
169
200
  },
170
201
  "runUsage": {
171
202
  "heading": "이번 실행에 든 비용",
@@ -359,7 +390,13 @@
359
390
  "uncommitted-state": "커밋되지 않은 상태",
360
391
  "per-stage-reports": "stage 별 리포트",
361
392
  "release-path": "릴리스 경로",
362
- "the-current-verdict-does-not-allow-release-h": "현재 판정으로는 release-handoff 를 시작할 수 없습니다. 차단 항목이나 조건을 먼저 해소하세요."
393
+ "the-current-verdict-does-not-allow-release-h": "현재 판정으로는 release-handoff 를 시작할 수 없습니다. 차단 항목이나 조건을 먼저 해소하세요.",
394
+ "next-step": "다음 단계",
395
+ "conditions-for-acceptance": "수용 조건",
396
+ "conditions-for-acceptance-intro": "아래 조건이 모두 충족되는 동안에만 수용이 유지됩니다. release-handoff 를 막는 행은 인계 전에 근거가 있어야 합니다.",
397
+ "condition": "조건",
398
+ "evidence-required": "필요한 근거",
399
+ "blocks-release-handoff": "release-handoff 차단"
363
400
  },
364
401
  "implementation-option-selection": {
365
402
  "original-requirements": "원본 요구사항 커버리지",
@@ -513,7 +550,8 @@
513
550
  "stages": "stage",
514
551
  "status": "상태",
515
552
  "extract": "추출",
516
- "keep": "그대로 둠"
553
+ "keep": "그대로 둠",
554
+ "planning-invariants": "계획 불변 조건"
517
555
  },
518
556
  "implementation": {
519
557
  "what-was-delivered": "무엇을 만들었나",
@@ -80,13 +80,13 @@
80
80
  <ol class="clarification-options">
81
81
  {% for option in options %}
82
82
  <li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}" data-option-value="{{ option.answer }}">
83
- <p class="clarification-option-answer">{{ option.answer }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
83
+ <p class="clarification-option-answer">{{ option.answer | inline_code }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
84
84
  {% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
85
- <p class="clarification-option-rationale">{{ option.rationale }}</p>
85
+ <p class="clarification-option-rationale">{{ option.rationale | inline_code }}</p>
86
86
  <dl class="clarification-option-impact">
87
87
  <dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{% if option.reach | default(None) %}{{ option.reach }}{% if option.scopeEffects | default([]) %}, {{ option.scopeEffects | join(', ') }}{% endif %}{% else %}{{ option.scopeImpact | join(', ') }}{% endif %}</dd>
88
- <dt>{{ t('macros.forms.added-work') }}</dt><dd>{{ option.addedWork }}</dd>
89
- <dt>{{ t('macros.forms.direction-change') }}</dt><dd>{{ option.directionChange }}</dd>
88
+ <dt>{{ t('macros.forms.added-work') }}</dt><dd>{{ option.addedWork | inline_code }}</dd>
89
+ <dt>{{ t('macros.forms.direction-change') }}</dt><dd>{{ option.directionChange | inline_code }}</dd>
90
90
  </dl>
91
91
  </li>
92
92
  {% endfor %}
@@ -1,5 +1,5 @@
1
1
  {% extends "html/base.template.html" %}
2
- {% from "html/macros/layout.html" import narrative as render_narrative %}
2
+ {% from "html/macros/layout.html" import narrative as render_narrative, row_key %}
3
3
  {% from "html/macros/visualizations.html" import figure %}
4
4
 
5
5
  {% block human_content %}
@@ -7,8 +7,18 @@
7
7
  <h2>{{ t('tasks.final-verification.verification-verdict') }}</h2>
8
8
  <p><span class="status status-{{ verdictToken }}">{{ verdictToken }}</span></p>
9
9
  {{ render_narrative(narrative.verdictExplanation, "finalVerification.userNarrative.verdictExplanation") }}
10
+ <p data-report-field="finalVerdict.finalConclusion">{{ finalVerdict.finalConclusion | inline_code }}</p>
11
+ <p data-report-field="finalVerdict.nextStep"><strong>{{ t('tasks.final-verification.next-step') }}</strong> {{ finalVerdict.nextStep | inline_code }}</p>
10
12
  </section>
11
13
 
14
+ {% if finalVerdict.get("conditionalAcceptanceConditions") %}
15
+ <section data-report-section="acceptance-conditions" data-report-field="finalVerdict.conditionalAcceptanceConditions">
16
+ <h2>{{ t('tasks.final-verification.conditions-for-acceptance') }}</h2>
17
+ <p class="section-lede">{{ t('tasks.final-verification.conditions-for-acceptance-intro') }}</p>
18
+ <table><thead><tr><th>{{ t('macros.layout.id') }}</th><th>{{ t('tasks.final-verification.condition') }}</th><th>{{ t('tasks.final-verification.evidence-required') }}</th></tr></thead><tbody>{% for row in finalVerdict.conditionalAcceptanceConditions %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id)], status_name=t('tasks.final-verification.blocks-release-handoff'), status_raw=('blocked' if row.blocksReleaseHandoff else 'covered'), status_text=(t('macros.layout.checked') if row.blocksReleaseHandoff else t('macros.layout.none'))) }}<td>{{ row.condition | inline_code }}</td><td>{{ row.evidenceRequired | inline_code }}</td></tr>{% endfor %}</tbody></table>
19
+ </section>
20
+ {% endif %}
21
+
12
22
  <section data-report-section="requirement-results" data-report-field="finalVerification.validationEvidence">
13
23
  <h2>{{ t('tasks.final-verification.result-per-requirement') }}</h2>
14
24
  {{ render_narrative(narrative.coverageExplanation, "finalVerification.userNarrative.coverageExplanation") }}
@@ -8,42 +8,43 @@
8
8
  and the coverage table and left the mechanism, scores, votes, commitments
9
9
  and invariants in the record only. #}
10
10
  {% macro option_card(row, rank, criteria) -%}
11
+ {% set extra = cardLinks.get(row.id, {}) %}
11
12
  <article class="summary-card" id="id-{{ row.id }}">
12
- <p class="eyebrow">#{{ rank }} · {{ row.id | inline_code }} · {{ t('macros.layout.proposed-by') }} {{ row.proposedBy | inline_code }}</p>
13
- <h3>{{ row.name | inline_code }}</h3>
13
+ <p class="eyebrow">#{{ rank }} · {{ row.id | inline_code(extra) }} · {{ t('macros.layout.proposed-by') }} {{ row.proposedBy | inline_code(extra) }}</p>
14
+ <h3>{{ row.name | inline_code(extra) }}</h3>
14
15
  <div class="option-detail">
15
16
  <h4>{{ t('tasks.implementation-option-selection.what-changes') }}</h4>
16
- <p><strong>{{ t('tasks.implementation-option-selection.goal') }}</strong> {{ row.goal | inline_code }}</p>
17
+ <p><strong>{{ t('tasks.implementation-option-selection.goal') }}</strong> {{ row.goal | inline_code(extra) }}</p>
17
18
  <p><strong>{{ t('tasks.implementation-option-selection.change-areas') }}</strong></p>
18
- <ul>{% for area in row.expectedChangeAreas %}<li>{{ area | inline_code }}</li>{% endfor %}</ul>
19
- <table data-report-field="implementationOptionSelection.rankedOptions.scopeCommitments"><thead><tr><th>{{ t('tasks.implementation-option-selection.commitment') }}</th><th>{{ t('tasks.implementation-option-selection.commitment-body') }}</th><th>{{ t('tasks.implementation-option-selection.requirements') }}</th></tr></thead><tbody>{% for item in row.scopeCommitments %}<tr>{{ row_key(pairs=[(t('macros.layout.id'), item.id), (t('macros.layout.kind'), item.kind | enum_label('commitmentKind'))]) }}<td>{{ item.commitment | inline_code }}</td><td>{{ item.requirementIds | join(", ") | inline_code }}</td></tr>{% endfor %}</tbody></table>
19
+ <ul>{% for area in row.expectedChangeAreas %}<li>{{ area | inline_code(extra) }}</li>{% endfor %}</ul>
20
+ <table data-report-field="implementationOptionSelection.rankedOptions.scopeCommitments"><thead><tr><th>{{ t('tasks.implementation-option-selection.commitment') }}</th><th>{{ t('tasks.implementation-option-selection.commitment-body') }}</th><th>{{ t('tasks.implementation-option-selection.requirements') }}</th></tr></thead><tbody>{% for item in row.scopeCommitments %}<tr id="id-{{ row.id }}-{{ item.id }}">{{ row_key(pairs=[(t('macros.layout.id'), item.id), (t('macros.layout.kind'), item.kind | enum_label('commitmentKind'))]) }}<td>{{ item.commitment | inline_code(extra) }}</td><td>{{ item.requirementIds | join(", ") | inline_code(extra) }}</td></tr>{% endfor %}</tbody></table>
20
21
  </div>
21
22
  <div class="option-detail">
22
23
  <h4>{{ t('tasks.implementation-option-selection.how-it-works') }}</h4>
23
- <p>{{ row.coreMechanism | inline_code }}</p>
24
+ <p>{{ row.coreMechanism | inline_code(extra) }}</p>
24
25
  <p><strong>{{ t('tasks.implementation-option-selection.boundaries') }}</strong></p>
25
- <ul>{% for boundary in row.architectureBoundaries %}<li>{{ boundary | inline_code }}</li>{% endfor %}</ul>
26
+ <ul>{% for boundary in row.architectureBoundaries %}<li>{{ boundary | inline_code(extra) }}</li>{% endfor %}</ul>
26
27
  </div>
27
28
  <div class="option-detail">
28
29
  <h4>{{ t('tasks.implementation-option-selection.why-this-direction') }}</h4>
29
30
  <p><strong>{{ t('tasks.implementation-option-selection.weighted-score') }}</strong> {{ row.weightedScore }} / 5</p>
30
- <table data-report-field="implementationOptionSelection.rankedOptions.criterionScores"><thead><tr><th>{{ t('tasks.implementation-option-selection.criterion') }}</th><th class="figure">{{ t('tasks.implementation-option-selection.weight') }}</th><th class="figure">{{ t('tasks.implementation-option-selection.score') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th></tr></thead><tbody>{% for score in row.criterionScores %}<tr><td>{{ score.criterion | enum_label('optionCriterion') }}</td><td class="figure">{{ criteria.get(score.criterion, '') }}</td><td class="figure">{{ score.score }}</td><td>{{ score.rationale | inline_code }}</td></tr>{% endfor %}</tbody></table>
31
+ <table data-report-field="implementationOptionSelection.rankedOptions.criterionScores"><thead><tr><th>{{ t('tasks.implementation-option-selection.criterion') }}</th><th class="figure">{{ t('tasks.implementation-option-selection.weight') }}</th><th class="figure">{{ t('tasks.implementation-option-selection.score') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th></tr></thead><tbody>{% for score in row.criterionScores %}<tr><td>{{ score.criterion | enum_label('optionCriterion') }}</td><td class="figure">{{ criteria.get(score.criterion, '') }}</td><td class="figure">{{ score.score }}</td><td>{{ score.rationale | inline_code(extra) }}</td></tr>{% endfor %}</tbody></table>
31
32
  <p><strong>{{ t('tasks.implementation-option-selection.feasibility-votes') }}</strong></p>
32
- <table data-report-field="implementationOptionSelection.rankedOptions.feasibilityVotes"><thead><tr><th>{{ t('tasks.implementation-option-selection.worker') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th><th>{{ t('tasks.implementation-option-selection.counterevidence') }}</th></tr></thead><tbody>{% for vote in row.feasibilityVotes %}<tr>{{ row_key(pairs=[(t('tasks.implementation-option-selection.worker'), vote.worker)], status_name=t('tasks.implementation-option-selection.verdict'), status_raw=vote.verdict, status_text=vote.verdict | enum_label('feasibilityVerdict')) }}<td>{{ vote.rationale | inline_code }}</td><td>{{ vote.counterevidence | inline_code }}</td></tr>{% endfor %}</tbody></table>
33
+ <table data-report-field="implementationOptionSelection.rankedOptions.feasibilityVotes"><thead><tr><th>{{ t('tasks.implementation-option-selection.worker') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th><th>{{ t('tasks.implementation-option-selection.counterevidence') }}</th></tr></thead><tbody>{% for vote in row.feasibilityVotes %}<tr>{{ row_key(pairs=[(t('tasks.implementation-option-selection.worker'), vote.worker)], status_name=t('tasks.implementation-option-selection.verdict'), status_raw=vote.verdict, status_text=vote.verdict | enum_label('feasibilityVerdict')) }}<td>{{ vote.rationale | inline_code(extra) }}</td><td>{{ vote.counterevidence | inline_code(extra) }}</td></tr>{% endfor %}</tbody></table>
33
34
  {% if row.safetyBlockers %}<p><strong>{{ t('tasks.implementation-option-selection.safety-blockers') }}</strong></p>
34
- <ul>{% for blocker in row.safetyBlockers %}<li><span class="status status-risk">{{ blocker.category | enum_label('safetyCategory') }}</span> {{ blocker.statement | inline_code }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ blocker.evidence | evidence_refs }}</span></li>{% endfor %}</ul>{% endif %}
35
+ <ul>{% for blocker in row.safetyBlockers %}<li><span class="status status-risk">{{ blocker.category | enum_label('safetyCategory') }}</span> {{ blocker.statement | inline_code(extra) }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ blocker.evidence | evidence_refs(extra) }}</span></li>{% endfor %}</ul>{% endif %}
35
36
  {% if row.unresolvedFeasibilityFacts %}<p><strong>{{ t('tasks.implementation-option-selection.unresolved-facts') }}</strong></p>
36
- <ul>{% for fact in row.unresolvedFeasibilityFacts %}<li>{{ fact.fact | inline_code }} — {{ t('tasks.implementation-option-selection.why-it-matters') }} {{ fact.whyItMatters | inline_code }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ fact.evidence | evidence_refs }}</span></li>{% endfor %}</ul>{% endif %}
37
+ <ul>{% for fact in row.unresolvedFeasibilityFacts %}<li>{{ fact.fact | inline_code(extra) }} — {{ t('tasks.implementation-option-selection.why-it-matters') }} {{ fact.whyItMatters | inline_code(extra) }} <span class="evidence-refs">{{ t('macros.layout.evidence') }} {{ fact.evidence | evidence_refs(extra) }}</span></li>{% endfor %}</ul>{% endif %}
37
38
  </div>
38
39
  <div class="option-detail">
39
40
  <h4>{{ t('tasks.implementation-option-selection.expected-outcome') }}</h4>
40
41
  <div data-report-field="implementationOptionSelection.rankedOptions.coverageSummary"><p>{{ t('tasks.implementation-option-selection.coverage-line') | replace('{covered}', row.coverageSummary.coveredCount) | replace('{total}', row.coverageSummary.totalCount) | replace('{verdict}', row.coverageSummary.coverageVerdict | enum_label('coverageVerdict')) }}</p>
41
42
  <p><strong>{{ t('macros.layout.coverage-percent') }}</strong> <span data-report-metric="coveragePercent">{{ row.coverageSummary.coveragePercent }}</span>% · <strong>{{ t('macros.layout.scope-precision-percent') }}</strong> <span data-report-metric="scopePrecisionPercent">{{ row.coverageSummary.scopePrecisionPercent }}</span>%</p></div>
42
- <table data-report-field="implementationOptionSelection.rankedOptions.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation-option-selection.requirement') }}</th><th>{{ t('tasks.implementation-option-selection.satisfaction') }}</th><th>{{ t('tasks.implementation-option-selection.verification') }}</th></tr></thead><tbody>{% for coverage in row.requirementCoverage %}<tr>{{ row_key(pairs=[(t('macros.layout.id'), coverage.requirementId), (t('macros.layout.status'), coverage.status)]) }}<td>{{ coverage.satisfaction | inline_code }}</td><td>{{ coverage.expectedVerification | inline_code }}</td></tr>{% endfor %}</tbody></table>
43
+ <table data-report-field="implementationOptionSelection.rankedOptions.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation-option-selection.requirement') }}</th><th>{{ t('tasks.implementation-option-selection.satisfaction') }}</th><th>{{ t('tasks.implementation-option-selection.verification') }}</th></tr></thead><tbody>{% for coverage in row.requirementCoverage %}<tr>{{ row_key(pairs=[(t('macros.layout.id'), coverage.requirementId), (t('macros.layout.status'), coverage.status)]) }}<td>{{ coverage.satisfaction | inline_code(extra) }}</td><td>{{ coverage.expectedVerification | inline_code(extra) }}</td></tr>{% endfor %}</tbody></table>
43
44
  {% if row.planningInvariants %}<p><strong>{{ t('tasks.implementation-option-selection.planning-invariants') }}</strong></p>
44
- <ul data-report-field="implementationOptionSelection.rankedOptions.planningInvariants">{% for invariant in row.planningInvariants %}<li>{{ invariant.id | inline_code }} · {{ invariant.statement | inline_code }} ({{ invariant.requirementIds | join(", ") | inline_code }})</li>{% endfor %}</ul>{% endif %}
45
+ <ul data-report-field="implementationOptionSelection.rankedOptions.planningInvariants">{% for invariant in row.planningInvariants %}<li id="id-{{ row.id }}-{{ invariant.id }}">{{ invariant.id | inline_code(extra) }} · {{ invariant.statement | inline_code(extra) }} ({{ invariant.requirementIds | join(", ") | inline_code(extra) }})</li>{% endfor %}</ul>{% endif %}
45
46
  </div>
46
- <p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.codeEvidence | evidence_refs }}</p>
47
+ <p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.codeEvidence | evidence_refs(extra) }}</p>
47
48
  </article>
48
49
  {%- endmacro %}
49
50
 
@@ -88,6 +89,6 @@
88
89
 
89
90
  <section data-report-section="candidate-audit" data-report-field="implementationOptionSelection.candidateAudit">
90
91
  <h2>{{ t('tasks.implementation-option-selection.candidate-audit') }}</h2>
91
- <table><thead><tr><th>{{ t('tasks.implementation-option-selection.candidate') }}</th><th>{{ t('tasks.implementation-option-selection.goal-column') }}</th><th>{{ t('tasks.implementation-option-selection.disposition') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th><th>{{ t('macros.layout.coverage-percent') }}</th><th>{{ t('macros.layout.scope-precision-percent') }}</th></tr></thead><tbody>{% for row in selection.candidateAudit %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('macros.layout.proposed-by'), row.proposedBy)]) }}<td>{{ row.goal | inline_code }}</td><td>{{ row.disposition }}{% if row.mergedInto %} → {{ row.mergedInto | inline_code }}{% endif %}</td><td>{{ row.reason | inline_code }}</td><td>{{ row.coverageSummary.coveragePercent }}</td><td>{{ row.coverageSummary.scopePrecisionPercent }}</td></tr>{% else %}<tr><td colspan="6">{{ t('tasks.implementation-option-selection.no-audit-entries') }}</td></tr>{% endfor %}</tbody></table>
92
+ <table><thead><tr><th>{{ t('tasks.implementation-option-selection.candidate') }}</th><th>{{ t('tasks.implementation-option-selection.goal-column') }}</th><th>{{ t('tasks.implementation-option-selection.disposition') }}</th><th>{{ t('tasks.implementation-option-selection.reason') }}</th><th>{{ t('macros.layout.coverage-percent') }}</th><th>{{ t('macros.layout.scope-precision-percent') }}</th></tr></thead><tbody>{% for row in selection.candidateAudit %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('macros.layout.proposed-by'), row.proposedBy)]) }}<td>{{ row.goal | inline_code(cardLinks.get(row.id, {})) }}{% if row.get("scopeCommitments") %}<ul class="audit-commitments">{% for item in row.scopeCommitments %}<li id="id-{{ row.id }}-{{ item.id }}">{{ item.id | inline_code(cardLinks.get(row.id, {})) }} · {{ item.commitment | inline_code(cardLinks.get(row.id, {})) }}</li>{% endfor %}</ul>{% endif %}</td><td>{{ row.disposition }}{% if row.mergedInto %} → {{ row.mergedInto | inline_code }}{% endif %}</td><td>{{ row.reason | inline_code }}</td><td>{{ row.coverageSummary.coveragePercent }}</td><td>{{ row.coverageSummary.scopePrecisionPercent }}</td></tr>{% else %}<tr><td colspan="6">{{ t('tasks.implementation-option-selection.no-audit-entries') }}</td></tr>{% endfor %}</tbody></table>
92
93
  </section>
93
94
  {% endblock %}
@@ -19,6 +19,10 @@
19
19
  <section data-report-section="direction-realization" data-report-field="implementationPlanning.directionRealization">
20
20
  <h2>{{ t('tasks.implementation-planning.direction-realization') }}</h2>
21
21
  <article class="summary-card"><h3>{{ planning.directionRealization.goal | inline_code }}</h3><p><strong>{{ t('tasks.implementation-planning.core-mechanism') }}</strong> {{ planning.directionRealization.coreMechanism | inline_code }}</p><p><strong>{{ t('tasks.implementation-planning.interfaces') }}</strong> {{ planning.directionRealization.interfaces | inline_code }}</p><p><strong>{{ t('tasks.implementation-planning.blast-radius') }}</strong> {{ planning.directionRealization.blastRadius | inline_code }}</p><ul>{% for file in planning.directionRealization.fileStructure %}<li id="id-{{ file.id }}">{{ file.action }} <code>{{ file.path }}</code> — {{ file.summary | inline_code }}</li>{% endfor %}</ul></article>
22
+ {% if planning.directionRealization.get("planningInvariants") %}
23
+ <h3>{{ t('tasks.implementation-planning.planning-invariants') }}</h3>
24
+ <ul data-report-field="implementationPlanning.directionRealization.planningInvariants">{% for row in planning.directionRealization.planningInvariants %}<li id="id-{{ row.id }}">{{ row.id | inline_code }} · {{ row.statement | inline_code }}{% if row.get("requirementIds") %} ({{ row.requirementIds | join(", ") | inline_code }}){% endif %}</li>{% endfor %}</ul>
25
+ {% endif %}
22
26
  </section>
23
27
  <section data-report-section="plan-coverage" data-report-field="implementationPlanning.coverageSummary">
24
28
  <h2>{{ t('tasks.implementation-planning.plan-coverage') }}</h2>
@@ -90,7 +94,7 @@
90
94
  <section data-report-section="requirement-coverage" data-report-field="implementationPlanning.requirementCoverage">
91
95
  <h2>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h2>
92
96
  {% if planning.get("planningContract") == "selected-direction" %}
93
- <table><thead><tr><th>{{ t('tasks.implementation-planning.original-requirement') }}</th><th>{{ t('tasks.implementation-planning.stages') }}</th><th>{{ t('tasks.implementation-planning.status') }}</th></tr></thead><tbody>{% for row in planning.requirementCoverage %}<tr><td>{{ row.originalRequirementId | inline_code }}</td><td>{{ row.stageRefs | join(", ") | inline_code }}</td><td>{{ row.status | inline_code }}</td></tr>{% endfor %}</tbody></table>
97
+ <table><thead><tr><th>{{ t('tasks.implementation-planning.original-requirement') }}</th><th>{{ t('tasks.implementation-planning.stages') }}</th><th>{{ t('tasks.implementation-planning.status') }}</th></tr></thead><tbody>{% for row in planning.requirementCoverage %}<tr{% if row.get("id") %} id="id-{{ row.id }}"{% endif %}><td>{% if row.get("id") %}{{ row.id | inline_code }} · {% endif %}{{ row.originalRequirementId | inline_code }}</td><td>{{ row.stageRefs | join(", ") | inline_code }}</td><td>{{ row.status | inline_code }}</td></tr>{% endfor %}</tbody></table>
94
98
  {% else %}
95
99
  <table><thead><tr><th>{{ t('tasks.implementation-planning.requirement') }}</th><th>{{ t('tasks.implementation-planning.body') }}</th><th>{{ t('tasks.implementation-planning.covered-by') }}</th></tr></thead><tbody>{% for row in planning.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('macros.layout.source'), row.source)], status_name=t('macros.layout.status'), status_raw=row.status, status_text=row.status) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
96
100
  {% endif %}
@@ -145,10 +149,6 @@
145
149
  {% endif %}
146
150
  {% endif %}
147
151
 
148
- {% if agentActivities %}
149
- <div hidden>{% for row in agentActivities %}<span id="id-{{ row.activityId }}"></span>{% endfor %}</div>
150
- {% endif %}
151
-
152
152
  <section data-report-section="open-decisions">
153
153
  <h2>{{ t('tasks.implementation-planning.open-decisions-before-approval') }}</h2>
154
154
  <div class="summary-grid">{% for row in openDecisions %}{{ summary_card(row.id ~ " · " ~ row.kind, row.statement, "important", anchor=row.id) }}{% else %}<p>{{ t('tasks.implementation-planning.no-open-decision-is-holding-approval') }}</p>{% endfor %}</div>
@@ -38,7 +38,7 @@
38
38
  {{ render_narrative(narrative.confirmedRequirements, "requirementsDiscovery.userNarrative.confirmedRequirements") }}
39
39
  <div class="summary-grid" data-report-field="requirementsDiscovery.confirmedRequirements">{% for row in requirements.confirmedRequirements %}{{ summary_card(row.id ~ " · " ~ t('tasks.requirements-discovery.settled'), row.statement, anchor=row.id) }}{% endfor %}</div>
40
40
  {{ render_narrative(narrative.unresolvedRequirements, "requirementsDiscovery.userNarrative.unresolvedRequirements") }}
41
- <div class="summary-grid" data-report-field="requirementsDiscovery.unresolvedRequirements">{% for row in requirements.unresolvedRequirements %}{{ summary_card(row.id ~ " · " ~ t('tasks.requirements-discovery.decision-needed'), row.statement, "important") }}{% else %}<p>{{ t('tasks.requirements-discovery.nothing-is-left-unsettled') }}</p>{% endfor %}</div>
41
+ <div class="summary-grid" data-report-field="requirementsDiscovery.unresolvedRequirements">{% set clarification_ids = clarificationItems | map(attribute='id') | list %}{% for row in requirements.unresolvedRequirements %}{{ summary_card(row.id ~ " · " ~ t('tasks.requirements-discovery.decision-needed'), row.statement, "important", anchor=(row.id if row.id not in clarification_ids else '')) }}{% else %}<p>{{ t('tasks.requirements-discovery.nothing-is-left-unsettled') }}</p>{% endfor %}</div>
42
42
  </section>
43
43
 
44
44
  <section data-report-section="routing">
@@ -14,7 +14,9 @@ second rendering of that same record:
14
14
  4. each visualization's svg node ids match its fallback ids;
15
15
  5. the Response IDs in the HTML match ``clarificationItems[]`` 1:1;
16
16
  6. no external URL appears in ``<script src=>`` / ``<link href=>`` /
17
- ``<img src=>`` — the self-contained guarantee.
17
+ ``<img src=>`` — the self-contained guarantee;
18
+ 7. every in-page ``href="#…"`` lands on an element id of the page — the
19
+ renderer's index and the template's anchors agree.
18
20
 
19
21
  A schema-v1 report keeps the Markdown as its source, so its checks read the
20
22
  §1 Clarification Items table (fail-closed when the heading is present but the
@@ -133,9 +135,36 @@ def _validate_v2(report_path: Path, data_path: Path, data: dict) -> list[str]:
133
135
  )
134
136
  if _EXTERNAL_URL_RE.search(html_text):
135
137
  failures.append("html contains external URL in script/link/img — must be self-contained")
138
+ failures.extend(_dead_in_page_links(html_text))
136
139
  return failures
137
140
 
138
141
 
142
+ _ANCHOR_HREF_RE = re.compile(r'href="#([^"]+)"')
143
+ _ELEMENT_ID_RE = re.compile(r'\bid="([^"]+)"')
144
+
145
+
146
+ def _dead_in_page_links(html_text: str) -> list[str]:
147
+ """(7) every in-page link lands on an element of this page.
148
+
149
+ The renderer links every id the record defines (`report_html/common.py`
150
+ `anchor_index`); a link to a missing anchor means the index claimed a row
151
+ the template never drew — 2026-09-05 audit of 28 shipped reports found
152
+ `A-`, `UQ-`, `PI-` and `IC-` links dead this way. The cause is always
153
+ renderer-side, so the finding stays with the page, not the author.
154
+ """
155
+ body = html_text.split("<body", 1)[-1]
156
+ body = re.sub(r"<script.*?</script>|<style.*?</style>", "", body, flags=re.DOTALL)
157
+ ids = set(_ELEMENT_ID_RE.findall(body))
158
+ dead = sorted({target for target in _ANCHOR_HREF_RE.findall(body) if target not in ids})
159
+ if not dead:
160
+ return []
161
+ return [
162
+ "dead in-page link(s): "
163
+ + ", ".join(f"#{target}" for target in dead[:12])
164
+ + (f" (+{len(dead) - 12} more)" if len(dead) > 12 else "")
165
+ ]
166
+
167
+
139
168
  def _no_form_sections(html_body: str) -> list[str]:
140
169
  """Return a list of strings, each being the rendered chunk of a
141
170
  no-form section (5.6 / 5.7 / 5.8) up to the next h2/h3. Used to
@@ -3084,6 +3084,9 @@ def validate_final_report_data(
3084
3084
  failures.extend(f"{task_type}: {error}" for error in analysis_result.errors)
3085
3085
 
3086
3086
  _validate_no_opaque_id_references(data, failures)
3087
+ _validate_no_null_literals_in_prose(data, failures)
3088
+ for warning in _unbridged_worker_finding_refs(data):
3089
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
3087
3090
  # Phase-agnostic: the coverage critic runs in every finding-producing phase.
3088
3091
  _validate_unverified_critic_gaps_recorded(data, failures)
3089
3092
  # Called here rather than from a task-type branch: four profiles raise
@@ -3162,7 +3165,9 @@ def validate_final_report_data(
3162
3165
  carried = _carried_decision_map(
3163
3166
  manifest, project_root=project_root, report_path=report_path
3164
3167
  )
3165
- _validate_supersession_ledger(data, failures, carried=carried)
3168
+ _validate_supersession_ledger(
3169
+ data, failures, carried=carried, new_plan=selected_direction_contract
3170
+ )
3166
3171
  _validate_approval_clarification_backtrace(data, failures)
3167
3172
  _validate_rerun_guidance(data, failures)
3168
3173
  _validate_approval_guidance(data, failures)
@@ -3242,6 +3247,97 @@ def _validate_no_opaque_id_references(data: dict, failures: list[str]) -> None:
3242
3247
  )
3243
3248
 
3244
3249
 
3250
+ # A stringified null is not prose. Lowercase `none` is a token the clarification
3251
+ # option contract uses on purpose ("reverses nothing"), so only the capitalised
3252
+ # and language-native spellings count — 2026-09-05 audit: `"None"` in 16
3253
+ # trade-off cells and 2 option fields across the shipped reports.
3254
+ _NULL_LITERALS = frozenset({"None", "null", "undefined", "NaN"})
3255
+ _FINDING_REF_RE = re.compile(r"^F-\d{3,}$")
3256
+ # Citation lists the human page renders as links or plain text.
3257
+ _CITATION_LIST_KEYS = ("evidenceRefs", "supportingEvidence", "falsifyingEvidenceChecked")
3258
+
3259
+
3260
+ def _prose_pointers(node: object, key: str = "", pointer: str = "") -> list[tuple[str, str]]:
3261
+ """(JSON pointer, value) for every string under a reader-facing prose key."""
3262
+ from okstra_ctl.report_translation import PROSE_KEYS
3263
+
3264
+ out: list[tuple[str, str]] = []
3265
+ if isinstance(node, dict):
3266
+ for child_key, value in node.items():
3267
+ out.extend(_prose_pointers(value, str(child_key), f"{pointer}/{child_key}"))
3268
+ elif isinstance(node, list):
3269
+ for index, value in enumerate(node):
3270
+ out.extend(_prose_pointers(value, key, f"{pointer}/{index}"))
3271
+ elif isinstance(node, str) and key in PROSE_KEYS:
3272
+ out.append((pointer, node))
3273
+ return out
3274
+
3275
+
3276
+ def _validate_no_null_literals_in_prose(data: dict, failures: list[str]) -> None:
3277
+ """A prose cell holding `None`/`null`/`undefined` is a serialisation
3278
+ artefact the page prints verbatim — nlpvibe planning-009…014 showed
3279
+ `None` under Test cost and Rollout cost. An empty value is spelled by
3280
+ omitting the field or leaving it empty, never by naming the language's
3281
+ null."""
3282
+ offenders = [
3283
+ f"{pointer} = {value.strip()!r}"
3284
+ for pointer, value in _prose_pointers(data)
3285
+ if value.strip() in _NULL_LITERALS
3286
+ ]
3287
+ if offenders:
3288
+ failures.append(
3289
+ "final-report data.json: prose field(s) hold a null literal — "
3290
+ + ", ".join(offenders[:8])
3291
+ + (f" (+{len(offenders) - 8} more)" if len(offenders) > 8 else "")
3292
+ + ". Leave the field empty or omit it; the page prints the literal as text."
3293
+ )
3294
+
3295
+
3296
+ def _unbridged_worker_finding_refs(data: dict) -> list[str]:
3297
+ """Advisory: citation lists naming a worker's own finding number (`F-NNN`)
3298
+ that no `evidence.primary[].sourceItems` entry carries.
3299
+
3300
+ The page links a bare `F-NNN` only when exactly one promoted evidence row
3301
+ cites it as `<worker>:F-NNN` (`report_html/common.py`
3302
+ `worker_finding_links`); every other one is dead text. The 2026-09-05
3303
+ audit found 22 to 156 such citations per shipped report, so this stays a
3304
+ warning until the writer contracts have caught up — cite the promoted
3305
+ `E-` row, or add the finding to that row's `sourceItems`.
3306
+ """
3307
+ bridged: set[str] = set()
3308
+ for row in ((data.get("evidence") or {}).get("primary") or []):
3309
+ if not isinstance(row, dict):
3310
+ continue
3311
+ for item in row.get("sourceItems") or []:
3312
+ for token in re.findall(r"\b(F-\d{3,})\b", str(item)):
3313
+ bridged.add(token)
3314
+ unbridged: dict[str, list[str]] = {}
3315
+
3316
+ def walk(node: object, key: str = "", pointer: str = "") -> None:
3317
+ if isinstance(node, dict):
3318
+ for child_key, value in node.items():
3319
+ walk(value, str(child_key), f"{pointer}/{child_key}")
3320
+ elif isinstance(node, list):
3321
+ if key in _CITATION_LIST_KEYS:
3322
+ for value in node:
3323
+ if isinstance(value, str) and _FINDING_REF_RE.match(value.strip()) and value.strip() not in bridged:
3324
+ unbridged.setdefault(value.strip(), []).append(pointer)
3325
+ else:
3326
+ for index, value in enumerate(node):
3327
+ walk(value, key, f"{pointer}/{index}")
3328
+
3329
+ walk(data)
3330
+ if not unbridged:
3331
+ return []
3332
+ listed = ", ".join(f"{ref} ({len(where)}×)" for ref, where in sorted(unbridged.items())[:10])
3333
+ return [
3334
+ f"citation lists name worker finding number(s) no evidence.primary "
3335
+ f"sourceItems entry carries — {listed}"
3336
+ + (f" (+{len(unbridged) - 10} more)" if len(unbridged) > 10 else "")
3337
+ + "; cite the promoted E- row or add `<worker>:F-NNN` to its sourceItems"
3338
+ ]
3339
+
3340
+
3245
3341
  def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
3246
3342
  """타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
3247
3343
  `crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이
@@ -4794,11 +4890,22 @@ def _answered_clarification_ids(data: dict) -> list[str]:
4794
4890
 
4795
4891
 
4796
4892
  def _validate_supersession_ledger(
4797
- data: dict, failures: list[str], *, carried: dict | None = None,
4893
+ data: dict,
4894
+ failures: list[str],
4895
+ *,
4896
+ carried: dict | None = None,
4897
+ new_plan: bool = False,
4798
4898
  ) -> None:
4799
4899
  """Incorporating an answer means retiring what it invalidates, not only
4800
4900
  adding what it decides.
4801
4901
 
4902
+ `new_plan` marks a plan built from a selected direction. Prepare seeds
4903
+ that run's ledger with every answer the option-selection record carried
4904
+ (2026-09-05), and a first plan has no earlier statement those answers
4905
+ could retire — an entry per carried row would be `no-dependent-statement`
4906
+ by construction. Those ids are exempt; answers the plan itself raised and
4907
+ settled still need their entry.
4908
+
4802
4909
  A re-run reconciles each `C-*` row's `Status` and writes the new decision
4803
4910
  into the plan, but nothing required it to remove the sentences the answer
4804
4911
  made false. The result is one plan carrying two opposite instructions for
@@ -4812,7 +4919,10 @@ def _validate_supersession_ledger(
4812
4919
  if not isinstance(ip, dict):
4813
4920
  return
4814
4921
  answered = set(_answered_clarification_ids(data))
4815
- answered.update((carried or {}).keys())
4922
+ if new_plan:
4923
+ answered.difference_update((carried or {}).keys())
4924
+ else:
4925
+ answered.update((carried or {}).keys())
4816
4926
  if not answered:
4817
4927
  return
4818
4928
  ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]