okstra 0.189.1 → 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 (25) hide show
  1. package/docs/architecture.md +2 -0
  2. package/docs/cli.md +1 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/duties/direction-selection-worker.md +2 -2
  6. package/runtime/prompts/lead/report-writer.md +1 -1
  7. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  8. package/runtime/prompts/profiles/implementation-option-selection.md +2 -2
  9. package/runtime/python/okstra_ctl/approval_decisions.py +85 -0
  10. package/runtime/python/okstra_ctl/implementation_options.py +35 -0
  11. package/runtime/python/okstra_ctl/render.py +13 -0
  12. package/runtime/python/okstra_ctl/report_html/common.py +60 -4
  13. package/runtime/python/okstra_ctl/report_html/context_links.py +137 -0
  14. package/runtime/python/okstra_ctl/report_html/filters.py +22 -20
  15. package/runtime/python/okstra_ctl/report_html/render.py +18 -5
  16. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +7 -0
  17. package/runtime/python/okstra_ctl/report_synthesis_packet.py +5 -0
  18. package/runtime/python/okstra_ctl/report_translation.py +8 -0
  19. package/runtime/python/okstra_ctl/scope_provenance.py +28 -6
  20. package/runtime/templates/reports/html/assets/base.css +10 -0
  21. package/runtime/templates/reports/html/base.template.html +10 -0
  22. package/runtime/templates/reports/html/i18n/en.json +71 -2
  23. package/runtime/templates/reports/html/i18n/ko.json +71 -2
  24. package/runtime/templates/reports/html/macros/forms.html +1 -0
  25. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +55 -11
@@ -877,6 +877,8 @@ The Phase 7 `render-views` step accepts either a final-report data.json or its M
877
877
  - **Human summary**: `humanSummary` is the sole v2 top-level human summary contract. It is not copied into AI Markdown. Each task view decides how to present it together with the task deliverable instead of sharing a generic dashboard body.
878
878
  - **Audit isolation**: worker execution, convergence, and token/cost material remain available for traceability but are subordinate to the user's findings and decisions. They never replace the task analysis narrative.
879
879
  - **Implementation-planning activity**: activity-contract reports show each agent's task, summary, and outcome in the default view. Commands, exit codes, file-and-line evidence, and result paths remain inside expandable detail. Approval decision cards link to the relevant `id-A-NNN` activity anchors and preserve each option's disposition in the exported user response.
880
+ - **Cited ids resolve to a home**: every id the record defines is an in-page anchor (`#id-<id>`; cross-check consensus and dissent cards are `#id-xv-<id>`), and prose, citation lists, and table cells link to it (`report_html/common.py` `anchor_index`, `report_html/filters.py`). Two id families are cited everywhere and defined nowhere in the record, so the renderer follows the two pointers the run pinned (`report_html/context_links.py`): the brief's end-state rows (`EB`/`PB`/`EO`) are read from `<task>/instruction-set/task-brief.md` and rendered once as the "End states the brief pinned" section with `#id-EB-001` anchors, and the clarification ids a previous run settled link to that run's page through `clarificationCarryIn.sourceFile` (`../../<type>/reports/final-report-<type>-<seq>.html#id-C-005`). A missing file yields no rows and no links, never a render failure. An id several rows of the task block share — a direction's `IC-`/`PI-` rows are numbered from 001 inside each direction — has no single home and stays plain text.
881
+ - **Implementation-option-selection cards**: each ranked direction reads as four blocks — what it changes (goal, expected change areas, scope commitments), how it is done (core mechanism, architecture boundaries), why it ranks where it does (criterion scores beside the criterion weights, feasibility votes with counterevidence, safety blockers, unresolved facts), and the expected outcome (coverage count and verdict, requirement coverage, planning invariants). The evaluation criteria and their weights render as their own section before the ranking, the direction picker shows each option's goal, and the candidate audit shows each rejected candidate's goal.
880
882
  - **Schema v1 compatibility**: existing v1 data and quick Markdown reports keep the legacy conditional renderer, including `readerSummary`, reader modes, and the original Markdown-parsing path.
881
883
  - **`C-*` select option order (schema v1)**: the legacy renderer parses `Expected form`, puts the `Recommended:` answer **first**, and relabels the `Alternatives:` items consecutively as `(a)`, `(b)`, and so on (the original character labels are not retained). Schema v2 parses nothing here — a `Kind=decision` row carries `options[]`, and the presentation order is the array order with the `role: recommended` entry first.
882
884
 
package/docs/cli.md CHANGED
@@ -841,7 +841,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
841
841
  | `okstra team dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--jobs-file <path>] [--dry-run]` / `okstra team await --project-root <dir> --run-manifest <path> [--json]` / `okstra team teardown --project-root <dir> --run-manifest <path> [--dry-run] [--json]` | Read a `leadRuntime=external` run manifest and dispatch, await, or tear down pane-backed workers. Default dispatch excludes report writer; Phase 6 selects it explicitly, and mixed analysis/report jobs are rejected. If a pane cannot be opened, gracefully degrade to the CLI wrapper and record the fallback in `workerDispatches[].degradedFrom` |
842
842
  | `okstra agent-activity append --project-root <dir> --run-manifest <path> --kind <kind> --agent <assigned-id> (--summary <text>\|--summary-file <markdown>) --outcome <outcome> [--plan-item-id <current-id>]… [--command <text> --command-cwd <dir> --command-exit-code <n> --command-output-file <markdown>] [--request-ref <returned-ref>]` | Append one structured activity after checking the agent against this run's role assignments and every plan item against its current convergence state. Python returns an `activityRequestRef`; supply only that returned value with `--request-ref` to retry idempotently. A new call without it remains a distinct activity even with identical contents. Legacy JSON command records remain automation compatibility only. |
843
843
  | `okstra agent-activity project --project-root <dir> --run-manifest <path> --data <data.json>` | Project this run's canonical activity events into `agentActivity[]`. The command preserves event order, rejects duplicate or decreasing activity IDs, and replaces no other report field. A historical manifest without `activityContractVersion: 1` returns an empty projection and leaves data.json unchanged. Normal Phase 7 execution reaches this behavior through `report-finalize`; use the standalone command only for diagnostics. |
