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.
- package/docs/architecture.md +2 -0
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/duties/direction-selection-worker.md +2 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
- package/runtime/prompts/profiles/implementation-option-selection.md +2 -2
- package/runtime/python/okstra_ctl/approval_decisions.py +85 -0
- package/runtime/python/okstra_ctl/implementation_options.py +35 -0
- package/runtime/python/okstra_ctl/render.py +13 -0
- package/runtime/python/okstra_ctl/report_html/common.py +60 -4
- package/runtime/python/okstra_ctl/report_html/context_links.py +137 -0
- package/runtime/python/okstra_ctl/report_html/filters.py +22 -20
- package/runtime/python/okstra_ctl/report_html/render.py +18 -5
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +7 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +5 -0
- package/runtime/python/okstra_ctl/report_translation.py +8 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +28 -6
- package/runtime/templates/reports/html/assets/base.css +10 -0
- package/runtime/templates/reports/html/base.template.html +10 -0
- package/runtime/templates/reports/html/i18n/en.json +71 -2
- package/runtime/templates/reports/html/i18n/ko.json +71 -2
- package/runtime/templates/reports/html/macros/forms.html +1 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +55 -11
package/docs/architecture.md
CHANGED
|
@@ -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
package/runtime/BUILD.json
CHANGED
|
@@ -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`
|
|
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
|
|
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 =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
34
|
-
"""
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
43
|
+
if not links:
|
|
42
44
|
return escaped
|
|
43
|
-
pattern = _anchors_pattern(tuple(sorted(
|
|
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
|
-
|
|
50
|
-
return f'<a href="
|
|
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,
|
|
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. ``
|
|
61
|
+
arrives wrapped in backticks. ``links`` links the row ids cited in the
|
|
60
62
|
prose, inside backticks and out.
|
|
61
63
|
"""
|
|
62
|
-
index =
|
|
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,
|
|
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,
|
|
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,
|
|
101
|
-
"""Render a citation list,
|
|
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 =
|
|
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
|
-
|
|
113
|
-
if
|
|
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="
|
|
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
|
-
|
|
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,
|
|
142
|
-
env.filters["inline_code"] = lambda value: inline_code(value,
|
|
143
|
-
env.filters["paragraphs"] = lambda value: paragraphs(value,
|
|
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 {},
|
package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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 %}
|
|
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 %}
|