844
- | `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. `carry --from-responses <instruction-set/clarification-response.md>` is the source of truth for an answer given in an earlier run: the bundle is task-level and cumulative, each response section names the report that posed the question, and `--clarification-id` repeats to carry several ids in one call. `carry --source-ledger` remains for a prior run's ledger that is still on disk and needs `--source-run-ref`. |
844
+ | `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. `carry --from-responses <instruction-set/clarification-response.md>` is the source of truth for an answer given in an earlier run: the bundle is task-level and cumulative, each response section names the report that posed the question, and `--clarification-id` repeats to carry several ids in one call. `carry --source-ledger` remains for a prior run's ledger that is still on disk and needs `--source-run-ref`. Prepare seeds `carriedDecisions[]` itself when it creates a run's ledger from a `--clarification-response` that names a report record — every row that record answered or resolved, plus rows its user-responses sidecars answered (`scripts/okstra_ctl/approval_decisions.py` `seed_carried_decisions`) — so `carry` is for ids that record does not answer. |
845
845
  | `okstra design-snapshot --narrative <report-narrative.md> --output <design-preparation.json>` | Detect implementation-planning design surfaces and write the detector-owned snapshot consumed by final report assembly. |
846
846
  | `okstra plan-verify --narrative <report-narrative.md> --state <plan-body-verification.json>` | Recompute the plan-body gate from the convergence-owned state before `data.json` publication. `--report <historical-data.json>` remains the v2 reader. |
847
847
  | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; only `teardown-stages` is skipped so a failed run does not reclaim worktrees. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. This is the shared path for every lead adapter. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.189.1",
3
+ "version": "0.189.2",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.189.1",
3
- "builtAt": "2026-09-04T19:05:25.979Z",
2
+ "package": "0.189.2",
3
+ "builtAt": "2026-09-04T22:24:35.201Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -13,7 +13,7 @@ Compare feasible directions before planning in `candidate-comparison` mode, or v
13
13
 
14
14
  ## Required conduct
15
15
 
16
- In `candidate-comparison` mode, inspect the evidence needed to distinguish candidates, submit no more than three candidates, state the strongest counterevidence for each one, and map every candidate to the stable brief end-state IDs it satisfies, preserves, or leaves unresolved. In `preselected-validation` mode, validate the one preselected direction against that evidence and mapping; the worker must not generate new candidates.
16
+ In `candidate-comparison` mode, inspect the evidence needed to distinguish candidates, submit no more than three candidates, give each one a feasibility verdict (`feasible`, `not-feasible`, or `uncertain`) with a one-sentence rationale, state the strongest counterevidence for each one, and map every candidate to the stable brief end-state IDs it satisfies, preserves, or leaves unresolved. In `preselected-validation` mode, validate the one preselected direction against that evidence and mapping with the same verdict; the worker must not generate new candidates.
17
17
 
18
18
  ## Decision principles
19
19
 
@@ -33,7 +33,7 @@ Reason independently from other workers. Do not collapse overlapping candidates
33
33
 
34
34
  ## Completion criteria
35
35
 
36
- In `candidate-comparison` mode, every submitted candidate has a criterion score, counterevidence, and stable requirement mapping. In `preselected-validation` mode, the one preselected direction has a validation result, counterevidence, and stable requirement mapping. Rejected candidates retain their audit reason and evidence.
36
+ In `candidate-comparison` mode, every submitted candidate has a criterion score, a feasibility verdict with its rationale, counterevidence, and stable requirement mapping. In `preselected-validation` mode, the one preselected direction has a validation result, a feasibility verdict with its rationale, counterevidence, and stable requirement mapping. Rejected candidates retain their audit reason and evidence.
37
37
 
38
38
  ## Forbidden conduct
39
39
 
@@ -24,7 +24,7 @@ Report assembly reads the role-owned inputs, validates them, derives links and s
24
24
 
25
25
  An active clarification exists only in `activeClarifications[]`. A decision carried from a previous run exists only in `carriedDecisions[]`; do not recreate it as an active question.
26
26
 
27
- A carried plan row's `requirementCoverage[].decisionRefs` may name a `C-NNN` this run never asked. Carry it before assembly: `okstra approval-decision carry --ledger <approvalDecisionsPath> --from-responses <instruction-set/clarification-response.md> --clarification-id C-NNN` — repeat `--clarification-id` to take several in one call. That bundle is the source of truth for an earlier run's answer: it is task-level and cumulative, so no prior run seq has to be located, and each response section names the report that posed the question, which is where the row's `statement`, `expectedForm`, and options come from. A carried row lands as `answered`, not `resolved` — it was resolved in another run, and `resolution.checkRefs` names *this* run's activity rows. Carrying an answer also obliges a `supersessionLedger` entry for it.
27
+ Prepare seeds `carriedDecisions[]` when it creates the ledger: every clarification the run's carry-in record answered or resolved, plus every row that record's user-responses sidecars answered, arrives carried (`scripts/okstra_ctl/approval_decisions.py` `seed_carried_decisions`). A carried plan row's `requirementCoverage[].decisionRefs` may still name a `C-NNN` the carry-in record does not answer — a decision from an older run. Carry it before assembly: `okstra approval-decision carry --ledger <approvalDecisionsPath> --from-responses <instruction-set/clarification-response.md> --clarification-id C-NNN` — repeat `--clarification-id` to take several in one call. That bundle is the source of truth for an earlier run's answer: it is task-level and cumulative, so no prior run seq has to be located, and each response section names the report that posed the question, which is where the row's `statement`, `expectedForm`, and options come from. A carried row lands as `answered`, not `resolved` — it was resolved in another run, and `resolution.checkRefs` names *this* run's activity rows. Carrying an answer also obliges a `supersessionLedger` entry for it.
28
28
 
29
29
  Each decision option has `role`, `answer`, `rationale`, `disposition`, `reach`, optional `scopeEffects`, `addedWork`, and `directionChange`. `reach` is exactly one of `in-repo` or `cross-repo`. `scopeEffects` may contain `new-schema` and `deferrable`. A `correctness-critical` option cannot use `select` or `accept-risk`; a `noncritical-dissent` option cannot use `select`.
30
30
 
@@ -8,7 +8,7 @@
8
8
  - `directionChange` — one sentence naming what this choice reverses: an approved plan item, a recorded decision, an earlier answer. Name that item. When it reverses nothing, say so.
9
9
  - `disposition` — the effect of selecting the option. Use `select` for `user-decision`. Use `accept-risk` on any classification, including `correctness-critical`, when the user ends the gate and leaves the DISAGREE on the record. Use `request-revision` or `reject` when the option sends the plan back.
10
10
  - report assembly derives `approvalContext`, status, and resolution. `approvalContext` contains only `classification`, `unblockCondition`, and `recommendedDisposition`; it never copies plan or activity identifiers.
11
- - a `C-NNN` you name outside the row itself must be a row that exists. One place is checked: a `blocked` `endStateCoverage` row's `blockedBy.ref`, when its `kind` is `clarification` — see each phase profile's `blockedBy` rule and `validators/validate-run.py` `_validate_end_state_blocked_by`. Everywhere else — `coveredBy`, `rationale`, `verdictCard.nextStep`, `finalVerdict.nextStep`, `humanSummary.actions[]`, `recommendedNextSteps[].text` — is free prose and stays uncheckable: a shipped report legitimately writes `C-057 through C-068 are applied or carried` or `C-201 does not apply`, and a validator scanning those fields for ids would fail 15 of the 56 reports on disk. There it is on you not to send a reader after an id with no row. An id an earlier run already answered is admitted with `okstra approval-decision carry`, not by citing it bare. `crossVerification` rows are numbered `CV-NNN` so a `C-NNN` has exactly one meaning.
11
+ - a `C-NNN` you name outside the row itself must be a row that exists. One place is checked: a `blocked` `endStateCoverage` row's `blockedBy.ref`, when its `kind` is `clarification` — see each phase profile's `blockedBy` rule and `validators/validate-run.py` `_validate_end_state_blocked_by`. Everywhere else — `coveredBy`, `rationale`, `verdictCard.nextStep`, `finalVerdict.nextStep`, `humanSummary.actions[]`, `recommendedNextSteps[].text` — is free prose and stays uncheckable: a shipped report legitimately writes `C-057 through C-068 are applied or carried` or `C-201 does not apply`, and a validator scanning those fields for ids would fail 15 of the 56 reports on disk. There it is on you not to send a reader after an id with no row. An id an earlier run already answered reaches this report as a carried decision — prepare seeds `carriedDecisions[]` from the run's carry-in record, and `okstra approval-decision carry` admits one that record does not answer — never by citing it bare. `crossVerification` rows are numbered `CV-NNN` so a `C-NNN` has exactly one meaning.
12
12
  - the three impact fields answer three different questions — how far the change reaches, what new work it creates, and what it overturns. Someone choosing between options needs all three, so never fold them into one sentence: whichever axis is easiest to write would silently stand in for the other two.
13
13
  - a row that omits `options[]`, offers fewer than two, or marks zero or two options as `recommended` is incomplete and must be completed before the report is finalised.
14
14
  - `expectedForm` states only the *shape* of the answer — one of the options, a file path, a number, a date. It never lists the choices again; two sources for one fact leave consumers disagreeing about which is authoritative.
@@ -34,9 +34,9 @@ roles:
34
34
  - Apply the shared reporter-confirmation precondition exactly as written. Unresolved `intent-check:` and `conversion-block:` rows use `Blocks=next-phase`.
35
35
  - Treat each stable brief end-state ID as a required evaluation target. A missing ID is a preparation failure; do not invent a replacement requirement.
36
36
  - Worker direction-selection procedure:
37
- - In `candidate-comparison` mode, produce candidate, supporting and contradicting evidence, criterion scores, and requirement mappings.
37
+ - In `candidate-comparison` mode, produce candidate, supporting and contradicting evidence, criterion scores, and requirement mappings. For every candidate, state one feasibility verdict — `feasible`, `not-feasible`, or `uncertain` — with a one-sentence rationale and the strongest counterevidence, each citing inspected evidence. The report's `feasibilityVotes` row for this worker is built from that statement, so a candidate without one leaves the writer nothing but another worker's words.
38
38
  - In `candidate-comparison` mode only, submit at most three candidates. A candidate must be feasible from inspected evidence, not from an assumed future change.
39
- - In `preselected-validation` mode, receive one preselected direction from the lead and validate its evidence, counterevidence, criterion scores, and requirement mappings. The worker must not generate new candidates.
39
+ - In `preselected-validation` mode, receive one preselected direction from the lead and validate its evidence, counterevidence, criterion scores, and requirement mappings, and state the same feasibility verdict with its rationale and counterevidence. The worker must not generate new candidates.
40
40
  - Do not produce detailed file lists, stage maps, execution commands, or a plan approval request.
41
41
  - Pre-selection context exploration:
42
42
  - In `candidate-comparison` mode, inspect the code paths, interfaces, tests, and constraints needed to distinguish candidates before assigning scores.
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, Mapping, Sequence
13
13
 
14
14
  from .clarification_items.sidecars import (
15
+ sidecar_answers,
15
16
  attached_response_sections,
16
17
  response_source_report,
17
18
  )
@@ -286,6 +287,90 @@ def carry_decision(
286
287
  write_json_atomic(ledger_path, ledger)
287
288
 
288
289
 
290
+ _CARRIED_STATUSES = frozenset({"answered", "resolved"})
291
+
292
+
293
+ def _carried_answer(row: Mapping[str, Any], sidecar_answer: str) -> str:
294
+ """The answer a prior report's row carries, or an empty string.
295
+
296
+ The user-responses sidecar wins over the row: it is the later writing,
297
+ and a row the report left `open` is answered only there. A `resolved`
298
+ row keeps its answer in `resolution.userText`; an `answered` row in
299
+ `userInput`.
300
+ """
301
+ if sidecar_answer.strip():
302
+ return sidecar_answer.strip()
303
+ user_input = row.get("userInput")
304
+ if isinstance(user_input, str) and user_input.strip():
305
+ return user_input.strip()
306
+ resolution = row.get("resolution")
307
+ if isinstance(resolution, Mapping):
308
+ text = resolution.get("userText")
309
+ if isinstance(text, str) and text.strip():
310
+ return text.strip()
311
+ return ""
312
+
313
+
314
+ def seed_carried_decisions(
315
+ ledger_path: Path, source: Path, *, source_run_ref: str,
316
+ ) -> list[str]:
317
+ """Carry every clarification the carry-in report already settled.
318
+
319
+ Prepare calls this when it creates a run's ledger and the run was
320
+ launched with a carry-in. The lead used to carry each id by hand and
321
+ mostly did not (2026-09-04, dev-10626: the error-analysis ledgers carried
322
+ six rows, the option-selection and planning ledgers none), so the report
323
+ cited `C-005` in its prose and had no row for it. Carrying is a copy with
324
+ no judgement in it — the prior record already holds the question, its
325
+ options, and the answer — which is why it lives here rather than in the
326
+ lead's procedure.
327
+
328
+ Only a structured report record (or the Markdown sibling of one) seeds:
329
+ an answers-only file has no question rows to copy, and there
330
+ ``okstra approval-decision carry --from-responses`` remains the path.
331
+ A row is carried when it is `answered` or `resolved`, or when the
332
+ record's user-responses sidecars answer it; an `open` row nobody
333
+ answered and an `obsolete` row are not decisions. Returns the carried
334
+ ids in record order.
335
+ """
336
+ from .clarification_items.rows import _structured_report_data
337
+
338
+ data = _structured_report_data(source)
339
+ if data is None:
340
+ return []
341
+ rows = data.get("clarificationItems")
342
+ if not isinstance(rows, list):
343
+ return []
344
+ answers = sidecar_answers(source)
345
+ carried: list[str] = []
346
+ for row in rows:
347
+ if not isinstance(row, Mapping):
348
+ continue
349
+ clarification_id = row.get("id")
350
+ if not isinstance(clarification_id, str) or not clarification_id:
351
+ continue
352
+ status = str(row.get("status") or "")
353
+ answer = _carried_answer(row, answers.get(clarification_id, ""))
354
+ if status == "obsolete" or not answer:
355
+ continue
356
+ if status not in _CARRIED_STATUSES and clarification_id not in answers:
357
+ continue
358
+ decision = {key: row[key] for key in _QUESTION_FIELDS if key in row}
359
+ if isinstance(decision.get("options"), list):
360
+ decision["options"] = [
361
+ _contract_v3_option(option, clarification_id)
362
+ for option in decision["options"]
363
+ if isinstance(option, Mapping)
364
+ ]
365
+ if "approvalContext" in row:
366
+ decision["approval"] = row["approvalContext"]
367
+ decision["userConfirmation"] = "asked-and-answered"
368
+ decision["userInput"] = answer
369
+ carry_decision(ledger_path, source_run_ref=source_run_ref, decision=decision)
370
+ carried.append(clarification_id)
371
+ return carried
372
+
373
+
289
374
  def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
290
375
  fields = (
291
376
  args.option_role, args.option_answer, args.option_rationale,
@@ -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],
@@ -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
+ }
@@ -30,36 +30,38 @@ def _anchors_pattern(keys: tuple[str, ...]) -> re.Pattern[str] | None:
30
30
  )
31
31
 
32
32
 
33
- def _link_ids(escaped: str, anchors: dict) -> str:
34
- """Anchor the row ids inside text that is already escaped.
33
+ def _link_ids(escaped: str, links: dict) -> str:
34
+ """Link the row ids inside text that is already escaped.
35
35
 
36
36
  Running after the escape keeps this anchor the only markup in the result.
37
- Only ids the document defines become links an id with no row would point
38
- at a missing anchor, and a ticket number like ``DEV-10339`` has the same
39
- shape as a row id without being one.
37
+ ``links`` maps an id to the href that lands on it: ``#id-…`` for a row of
38
+ this page, or a relative page path plus fragment for a row the carry-in
39
+ report defines. Only ids with a link target become links — an id with no
40
+ row would point at a missing anchor, and a ticket number like
41
+ ``DEV-10339`` has the same shape as a row id without being one.
40
42
  """
41
- if not anchors:
43
+ if not links:
42
44
  return escaped
43
- pattern = _anchors_pattern(tuple(sorted(anchors)))
45
+ pattern = _anchors_pattern(tuple(sorted(links)))
44
46
  if pattern is None:
45
47
  return escaped
46
48
 
47
49
  def swap(match: re.Match[str]) -> str:
48
50
  token = match.group(0)
49
- name = anchors.get(token)
50
- return f'<a href="#{name}">{token}</a>' if name else token
51
+ href = links.get(token)
52
+ return f'<a href="{escape(href)}">{token}</a>' if href else token
51
53
 
52
54
  return pattern.sub(swap, escaped)
53
55
 
54
56
 
55
- def inline_code(value: object, anchors: dict | None = None) -> Markup:
57
+ def inline_code(value: object, links: dict | None = None) -> Markup:
56
58
  """Convert paired backticks to ``<code>``, escaping everything else.
57
59
 
58
60
  Workers author report prose with Markdown conventions, so an identifier
59
- arrives wrapped in backticks. ``anchors`` links the row ids cited in the
61
+ arrives wrapped in backticks. ``links`` links the row ids cited in the
60
62
  prose, inside backticks and out.
61
63
  """
62
- index = anchors or {}
64
+ index = links or {}
63
65
  text = "" if value is None else str(value)
64
66
  out: list[str] = []
65
67
  cursor = 0
@@ -84,7 +86,7 @@ def _grouped_paragraphs(text: str) -> list[str]:
84
86
  ]
85
87
 
86
88
 
87
- def paragraphs(value: object, anchors: dict | None = None) -> Markup:
89
+ def paragraphs(value: object, links: dict | None = None) -> Markup:
88
90
  """Split prose into ``<p>`` blocks, converting inline code inside each.
89
91
 
90
92
  ``userNarrative`` prose arrives as one unbroken run of sentences, so the
@@ -93,27 +95,27 @@ def paragraphs(value: object, anchors: dict | None = None) -> Markup:
93
95
  """
94
96
  text = "" if value is None else str(value)
95
97
  return Markup(
96
- "".join(f"<p>{inline_code(block, anchors)}</p>" for block in _grouped_paragraphs(text))
98
+ "".join(f"<p>{inline_code(block, links)}</p>" for block in _grouped_paragraphs(text))
97
99
  )
98
100
 
99
101
 
100
- def evidence_refs(refs: object, anchors: object) -> Markup:
101
- """Render a citation list, anchoring the entries the document defines.
102
+ def evidence_refs(refs: object, links: object) -> Markup:
103
+ """Render a citation list, linking the entries that have a target.
102
104
 
103
105
  The same index the prose links against decides here too — a citation and a
104
106
  mid-sentence mention of the same id must land in the same place. No schema
105
107
  behind this filter constrains an entry to an id: most of it is prose citing
106
108
  a ``path:line``, which takes ``inline_code`` like every other prose cell.
107
109
  """
108
- index = anchors if isinstance(anchors, dict) else {}
110
+ index = links if isinstance(links, dict) else {}
109
111
  items = refs if isinstance(refs, (list, tuple)) else []
110
112
  out: list[str] = []
111
113
  for ref in items:
112
- name = index.get(str(ref))
113
- if name is None:
114
+ href = index.get(str(ref))
115
+ if href is None:
114
116
  out.append(str(inline_code(ref, index)))
115
117
  continue
116
- out.append(f'<a href="#{name}">{escape(str(ref))}</a>')
118
+ out.append(f'<a href="{escape(href)}">{escape(str(ref))}</a>')
117
119
  return Markup(", ".join(out))
118
120
 
119
121
 
@@ -17,6 +17,7 @@ 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
19
  from .common import anchor_index
20
+ from .context_links import brief_end_states, carry_in_links
20
21
  from .filters import (
21
22
  code_evidence,
22
23
  enum_label,
@@ -130,17 +131,26 @@ def render_v2_html_view(
130
131
  env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
131
132
  env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
132
133
  # Binding the index here is what lets a template cite an id without
133
- # threading the index through every macro and call site.
134
- anchors = anchor_index(data, view.omitted_fields)
134
+ # threading the index through every macro and call site. The record's
135
+ # own rows come first; the brief's end-state rows and the carry-in
136
+ # report's clarifications only fill ids this page has no row for.
137
+ links = {
138
+ row_id: f"#{name}"
139
+ for row_id, name in anchor_index(data, view.omitted_fields).items()
140
+ }
141
+ brief_rows = brief_end_states(data_path)
142
+ for row in brief_rows:
143
+ links.setdefault(row["id"], f"#id-{row['id']}")
144
+ links.update(carry_in_links(data, data_path, exclude=links))
135
145
  chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
136
146
  translate = make_jinja_global(chrome)
137
147
  env.globals["t"] = translate
138
148
  env.filters["code_evidence"] = code_evidence
139
149
  env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
140
150
  env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
141
- env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
142
- env.filters["inline_code"] = lambda value: inline_code(value, anchors)
143
- env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
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)
144
154
  response_js = (root / "report.js").read_text(encoding="utf-8")
145
155
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
146
156
  source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
@@ -153,6 +163,9 @@ def render_v2_html_view(
153
163
  "sourceData": source_data,
154
164
  "dataSha256": _sha256(data_path),
155
165
  "clarificationItems": data.get("clarificationItems", []),
166
+ # 브리프의 최종 상태 행. 모든 phase 가 EB/PB/EO 아이디를 인용하지만
167
+ # 문장은 브리프에만 있으므로 여기서 한 번 그려 인용에 착지점을 준다.
168
+ "briefEndStates": brief_rows,
156
169
  # 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
157
170
  # 감사 모드에만 두면 판정이 사용자에게 안 보인다.
158
171
  "crossVerification": data.get("crossVerification") or {},
@@ -14,6 +14,13 @@ def build_implementation_option_selection_view(data: dict) -> HumanReportView:
14
14
  selection if selection["mode"] == "candidate-comparison" else None
15
15
  ),
16
16
  "narrative": selection["userNarrative"],
17
+ # 기준별 가중치. 옵션 카드의 점수 표가 기준 이름 옆에 가중치를 같이
18
+ # 보여야 가중 점수가 어떻게 나왔는지 읽힌다.
19
+ "criteriaWeights": {
20
+ criterion["criterion"]: criterion["weight"]
21
+ for criterion in selection.get("evaluationCriteria") or []
22
+ if isinstance(criterion, dict)
23
+ },
17
24
  "recommendedOption": next(
18
25
  (
19
26
  option
@@ -198,6 +198,11 @@ class ReportSynthesisPacket:
198
198
  "supplied one feasibility vote, at least "
199
199
  f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
200
200
  "and `unresolvedFeasibilityFacts` are empty.",
201
+ "Each `feasibilityVotes` row states that analyser's own verdict, "
202
+ "rationale, and strongest counterevidence as its result gives them. "
203
+ "Two non-`uncertain` votes with identical rationale and "
204
+ "counterevidence are rejected as one worker's text copied under "
205
+ "another's name.",
201
206
  f"Rank at most {MAX_RANKED_OPTIONS} valid options by descending "
202
207
  "`requirement-fit`, `correctness-risk`, `architecture-fit`, "
203
208
  "`weightedScore`, then id. With valid options set routing to "
@@ -30,6 +30,7 @@ PROSE_KEYS = frozenset({
30
30
  "approach",
31
31
  "approvalDisposition",
32
32
  "approvalEvidence",
33
+ "architectureBoundaries",
33
34
  "behavior",
34
35
  "blastRadius",
35
36
  "blockReason",
@@ -41,13 +42,16 @@ PROSE_KEYS = frozenset({
41
42
  "change",
42
43
  "check",
43
44
  "claim",
45
+ "commitment",
44
46
  "condition",
45
47
  "confirmingSignal",
46
48
  "conformanceExemption",
47
49
  "consequences",
48
50
  "constraint",
49
51
  "context",
52
+ "coreMechanism",
50
53
  "coreReason",
54
+ "counterevidence",
51
55
  "decision",
52
56
  "declinedFixRecommendations",
53
57
  "description",
@@ -66,12 +70,15 @@ PROSE_KEYS = frozenset({
66
70
  "exitContractSummary",
67
71
  "expected",
68
72
  "expectedBehaviorAfter",
73
+ "expectedChangeAreas",
69
74
  "expectedForm",
70
75
  "expectedOutcome",
71
76
  "expectedResult",
72
77
  "expectedVerification",
78
+ "fact",
73
79
  "failureHandling",
74
80
  "finalConclusion",
81
+ "goal",
75
82
  "headline",
76
83
  "howToStart",
77
84
  "hypothesis",
@@ -134,6 +141,7 @@ PROSE_KEYS = frozenset({
134
141
  "verification",
135
142
  "verificationMethod",
136
143
  "verificationSignal",
144
+ "whyItMatters",
137
145
  "workingAssumption",
138
146
  })
139
147
 
@@ -33,7 +33,9 @@ _END_STATE_ID_RE = re.compile(r"^(?:EB|PB|EO)-\d{3}$")
33
33
  _END_STATE_HEADINGS = frozenset(
34
34
  {"Expected Behavior", "Preserved Behavior", "Expected Outcome"}
35
35
  )
36
- _END_STATE_BULLET_RE = re.compile(r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b")
36
+ _END_STATE_BULLET_RE = re.compile(
37
+ r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b\s*(?P<statement>.*)$"
38
+ )
37
39
  # Only `## ` closes a section, mirroring validate-brief.py's section_body
38
40
  # lookahead. `_BRIEF_HEADING_RE` above matches `#` through `######` and is for
39
41
  # heading citations, which are checked against a different reader.
@@ -89,8 +91,21 @@ def brief_headings(brief_path: Path) -> set[str]:
89
91
  return headings
90
92
 
91
93
 
92
- def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
93
- """End-state ids the brief declares, preserving section and item order.
94
+ @dataclass(frozen=True)
95
+ class BriefEndState:
96
+ """One `- EB-001 <statement>` bullet of the brief's end-state sections."""
97
+
98
+ id: str
99
+ # The normalized `## ` heading the bullet sits under — one of
100
+ # `_END_STATE_HEADINGS`.
101
+ section: str
102
+ # The rest of the bullet line, verbatim. The brief is the only place this
103
+ # sentence exists; every phase cites the id and none of them repeats it.
104
+ statement: str
105
+
106
+
107
+ def brief_end_state_rows(brief_path: Path) -> tuple[BriefEndState, ...]:
108
+ """End-state rows the brief declares, preserving section and item order.
94
109
 
95
110
  The reading rules mirror validators/validate-brief.py exactly, because that
96
111
  file checks the very same lines for their format and the two readers must
@@ -116,7 +131,7 @@ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
116
131
  except OSError:
117
132
  return ()
118
133
 
119
- ids: list[str] = []
134
+ rows: list[BriefEndState] = []
120
135
  section = ""
121
136
  for line in text.splitlines():
122
137
  if m := _SECTION_HEADING_RE.match(line):
@@ -126,8 +141,15 @@ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
126
141
  if not section:
127
142
  continue
128
143
  if m := _END_STATE_BULLET_RE.match(line.strip()):
129
- ids.append(m.group("id"))
130
- return tuple(ids)
144
+ rows.append(
145
+ BriefEndState(m.group("id"), section, m.group("statement").strip())
146
+ )
147
+ return tuple(rows)
148
+
149
+
150
+ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
151
+ """End-state ids the brief declares, preserving section and item order."""
152
+ return tuple(row.id for row in brief_end_state_rows(brief_path))
131
153
 
132
154
 
133
155
  def brief_end_state_ids(brief_path: Path) -> set[str]:
@@ -31,6 +31,16 @@ nav.report-index a { color: inherit; }
31
31
  .tone-high, .tone-important { border-left-color: #d94b4b; }
32
32
  .tone-medium { border-left-color: #d79b25; }
33
33
  .evidence-refs { color: GrayText; font-size: .85rem; }
34
+ .section-lede { max-width: 78ch; color: GrayText; margin-top: 0; }
35
+ /* A direction card reads as four labelled blocks — what, how, why, expected
36
+ outcome — so each block carries its own heading and the lists inside stay
37
+ tight enough that the card does not read as one long table. */
38
+ .option-detail { margin-top: 1rem; padding-top: .8rem; border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent); }
39
+ .option-detail h4 { margin: 0 0 .4rem; font-size: 1rem; }
40
+ .option-detail p { margin: .3rem 0; max-width: 78ch; }
41
+ .option-detail ul { margin: .2rem 0 .6rem; padding-left: 1.3rem; }
42
+ .option-detail table { margin-top: .5rem; }
43
+ .direction-goal { margin: -.4rem 0 .7rem 1.6rem; color: GrayText; font-size: .9rem; max-width: 78ch; }
34
44
  figure { margin: 1rem 0 0; }
35
45
  figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; margin-bottom: .7rem; }
36
46
  .visualization { overflow-x: auto; border-radius: 12px; background: color-mix(in srgb, CanvasText 4%, Canvas); }
@@ -44,6 +44,16 @@
44
44
  </header>
45
45
  <main id="main-content" data-report-role="human-main">
46
46
  {% block human_content %}{% endblock %}
47
+ {% if briefEndStates %}
48
+ <section data-report-section="brief-end-states">
49
+ <h2>{{ t('base.brief-end-states') }}</h2>
50
+ <p class="section-lede">{{ t('base.brief-end-states-intro') }}</p>
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>
54
+ </table>
55
+ </section>
56
+ {% endif %}
47
57
  {% if crossVerification.get("consensus") or crossVerification.get("differences") %}
48
58
  <section data-report-section="cross-check">
49
59
  <h2>{{ t('base.cross-check') }}</h2>
@@ -89,6 +89,45 @@
89
89
  "missing-information": "Missing information",
90
90
  "follow-up": "Follow-up",
91
91
  "plan-item": "Plan item"
92
+ },
93
+ "optionCriterion": {
94
+ "requirement-fit": "Requirement fit",
95
+ "architecture-fit": "Architecture fit",
96
+ "change-locality": "Change locality",
97
+ "implementation-complexity": "Implementation complexity",
98
+ "correctness-risk": "Correctness risk",
99
+ "reversibility": "Reversibility",
100
+ "verification-cost": "Verification cost",
101
+ "rollout-cost": "Rollout cost"
102
+ },
103
+ "feasibilityVerdict": {
104
+ "feasible": "Feasible",
105
+ "not-feasible": "Not feasible",
106
+ "uncertain": "Uncertain"
107
+ },
108
+ "commitmentKind": {
109
+ "user-behavior": "User behavior",
110
+ "code-change": "Code change",
111
+ "migration": "Migration",
112
+ "operational-change": "Operational change",
113
+ "preserved-behavior": "Preserved behavior",
114
+ "derived-work": "Derived work"
115
+ },
116
+ "coverageVerdict": {
117
+ "exact": "exact",
118
+ "under": "under",
119
+ "over": "over",
120
+ "contradicted": "contradicted"
121
+ },
122
+ "safetyCategory": {
123
+ "data-loss": "Data loss",
124
+ "security-violation": "Security violation",
125
+ "irreversible-migration": "Irreversible migration"
126
+ },
127
+ "briefSection": {
128
+ "Expected Behavior": "Expected behavior",
129
+ "Preserved Behavior": "Preserved behavior",
130
+ "Expected Outcome": "Expected outcome"
92
131
  }
93
132
  },
94
133
  "enumHint": {
@@ -122,7 +161,11 @@
122
161
  "dismiss": "Dismiss",
123
162
  "export-downloads": "Export downloads",
124
163
  "drop-that-file-into": ". Drop that file into",
125
- "and-the-next-run-picks-your-answers-up-on-it": "and the next run picks your answers up on its own."
164
+ "and-the-next-run-picks-your-answers-up-on-it": "and the next run picks your answers up on its own.",
165
+ "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.",
167
+ "brief-section": "Section",
168
+ "brief-statement": "Statement"
126
169
  },
127
170
  "runUsage": {
128
171
  "heading": "What this run cost",
@@ -342,7 +385,33 @@
342
385
  "option-id": "Option ID",
343
386
  "direction": "Direction",
344
387
  "confirmation-evidence": "Confirmation evidence",
345
- "citation": "Citation"
388
+ "citation": "Citation",
389
+ "upstream-decisions": "Upstream decisions:",
390
+ "evaluation-criteria": "Evaluation criteria",
391
+ "evaluation-criteria-intro": "Every candidate is scored 1 to 5 on each criterion. The weighted score is the weight-averaged result, so a criterion with weight 5 counts five times as much as one with weight 1.",
392
+ "criterion": "Criterion",
393
+ "weight": "Weight",
394
+ "score": "Score",
395
+ "why-it-matters": "Why it matters",
396
+ "what-changes": "What this direction changes",
397
+ "goal": "Goal:",
398
+ "goal-column": "Goal",
399
+ "change-areas": "Expected change areas",
400
+ "commitment": "Commitment",
401
+ "commitment-body": "What the direction commits to",
402
+ "requirements": "Requirements",
403
+ "how-it-works": "How it is done",
404
+ "boundaries": "Architecture boundaries it keeps",
405
+ "why-this-direction": "Why this direction",
406
+ "feasibility-votes": "Feasibility by worker",
407
+ "worker": "Worker",
408
+ "verdict": "Verdict",
409
+ "counterevidence": "Strongest counterevidence",
410
+ "safety-blockers": "Safety blockers",
411
+ "unresolved-facts": "Unresolved feasibility facts",
412
+ "expected-outcome": "Expected outcome",
413
+ "coverage-line": "Covers {covered} of {total} requirements ({verdict}).",
414
+ "planning-invariants": "Must hold in planning"
346
415
  },
347
416
  "implementation-planning": {
348
417
  "what-the-plan-is-for": "What the plan is for",
@@ -89,6 +89,45 @@
89
89
  "missing-information": "빠진 정보",
90
90
  "follow-up": "후속 작업",
91
91
  "plan-item": "계획 항목"
92
+ },
93
+ "optionCriterion": {
94
+ "requirement-fit": "요구사항 적합",
95
+ "architecture-fit": "아키텍처 적합",
96
+ "change-locality": "변경 국소성",
97
+ "implementation-complexity": "구현 복잡도",
98
+ "correctness-risk": "정확성 위험",
99
+ "reversibility": "되돌리기 쉬움",
100
+ "verification-cost": "검증 비용",
101
+ "rollout-cost": "배포 비용"
102
+ },
103
+ "feasibilityVerdict": {
104
+ "feasible": "실행 가능",
105
+ "not-feasible": "실행 불가",
106
+ "uncertain": "불확실"
107
+ },
108
+ "commitmentKind": {
109
+ "user-behavior": "사용자 동작",
110
+ "code-change": "코드 변경",
111
+ "migration": "마이그레이션",
112
+ "operational-change": "운영 변경",
113
+ "preserved-behavior": "보존 동작",
114
+ "derived-work": "파생 작업"
115
+ },
116
+ "coverageVerdict": {
117
+ "exact": "정확히 일치",
118
+ "under": "미달",
119
+ "over": "초과",
120
+ "contradicted": "모순"
121
+ },
122
+ "safetyCategory": {
123
+ "data-loss": "데이터 손실",
124
+ "security-violation": "보안 위반",
125
+ "irreversible-migration": "되돌릴 수 없는 마이그레이션"
126
+ },
127
+ "briefSection": {
128
+ "Expected Behavior": "기대 동작",
129
+ "Preserved Behavior": "보존 동작",
130
+ "Expected Outcome": "기대 산출"
92
131
  }
93
132
  },
94
133
  "enumHint": {
@@ -122,7 +161,11 @@
122
161
  "dismiss": "닫기",
123
162
  "export-downloads": "내보내면 다음 파일이 받아집니다",
124
163
  "drop-that-file-into": ". 그 파일을 여기에 두면",
125
- "and-the-next-run-picks-your-answers-up-on-it": "다음 run 이 알아서 답변을 읽어 갑니다."
164
+ "and-the-next-run-picks-your-answers-up-on-it": "다음 run 이 알아서 답변을 읽어 갑니다.",
165
+ "brief-end-states": "브리프가 고정한 최종 상태",
166
+ "brief-end-states-intro": "이 리포트가 인용하는 요구사항 아이디(EB·PB·EO)는 브리프의 다음 항목입니다. 문장은 태스크 브리프 원문 그대로입니다.",
167
+ "brief-section": "절",
168
+ "brief-statement": "내용"
126
169
  },
127
170
  "runUsage": {
128
171
  "heading": "이번 실행에 든 비용",
@@ -342,7 +385,33 @@
342
385
  "option-id": "옵션 아이디",
343
386
  "direction": "방향",
344
387
  "confirmation-evidence": "확인 근거",
345
- "citation": "인용"
388
+ "citation": "인용",
389
+ "upstream-decisions": "선행 결정:",
390
+ "evaluation-criteria": "평가 기준",
391
+ "evaluation-criteria-intro": "모든 후보를 기준마다 1~5점으로 매깁니다. 가중 점수는 가중치를 반영한 평균이라, 가중치 5인 기준은 가중치 1인 기준의 다섯 배로 반영됩니다.",
392
+ "criterion": "기준",
393
+ "weight": "가중치",
394
+ "score": "점수",
395
+ "why-it-matters": "왜 중요한가",
396
+ "what-changes": "무엇을 바꾸는가",
397
+ "goal": "목표:",
398
+ "goal-column": "목표",
399
+ "change-areas": "예상 변경 영역",
400
+ "commitment": "약속",
401
+ "commitment-body": "이 방향이 약속하는 것",
402
+ "requirements": "요구사항",
403
+ "how-it-works": "어떻게 구현하는가",
404
+ "boundaries": "지키는 아키텍처 경계",
405
+ "why-this-direction": "왜 이 방향인가",
406
+ "feasibility-votes": "작업자별 실행 가능성",
407
+ "worker": "작업자",
408
+ "verdict": "판정",
409
+ "counterevidence": "가장 강한 반대 근거",
410
+ "safety-blockers": "안전 차단 요인",
411
+ "unresolved-facts": "미확정 실행 가능성 사실",
412
+ "expected-outcome": "예상 결과",
413
+ "coverage-line": "요구사항 {total}개 중 {covered}개를 충족합니다({verdict}).",
414
+ "planning-invariants": "계획에서 지켜야 할 조건"
346
415
  },
347
416
  "implementation-planning": {
348
417
  "what-the-plan-is-for": "이 계획이 하려는 것",
@@ -47,6 +47,7 @@
47
47
  <fieldset><legend>{{ t('macros.forms.direction') }}</legend>
48
48
  {% for option in state.rankedOptions %}
49
49
  <input id="direction-selection-{{ option.id }}" type="radio" name="direction-selection-option" value="{{ option.id }}" data-option-name="{{ option.name }}" required><label for="direction-selection-{{ option.id }}">{{ option.id | inline_code }} · {{ option.name | inline_code }}{% if option.id == state.recommendedOptionId %} ({{ t('macros.forms.recommended') }}){% endif %}</label>
50
+ <p class="direction-goal">{{ option.goal | inline_code }}</p>
50
51
  {% endfor %}
51
52
  </fieldset>
52
53
  <input id="direction-selection-confirmed" type="checkbox" required><label for="direction-selection-confirmed">{{ t('macros.forms.confirmed') }}</label>
@@ -2,6 +2,51 @@
2
2
  {% from "html/macros/layout.html" import narrative as render_narrative, summary_card, row_key %}
3
3
  {% from "html/macros/forms.html" import direction_selection %}
4
4
 
5
+ {# One direction, read top to bottom as what it changes, how, why it ranks
6
+ where it does, and what the reader should expect afterwards. Every block
7
+ is a field the writer already fills; the card used to show the goal line
8
+ and the coverage table and left the mechanism, scores, votes, commitments
9
+ and invariants in the record only. #}
10
+ {% macro option_card(row, rank, criteria) -%}
11
+ <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>
14
+ <div class="option-detail">
15
+ <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.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>
20
+ </div>
21
+ <div class="option-detail">
22
+ <h4>{{ t('tasks.implementation-option-selection.how-it-works') }}</h4>
23
+ <p>{{ row.coreMechanism | inline_code }}</p>
24
+ <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
+ </div>
27
+ <div class="option-detail">
28
+ <h4>{{ t('tasks.implementation-option-selection.why-this-direction') }}</h4>
29
+ <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
+ <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
+ {% 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
+ {% 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
+ </div>
38
+ <div class="option-detail">
39
+ <h4>{{ t('tasks.implementation-option-selection.expected-outcome') }}</h4>
40
+ <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
+ <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
+ {% 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
+ </div>
46
+ <p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.codeEvidence | evidence_refs }}</p>
47
+ </article>
48
+ {%- endmacro %}
49
+
5
50
  {% block human_content %}
6
51
  <section data-report-section="selection-status">
7
52
  <h2>{{ t('tasks.implementation-option-selection.selection-status') }}</h2>
@@ -21,29 +66,28 @@
21
66
  {{ render_narrative(narrative.comparisonOverview, "implementationOptionSelection.userNarrative.comparisonOverview") }}
22
67
  <p data-report-field="implementationOptionSelection.decisionContext.originalRequirementIds"><strong>{{ t('tasks.implementation-option-selection.requirement-ids') }}</strong> {{ selection.decisionContext.originalRequirementIds | join(", ") | inline_code }}</p>
23
68
  {% if selection.decisionContext.leadingCauseRefs %}<p><strong>{{ t('tasks.implementation-option-selection.leading-causes') }}</strong> {{ selection.decisionContext.leadingCauseRefs | join(", ") | inline_code }}</p>{% endif %}
69
+ {% if selection.decisionContext.upstreamDecisionRefs %}<p><strong>{{ t('tasks.implementation-option-selection.upstream-decisions') }}</strong> {{ selection.decisionContext.upstreamDecisionRefs | join(", ") | inline_code }}</p>{% endif %}
70
+ </section>
71
+
72
+ <section data-report-section="evaluation-criteria" data-report-field="implementationOptionSelection.evaluationCriteria">
73
+ <h2>{{ t('tasks.implementation-option-selection.evaluation-criteria') }}</h2>
74
+ <p class="section-lede">{{ t('tasks.implementation-option-selection.evaluation-criteria-intro') }}</p>
75
+ <table><thead><tr><th>{{ t('tasks.implementation-option-selection.criterion') }}</th><th class="figure">{{ t('tasks.implementation-option-selection.weight') }}</th><th>{{ t('tasks.implementation-option-selection.why-it-matters') }}</th></tr></thead><tbody>{% for criterion in selection.evaluationCriteria %}<tr><td>{{ criterion.criterion | enum_label('optionCriterion') }}</td><td class="figure">{{ criterion.weight }}</td><td>{{ criterion.rationale | inline_code }}</td></tr>{% endfor %}</tbody></table>
24
76
  </section>
25
77
 
26
78
  <section data-report-section="ranked-options" data-report-field="implementationOptionSelection.rankedOptions">
27
79
  <h2>{{ t('tasks.implementation-option-selection.ranked-options') }}</h2>
28
80
  {{ render_narrative(narrative.rankingExplanation, "implementationOptionSelection.userNarrative.rankingExplanation") }}
29
- <div class="summary-grid">{% for row in selection.rankedOptions %}<article class="summary-card" id="id-{{ row.id }}">
30
- <p class="eyebrow">#{{ loop.index }} · {{ row.id | inline_code }}</p>
31
- <h3>{{ row.name | inline_code }}</h3>
32
- <p>{{ row.goal | inline_code }}</p>
33
- <div data-report-field="implementationOptionSelection.rankedOptions.coverageSummary"><p><strong>{{ t('macros.layout.coverage-percent') }}</strong> <span data-report-metric="coveragePercent">{{ row.coverageSummary.coveragePercent }}</span></p>
34
- <p><strong>{{ t('macros.layout.scope-precision-percent') }}</strong> <span data-report-metric="scopePrecisionPercent">{{ row.coverageSummary.scopePrecisionPercent }}</span></p></div>
35
- <p><strong>{{ t('tasks.implementation-option-selection.weighted-score') }}</strong> {{ row.weightedScore }}</p>
36
- <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>
37
- </article>{% else %}<p>{{ t('tasks.implementation-option-selection.no-valid-options') }}</p>{% endfor %}</div>
81
+ <div class="summary-grid">{% for row in selection.rankedOptions %}{{ option_card(row, loop.index, criteriaWeights) }}{% else %}<p>{{ t('tasks.implementation-option-selection.no-valid-options') }}</p>{% endfor %}</div>
38
82
  </section>
39
83
 
40
84
  <section data-report-section="recommendation" data-report-field="implementationOptionSelection.recommendedOptionId">
41
85
  <h2>{{ t('tasks.implementation-option-selection.recommended-direction') }}</h2>
42
- {% if recommendedOption %}{{ summary_card(recommendedOption.id ~ " · " ~ recommendedOption.name, recommendedOption.coreMechanism, "important") }}{% else %}<p>{{ t('tasks.implementation-option-selection.no-recommendation') }}</p>{% endif %}
86
+ {% if recommendedOption %}{{ summary_card(recommendedOption.id ~ " · " ~ recommendedOption.name, recommendedOption.goal ~ " " ~ recommendedOption.coreMechanism, "important") }}{% else %}<p>{{ t('tasks.implementation-option-selection.no-recommendation') }}</p>{% endif %}
43
87
  </section>
44
88
 
45
89
  <section data-report-section="candidate-audit" data-report-field="implementationOptionSelection.candidateAudit">
46
90
  <h2>{{ t('tasks.implementation-option-selection.candidate-audit') }}</h2>
47
- <table><thead><tr><th>{{ t('tasks.implementation-option-selection.candidate') }}</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.disposition }}</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>
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>
48
92
  </section>
49
93
  {% endblock %}