okstra 0.210.0 → 0.211.0

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 (24) hide show
  1. package/docs/cli.md +2 -2
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/wizard/prompts.ko.json +3 -3
  5. package/runtime/python/okstra_ctl/manager_launch.py +2 -1
  6. package/runtime/python/okstra_ctl/manager_paths.py +4 -0
  7. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
  8. package/runtime/python/okstra_ctl/phases/implementation/report.py +66 -19
  9. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +8 -7
  10. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +6 -2
  11. package/runtime/python/okstra_ctl/report_html/run_usage.py +9 -0
  12. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -8
  13. package/runtime/python/okstra_ctl/report_projections.py +18 -2
  14. package/runtime/python/okstra_ctl/report_synthesis_packet.py +19 -0
  15. package/runtime/python/okstra_ctl/report_translation.py +27 -6
  16. package/runtime/python/okstra_ctl/wizard/state.py +30 -1
  17. package/runtime/python/okstra_ctl/wizard/steps_options.py +19 -0
  18. package/runtime/python/okstra_ctl/wizard/steps_roles.py +7 -1
  19. package/runtime/schemas/final-report-v3.0.schema.json +11 -0
  20. package/runtime/templates/reports/html/assets/base.css +4 -2
  21. package/runtime/templates/reports/html/base.template.html +14 -9
  22. package/runtime/templates/reports/html/i18n/en.json +91 -2
  23. package/runtime/templates/reports/html/i18n/ko.json +91 -2
  24. package/runtime/templates/reports/html/macros/forms.html +8 -7
package/docs/cli.md CHANGED
@@ -868,8 +868,8 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
868
868
  | `okstra migrate [--apply] [--cwd <dir>] [--quiet]` | One-time migration of the project artifact root from `.project-docs/okstra/` to `.okstra/`. It is a dry run by default; `--apply` performs the move with `git mv` in a Git worktree, removes an empty `.project-docs/`, and synchronizes the `<PROJECT>/CLAUDE.md` import line, `.gitignore`, the project's rows in `~/.okstra/{recent,active}.jsonl`, and `~/.okstra/worktrees/registry.json`. It exits 1 if `.okstra/` already exists or the legacy directory is absent. Scheduled for removal by the end of v0.x |
869
869
  | `okstra task-list [--project-root <path>]` | Combine `list_project_tasks` and `read_latest_task` into JSON containing the task catalog and latest task |
870
870
  | `okstra task-show <task-key> [--project-root <path>]` | Summarize workflow, phase, status, and artifacts from the Task Read-Side Snapshot |
871
- | `okstra stage-map <task-key> [--cwd <dir>\|--project <dir>]` | Dump the task's implementation-planning Stage Map as JSON: `{ ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }`. `sourcePlanPath` is the task's latest implementation-planning report — the authority on which stages exist, so a plan amendment's added stages are selectable as soon as the amended plan lands. `state` is `ready` for a resolved source and `missing` when no Stage Map exists; a corrupt source returns a structured non-zero error instead of silently selecting another report. `doneStages` is read from the implementation-planning stage consumer state (with carry recovery). This is the read-side source `/okstra-schedule-gen [task-group]` uses to derive selectable unfinished stages and their completed dependency closure |
872
- | `okstra stage-close <task-key> --stage <N> --from-commit <sha> [--cwd <dir>\|--project <dir>]` | Record the `done` stage-consumer row an implementation run would have written, for a stage whose work is already committed but which never registered as done — the run ended before writing its carry sidecar, and `backfill_done_from_carry` recovers only from that file. Without it `stage-map` reports `doneStages: []` while the branch carries the commit, the next plan re-describes the stage, and every RED expectation it produces is unreachable. Refuses unless the Stage Map has that stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a commit in the project repo, and the stage's conformance gate permits progress — the same `decide_conformance_gate` the run validator uses, so a stage closed here is not one the validator would have blocked. Closing also moves the `stage-<N>-exit` tag and releases the stage reservation, exactly as a normal stage completion does. Emits `{ ok, taskKey, taskRoot, stage, headCommit, conformance, consumersPath }` |
871
+ | `okstra stage-map <task-key> [--cwd <dir>\|--project <dir>]` | Dump the task's implementation-planning Stage Map as JSON: `{ ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count,cancelled}], doneStages:[int] }`. Each stage row carries `cancelled: true` when the plan marks that Stage Map row `status: cancelled`; such a stage is never selected, cannot be closed, and is left out of whole-task final verification and release handoff. `sourcePlanPath` is the task's latest implementation-planning report — the authority on which stages exist, so a plan amendment's added stages are selectable as soon as the amended plan lands. `state` is `ready` for a resolved source and `missing` when no Stage Map exists; a corrupt source returns a structured non-zero error instead of silently selecting another report. `doneStages` is read from the implementation-planning stage consumer state (with carry recovery). This is the read-side source `/okstra-schedule-gen [task-group]` uses to derive selectable unfinished stages and their completed dependency closure |
872
+ | `okstra stage-close <task-key> --stage <N> --from-commit <sha> [--cwd <dir>\|--project <dir>]` | Record the `done` stage-consumer row an implementation run would have written, for a stage whose work is already committed but which never registered as done — the run ended before writing its carry sidecar, and `backfill_done_from_carry` recovers only from that file. Without it `stage-map` reports `doneStages: []` while the branch carries the commit, the next plan re-describes the stage, and every RED expectation it produces is unreachable. Refuses unless the Stage Map has that stage and does not mark it cancelled, no `done`/`failed` row exists for it, `--from-commit` resolves to a commit in the project repo, and the stage's conformance gate permits progress — the same `decide_conformance_gate` the run validator uses, so a stage closed here is not one the validator would have blocked. Closing also moves the `stage-<N>-exit` tag and releases the stage reservation, exactly as a normal stage completion does. Emits `{ ok, taskKey, taskRoot, stage, headCommit, conformance, consumersPath }` |
873
873
  | `okstra incremental-scope <args…>` | Decide re-verify vs carry-forward scope for an `implementation-planning` clarification re-run. Thin shim into `scripts/okstra_ctl/incremental_scope.py` (deterministic): it reads the dependency graph from the prior run `data.json`'s `implementationPlanning.stageMap` and returns `mode:"incremental"` only when the base-ref SHA is unchanged and the affected stages' `downstream_stage_closure` covers at most half of all stages; `--full-reason` (selected option / Stage Map / approach) still forces `mode:"full"`. An answered `C-NNN` that traces to no stage returns `mode:"unresolved"` rather than full — pass `--impacted` with the stage numbers or `--full-reason`. `--run-manifest <path>` is required for a decision (not for `--preview`): the same decision is written to the record that manifest names in `incrementalDecisionPath`, so the report writer's authoring contract and `okstra incremental-carry` read it instead of CSVs the lead re-typed. `mode: "unresolved"` is a question back to the lead and is deliberately not recorded. `--preview --prev-data <path> --answered-clarifications <csv>` runs the link half alone — no base SHA, no side effects — and prints `{wouldForceFull, unlinkedIds, reason}`; unlinked ids set `wouldForceFull: false` and fill `unlinkedIds` |
874
874
  | `okstra incremental-carry <args…>` | Merge carried-forward plan-item verdicts into an incremental re-run. Contract v3 takes `--prev-data`, `--cur-narrative`, and the convergence-owned `--state`; it verifies carried stage rows and writes only `--out-state`, tagging copied verdicts with `carriedForwardFromSeq`. Pass `--decision <incrementalDecisionPath>` for the two stage sets — the same record the report writer's authoring contract was built from — instead of `--carry-stages` / `--reverify-stages`, which the lead re-typed off stdout; the two forms cannot be combined, and a record whose `mode` is not `incremental` is refused. Unchanged `P-Val-*` / `P-Req-*` / `P-Rb-*` rows whose extract hash still matches are carried the same way, and the sibling `plan-items-*.json` `dispatchQueue` is rewritten to match. The historical v2 `--cur-data --out` form remains readable. Ownership, scope, item, or schema drift raises `CarryError` and forces a full fallback. |
875
875
  | `okstra code-review target --task-key <k> --stage <N> [--project-root <dir>] [--cwd <dir>] [--json]` / `okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] [--cwd <dir>] [--json]` | Resolve what a code review reads and where its result file goes. Output is always JSON, so `--json` only makes that explicit. `--project-root` and `--cwd` are shared pre-dispatch arguments and apply to both modes; `--cwd` is only consulted when `--project-root` is absent. Both modes return `{ ok, projectRoot, mode, worktreePath, branch, baseCommit, headCommit, reviewPath, round }`; stage mode additionally returns `taskKey`, `taskRoot`, and `stage`. Stage mode takes the diff base from the `base_ref` recorded on that stage's worktree-registry row when it was provisioned — not from a rule re-applied at review time — and names the result `.okstra/tasks/<task-group>/<task-id>/code-reviews/stage-<NN>.md`, where a re-review of the same stage becomes `-r2`, `-r3`, … (the `round` field). Only a legacy row provisioned before `base_ref` was recorded falls back to re-deriving the base through `stage_targets`, and a failure there is reported as `stage_base_unresolved`. `worktreePath` comes back empty whenever the stage worktree is not usable as a live checkout — the registry row is no longer `active` (whole-task final-verification released it), the row never carried a path, or the recorded directory is gone — and the review then reads the `branch` ref instead. Branch mode uses `--base` when given, otherwise the merge-base with the default branch (`refs/remotes/origin/HEAD`, else `main`/`master`), and names the result `.project-docs/code-reviews/<branch>/<YYYY-MM-DD>-<NN>.md`, where `<NN>` (the `round` field) is the next sequence number for that date — the highest already on disk plus one. Read-only: it resolves paths and creates no directory and no file, so the review directory does not exist until the caller writes the report. Backend for the okstra-code-review skill |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.210.0",
3
+ "version": "0.211.0",
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.210.0",
3
- "builtAt": "2026-10-05T13:33:31.081Z",
2
+ "package": "0.211.0",
3
+ "builtAt": "2026-10-06T01:25:20.915Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -372,14 +372,14 @@
372
372
  }
373
373
  },
374
374
  "directive_pick": {
375
- "label": "추가 directive 가 있나요?",
375
+ "label": "이번 run 에 덧붙일 추가 지시(directive)를 고르세요. directive 는 instruction-set/directive.txt 로 저장되어 리드와 워커가 읽습니다. 브리프와 이전 리포트에 없는 새 결정(직전 run 이후 정한 사항 등)은 여기에 넣어야 계획에 반영됩니다.",
376
376
  "echo_template": "directive(pick): {value}",
377
377
  "options": {
378
378
  "__skip__": "없음 (건너뛰기)",
379
379
  "__free_input__": "직접 입력"
380
380
  },
381
381
  "labels": {
382
- "reuse_last": "이전 run 의 directive 재사용: {snippet}"
382
+ "reuse_last": "이전 run 의 directive 재사용: {snippet}", "context_file": " — 이 directive 는 파일 {path} 을 읽으라는 지시이고, 그 파일은 {date} 에 마지막으로 바뀌었습니다. 이후 정한 결정은 들어 있지 않습니다", "context_missing": " — 이 directive 가 가리키는 파일 {path} 이 없습니다"
383
383
  },
384
384
  "echo_suffixes": {
385
385
  "reuse": "directive: {value} (재사용)",
@@ -477,7 +477,7 @@
477
477
  }
478
478
  },
479
479
  "reuse_previous": {
480
- "label": "이 task·phase 의 직전 run 설정이 남아 있습니다. 그대로 재사용할까요?\n· 예 — 직전 run 의 워커 구성·역할별 모델·directive·관련 task 를 그대로 가져오고, 가장 최근 final-report 를 clarification 입력으로 자동 선택해 바로 확인 단계로 넘어갑니다.\n· 아니오 — 모델/워커/directive 등을 단계별로 다시 입력합니다.",
480
+ "label": "이 task·phase 의 직전 run 설정이 남아 있습니다. 그대로 재사용할까요?\n· 예 — 직전 run 의 워커 구성·역할별 모델·directive·관련 task 를 가져오고, 가장 최근 final-report 를 clarification 입력으로 자동 선택합니다. 가져온 항목은 다시 묻지 않고, 재사용 대상이 아닌 단계(기준 브랜치·재검증 범위 등)는 이어서 묻습니다.\n· 아니오 — 모델/워커/directive 등을 단계별로 다시 입력합니다.\n재사용할 directive: {directive}\n직전 run 이후 새로 정한 결정이 있으면 이 directive 에 없습니다. 확인 단계의 수정에서 directive_pick 을 고르면 directive 부터 다시 고를 수 있습니다(역할별 모델은 유지).",
481
481
  "echo_template": "reuse-previous: {value}",
482
482
  "options": {
483
483
  "yes": "예 — 직전 설정 그대로 진행",
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from okstra_ctl.jsonl import append_jsonl, read_jsonl
8
8
 
9
9
  from .manager_paths import (
10
+ MANAGER_CONTEXT_DIRECTIVE_PREFIX,
10
11
  child_context_path,
11
12
  children_json_path,
12
13
  directives_jsonl_path,
@@ -244,7 +245,7 @@ def build_launch_packet(
244
245
  run_args.extend(["--task-brief", str(child["briefPath"])])
245
246
  run_args += [
246
247
  "--directive",
247
- f"Read manager child context: {context_path}",
248
+ f"{MANAGER_CONTEXT_DIRECTIVE_PREFIX}{context_path}",
248
249
  ]
249
250
  packet = {
250
251
  "managerId": manager_id,
@@ -118,3 +118,7 @@ def child_context_path(
118
118
 
119
119
  def view_html_path(home: Path, manager_id: str) -> Path:
120
120
  return manager_root(home, manager_id) / "view" / "index.html"
121
+
122
+
123
+ # manager 가 자식 run 에 넘기는 directive 의 앞부분. 위저드는 이 접두로 컨텍스트 파일을 찾는다.
124
+ MANAGER_CONTEXT_DIRECTIVE_PREFIX = "Read manager child context: "
@@ -12,11 +12,12 @@ are collected and convergence finished. Phase 1-5 do not need it.
12
12
 
13
13
  - **Plan link & approval evidence**: path to the approved `final-report.md`, the exact quoted approval marker, AND the executed stage number / title quoted from the Stage Map row. For a selected-direction plan, also quote `selectedDirectionRef.optionId`, `snapshotPath`, and the validated snapshot digest; for a legacy plan, quote the effective `implementation-option` or the Recommended Option fallback.
14
14
  - **Commit list**: each commit's SHA (or short SHA), message, and the plan step(s) / TDD cycle it satisfies
15
- - **Diff summary**: capture `git diff --stat <base>..<captured-head>` and `git diff --numstat <base>..<captured-head>` for the full implementation stage, including after a fix run. Supply both outputs to the report writer; populate each `diffSummary.files[].lines` with its added/deleted counts and a one-line change summary. Do not substitute a fix-run aggregate or carry forward older statistics. If the captured commits are unavailable, identify the missing reference in the evidence instead of inventing counts.
15
+ - **Diff summary**: capture `git diff --stat <base>..<captured-head>` and `git diff --numstat <base>..<captured-head>` for the full implementation stage, including after a fix run. Supply both outputs to the report writer; put each file's added/deleted counts alone in `diffSummary.files[].lines` (`+150/-0`) and one sentence on what the change in that file does in `diffSummary.files[].summary` — the report renders `summary` as the file's "what it does" column and `lines` as a separate number column. Do not substitute a fix-run aggregate or carry forward older statistics. If the captured commits are unavailable, identify the missing reference in the evidence instead of inventing counts.
16
+ - **Change narrative scope**: `userNarrative.changeExplanation` describes the same scope as `diffSummary` — the whole stage from its base, every file the table lists. On a fix run, state what the fix run itself changed (its commits and files) as a separate sentence; never present the fix-run delta as the stage's change.
16
17
  - **Out-of-plan edits block**: every file edited that was not in the approved plan's file list, with rationale (empty block is acceptable and preferred)
17
18
  - **Clarification answers carried in**: when `instruction-set/clarification-response.md` exists, it holds the user's answers to the approved plan's `## 1. Clarification Items` rows. The report MUST render the `## 0. Clarification Response Carried In From Previous Run` section (set `clarificationCarryIn.sourceFile` to that path) and, for every carried-over `## 1.` row whose answer appears there, record the answer in the `User input` cell and flip `Status` to `resolved`. A row left `open`/`answered` despite a matching user answer is a contract violation; an answer that materially changes scope beyond the approved plan is routed to a new `implementation-planning` run, not silently executed.
18
- - **Stage sidecar evidence**: the JSON payload of `runs/<impl-task-key>/carry/stage-<N>.json` is embedded verbatim in a fenced ```json``` block, AND the `consumers.jsonl` rows this run appended are quoted line-by-line, so reviewers can audit the carry surface without grepping artifact directories.
19
- - **Validation evidence**: actual command output (stdout/stderr) for every `pre / mid / post` validation command from the plan. Truncated output is acceptable but the command line and exit code MUST be exact. No paraphrasing of test results.
19
+ - **Stage sidecar evidence**: set `stageSidecarEvidence.carryJson` to the path `runs/<impl-task-key>/carry/stage-<N>.json` followed by a one-line summary of its payload — stage number, step results by status, stage commit range, number of files changed. Do not paste the payload itself: the file on disk is the record (`_validate_stage_carry_sidecar_exists` checks that it exists), and a pasted one-line JSON is unreadable in the report. Quote the `consumers.jsonl` rows this run appended line-by-line in `consumerRows`.
20
+ - **Validation evidence**: actual command output (stdout/stderr) for every `pre / mid / post` validation command from the plan. Truncated output is acceptable but the command line and exit code MUST be exact. No paraphrasing of test results. Set `checkId` to the plan's `validationChecklist` id (`VC-007`) when the row runs a checklist command, and `advisory: true` on a row whose check is advisory (an external Tier 3 conformance entry, for example) so a non-zero exit there is not shown as a failure.
20
21
  - **TDD evidence (when applicable)**: for steps that should be TDD-ordered, show the failing-test output captured BEFORE the merged commit and the passing-test output after, citing the single `feat|fix` commit SHA (test and implementation share one commit).
21
22
  - **Verifier results**: a section per verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, and `Antigravity verifier` when opted in) containing:
22
23
  - their independent verdict (PASS / CONCERNS / FAIL),
@@ -66,4 +67,4 @@ are collected and convergence finished. Phase 1-5 do not need it.
66
67
  - On a non-`FAIL` verdict, for this run's single stage: append a `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` with `completed_at`, `carry_path`, `report_path` (this run's final-report path relative to the run root), and the SHA of HEAD. Append it with `okstra_ctl.consumers.append_consumer` (NOT a raw filesystem write) — that call honours the consumers lock AND releases this stage's worktree-registry occupancy, so later runs stop seeing a finished stage as a concurrent run. `report_path` lets `final-verification` cite each stage's originating report when assembling its Source Implementation Report list.
67
68
  - **Reopening a settled stage.** Appending a `status:"failed"` row after a `done` one withdraws that stage: `--stage N` accepts it again, its dependents stop resolving a base from the withdrawn head, and whole-task final-verification blocks until it is settled again. Use the same `okstra_ctl.consumers.append_consumer` call with a `reason`, and re-run the stage rather than repairing the tree outside okstra — a stage reverted outside the ledger leaves the recorded `head_commit` and the carry sidecar naming a tree that no longer exists, and the next stage branches from it.
68
69
  - The verifier round, Phase 5.5 convergence, and this Phase 6 report run **once per run** over this stage's diff — NOT per step.
69
- - Quote this stage's new contents (the sidecar JSON in full and the new consumers row by itself) in the final report's `Stage sidecar evidence` deliverable section.
70
+ - Record this stage's new contents in the final report's `Stage sidecar evidence` deliverable section: the sidecar by path with its one-line summary, and the new consumers row quoted by itself.
@@ -1,9 +1,12 @@
1
1
  """Human-first implementation delivery view model."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
5
+ import re
6
+ from collections import Counter
7
+
4
8
  from okstra_ctl.report_html.common import evidence_index
5
- from okstra_ctl.report_html.models import HumanReportView, VisualNode
6
- from okstra_ctl.report_html.visualizations import change_map_figure
9
+ from okstra_ctl.report_html.models import HumanReportView
7
10
 
8
11
  # Record fields this template anchors as `id-<row id>` (see
9
12
  # `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
@@ -13,39 +16,83 @@ ANCHORED_FIELDS = (
13
16
  )
14
17
 
15
18
 
16
- _FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
19
+ def _carry_payload(text: str) -> dict | None:
20
+ start = text.find("{")
21
+ if start < 0:
22
+ return None
23
+ try:
24
+ payload, _ = json.JSONDecoder().raw_decode(text[start:])
25
+ except json.JSONDecodeError:
26
+ # expected-miss: carryJson 은 경로+요약 산문일 수 있다. 그때는 본문에 글 그대로 보인다.
27
+ return None
28
+ return payload if isinstance(payload, dict) else None
17
29
 
18
30
 
19
- def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
20
- return tuple(
21
- VisualNode(
22
- row["file"],
23
- row["file"],
24
- row["planStep"],
25
- row["action"],
26
- row["lines"],
27
- note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
28
- )
29
- for row in implementation["diffSummary"]["files"]
31
+ def _carry_view(evidence: dict | None) -> dict | None:
32
+ if not evidence:
33
+ return None
34
+ text = evidence["carryJson"]
35
+ payload = _carry_payload(text)
36
+ if payload is None:
37
+ return {"text": text}
38
+ commit_range = payload.get("stageCommitRange") or {}
39
+ base, head = commit_range.get("base"), commit_range.get("head")
40
+ steps = Counter(
41
+ str(row.get("status"))
42
+ for row in payload.get("stepResults") or ()
43
+ if isinstance(row, dict)
30
44
  )
45
+ files = payload.get("filesChanged")
46
+ return {
47
+ "path": f"carry/stage-{evidence['stageNumber']}.json",
48
+ "commitRange": f"{base[:8]}..{head[:8]}" if base and head else None,
49
+ "steps": ", ".join(f"{status} {count}" for status, count in steps.items()),
50
+ "filesChanged": len(files) if isinstance(files, list) else None,
51
+ "raw": json.dumps(payload, indent=2, ensure_ascii=False),
52
+ }
53
+
54
+
55
+ _LEADING_CHECK_ID = re.compile(r"^(VC-\d{3,})\b")
56
+ _COMMAND_PREVIEW = 60
57
+
58
+
59
+ def _validation_card(row: dict) -> dict:
60
+ # checkId 가 없는 진행 중 task 는 writer 가 outputTail 머리에 붙인 VC id 를 보여 준다(표시 전용).
61
+ check_id = row.get("checkId")
62
+ if not check_id:
63
+ match = _LEADING_CHECK_ID.match(row["outputTail"])
64
+ check_id = match.group(1) if match else None
65
+ command = row["command"]
66
+ preview = command if len(command) <= _COMMAND_PREVIEW else command[: _COMMAND_PREVIEW - 1] + "…"
67
+ if not row["exitCode"]:
68
+ tone = "neutral"
69
+ elif row.get("advisory"):
70
+ tone = "medium"
71
+ else:
72
+ tone = "important"
73
+ return {**row, "checkId": check_id, "commandPreview": preview, "tone": tone}
31
74
 
32
75
 
33
76
  def build_implementation_view(data: dict) -> HumanReportView:
34
77
  implementation = data["implementation"]
35
- figure = change_map_figure(
36
- nodes=_change_nodes(implementation), title="Delivered change areas"
37
- )
38
78
  context = {
39
79
  "humanSummary": data["humanSummary"],
40
80
  "implementation": implementation,
41
81
  "narrative": implementation["userNarrative"],
42
- "changeFigure": figure,
82
+ # 진행 중인 task 의 data.json 에는 파일별 summary 가 없다. 빈 열을 그리지 않는다.
83
+ "diffHasSummary": any(
84
+ row.get("summary") for row in implementation["diffSummary"]["files"]
85
+ ),
86
+ "validationCards": [
87
+ _validation_card(row) for row in implementation["validationEvidence"]
88
+ ],
89
+ "carry": _carry_view(implementation.get("stageSidecarEvidence")),
43
90
  "evidenceIndex": evidence_index(data, ANCHORED_FIELDS),
44
91
  }
45
92
  return HumanReportView(
46
93
  "implementation",
47
94
  "html/tasks/implementation.template.html",
48
95
  context,
49
- (figure,),
96
+ (),
50
97
  anchored_fields=ANCHORED_FIELDS,
51
98
  )
@@ -1,6 +1,5 @@
1
1
  {% extends "html/base.template.html" %}
2
2
  {% from "html/macros/layout.html" import narrative as render_narrative, summary_card, row_key, file_paths %}
3
- {% from "html/macros/visualizations.html" import figure %}
4
3
 
5
4
  {% block human_content %}
6
5
  <section data-report-section="delivered-outcome">
@@ -12,19 +11,19 @@
12
11
  <section data-report-section="change-map" data-report-field="implementation.diffSummary">
13
12
  <h2>{{ t('tasks.implementation.what-changed') }}</h2>
14
13
  {{ render_narrative(narrative.changeExplanation, "implementation.userNarrative.changeExplanation") }}
15
- {{ figure(changeFigure) }}
14
+ <table><thead><tr><th>{{ t('tasks.implementation.file') }}</th><th>{{ t('tasks.implementation.change') }}</th>{% if diffHasSummary %}<th>{{ t('tasks.implementation.what-it-does') }}</th>{% endif %}<th class="figure">{{ t('tasks.implementation.lines') }}</th><th>{{ t('tasks.implementation.plan-step') }}</th></tr></thead><tbody>{% for row in implementation.diffSummary.files %}<tr><td><code>{{ row.file }}</code></td><td>{{ t('tasks.implementation.action-' ~ row.action) }}</td>{% if diffHasSummary %}<td>{{ row.get('summary', '') | inline_code }}</td>{% endif %}<td class="figure">{{ row.lines }}</td><td>{{ row.planStep | inline_code }}</td></tr>{% endfor %}</tbody></table>
16
15
  </section>
17
16
 
18
17
  <section data-report-section="requirement-coverage">
19
18
  <h2>{{ t('tasks.implementation.requirement-coverage') }}</h2>
20
- <table data-report-field="implementation.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation.requirement') }}</th><th>{{ t('tasks.implementation.body') }}</th><th>{{ t('tasks.implementation.implemented-by') }}</th></tr></thead><tbody>{% for row in implementation.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id)], status_name=t('macros.layout.status'), status_raw=row.status, status_text=row.status) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
19
+ <table data-report-field="implementation.requirementCoverage"><thead><tr><th>{{ t('tasks.implementation.requirement') }}</th><th>{{ t('tasks.implementation.body') }}</th><th>{{ t('tasks.implementation.implemented-by') }}</th></tr></thead><tbody>{% for row in implementation.requirementCoverage %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id)], status_name=t('macros.layout.status'), status_raw=row.status, status_text=(row.status.split(' ')[0] | enum_label('requirementStatus')) ~ ((' ' ~ row.status.split(' ', 1)[1]) if ' ' in row.status else '')) }}<td>{{ row.requirement | inline_code }}</td><td>{{ row.coveredBy | inline_code }}</td></tr>{% endfor %}</tbody></table>
21
20
  </section>
22
21
 
23
22
  <section data-report-section="validation" data-report-field="implementation.validationEvidence">
24
23
  <h2>{{ t('tasks.implementation.verification-result') }}</h2>
25
24
  {{ render_narrative(narrative.validationExplanation, "implementation.userNarrative.validationExplanation") }}
26
- <div class="summary-grid">{% for row in implementation.validationEvidence %}{{ summary_card(row.phase ~ " · " ~ t('tasks.implementation.exit-code') ~ " " ~ row.exitCode, row.outputTail, "important" if row.exitCode else "neutral") }}{% endfor %}</div>
27
- {% for row in implementation.verifierResults %}<article class="summary-card tone-{{ row.verdict | lower }}"><p class="eyebrow">{{ row.verifier | inline_code }} · <span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span></p>{% if row.get("independentValidationRerun") %}<p><strong>{{ t('tasks.implementation.independent-rerun') }}</strong> {{ row.independentValidationRerun | inline_code }}</p>{% endif %}{% if row.get("readOnlyCommandLog") %}<p><strong>{{ t('tasks.implementation.command-log') }}</strong> {{ row.readOnlyCommandLog | inline_code }}</p>{% endif %}{% if row.get("discrepancy") %}<p class="evidence-refs">{{ t('tasks.implementation.discrepancy') }} {{ row.discrepancy | inline_code }}</p>{% endif %}{% if row.get("declinedFixRecommendations") %}<p>{{ row.declinedFixRecommendations | inline_code }}</p>{% endif %}</article>{% endfor %}
25
+ <div class="summary-grid">{% for row in validationCards %}<article class="summary-card tone-{{ row.tone }}"><h3>{% if row.checkId %}{{ row.checkId }} · {% endif %}{{ row.phase }} · {{ t('tasks.implementation.exit-code') }} {{ row.exitCode }}{% if row.get("advisory") %} · {{ t('tasks.implementation.advisory') }}{% endif %}</h3><p><code title="{{ row.command }}">{{ row.commandPreview }}</code></p><p>{{ row.outputTail | inline_code }}</p></article>{% endfor %}</div>
26
+ {% for row in implementation.verifierResults %}<article class="summary-card tone-{{ row.verdict | lower }}"><p class="eyebrow">{{ row.verifier | inline_code }} · <span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span></p>{% for label, text in ((t('tasks.implementation.independent-rerun'), row.get("independentValidationRerun")), (t('tasks.implementation.command-log'), row.get("readOnlyCommandLog"))) if text %}<details><summary><strong>{{ label }}</strong> {{ t('tasks.implementation.entry-count') | replace('{count}', text.strip().splitlines() | select | list | length | string) }}</summary><pre>{{ text }}</pre></details>{% endfor %}{% if row.get("discrepancy") %}<p class="evidence-refs">{{ t('tasks.implementation.discrepancy') }} {{ row.discrepancy | inline_code }}</p>{% endif %}{% if row.get("declinedFixRecommendations") %}<p>{{ row.declinedFixRecommendations | inline_code }}</p>{% endif %}</article>{% endfor %}
28
27
  </section>
29
28
 
30
29
  <section data-report-section="remaining-issues">
@@ -42,8 +41,10 @@
42
41
 
43
42
  <section data-report-section="rollback" data-report-field="implementation.rollbackVerification">
44
43
  <h2>{{ t('tasks.implementation.can-this-be-rolled-back') }}</h2>
45
- <table><thead><tr><th>{{ t('tasks.implementation.subject') }}</th><th>{{ t('tasks.implementation.rollback-command') }}</th><th>{{ t('tasks.implementation.how-to-verify') }}</th><th>{{ t('tasks.implementation.result') }}</th></tr></thead><tbody>{% for row in implementation.rollbackVerification %}<tr><td>{{ row.category | inline_code }}</td><td><code>{{ row.rollbackCommand }}</code></td><td>{{ row.verification | inline_code }}</td><td>{{ row.result | inline_code }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation.no-rollback-check-was-recorded') }}</td></tr>{% endfor %}</tbody></table>
46
- {% if implementation.stageSidecarEvidence %}<p data-report-field="implementation.stageSidecarEvidence"><strong>{{ t('tasks.implementation.stage-carry-over') }}</strong> — Stage {{ implementation.stageSidecarEvidence.stageNumber }} {{ implementation.stageSidecarEvidence.stageTitle | inline_code }} · <code>{{ implementation.stageSidecarEvidence.carryJson }}</code></p>{% endif %}
44
+ <table><thead><tr><th>{{ t('tasks.implementation.subject') }}</th><th>{{ t('tasks.implementation.rollback-command') }}</th><th>{{ t('tasks.implementation.how-to-verify') }}</th><th>{{ t('tasks.implementation.result') }}</th></tr></thead><tbody>{% for row in implementation.rollbackVerification %}<tr><td>{{ row.category | enum_label('rollbackCategory') }}</td><td><code>{{ row.rollbackCommand }}</code></td><td>{{ row.verification | inline_code }}</td><td>{{ row.result | enum_label('rollbackResult') }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation.no-rollback-check-was-recorded') }}</td></tr>{% endfor %}</tbody></table>
45
+ {% if implementation.stageSidecarEvidence %}<div data-report-field="implementation.stageSidecarEvidence"><p><strong>{{ t('tasks.implementation.stage-carry-over') }}</strong> — Stage {{ implementation.stageSidecarEvidence.stageNumber }} {{ implementation.stageSidecarEvidence.stageTitle | inline_code }}</p>
46
+ {% if carry.get("text") %}<p>{{ carry.text | inline_code }}</p>{% else %}<table><tbody><tr><th>{{ t('tasks.implementation.carry-sidecar') }}</th><td><code>{{ carry.path }}</code></td></tr>{% if carry.commitRange %}<tr><th>{{ t('tasks.implementation.carry-commit-range') }}</th><td><code>{{ carry.commitRange }}</code></td></tr>{% endif %}{% if carry.steps %}<tr><th>{{ t('tasks.implementation.carry-steps') }}</th><td>{{ carry.steps }}</td></tr>{% endif %}{% if carry.filesChanged is not none %}<tr><th>{{ t('tasks.implementation.carry-files-changed') }}</th><td>{{ carry.filesChanged }}</td></tr>{% endif %}</tbody></table>
47
+ <details><summary>{{ t('tasks.implementation.carry-raw') }}</summary><pre>{{ carry.raw }}</pre></details>{% endif %}</div>{% endif %}
47
48
  </section>
48
49
 
49
50
  <section data-report-section="manual-test" data-report-field="implementation.manualUserTest">
@@ -1645,6 +1645,8 @@ def _replace_item_verdicts(
1645
1645
  ) -> None:
1646
1646
  item["verdicts"] = _stamped_verdicts(incoming, round_number, project_root)
1647
1647
  _remember_verified_hash(item)
1648
+ # 이번 run 의 표로 바뀌었으니 더는 직전 run 에서 이월된 항목이 아니다.
1649
+ item.pop("carriedForwardFromSeq", None)
1648
1650
 
1649
1651
 
1650
1652
  def _append_item_verdicts(
@@ -1769,11 +1771,13 @@ def _reject_uncompleted_round_loss(
1769
1771
  for item in recorded:
1770
1772
  if not isinstance(item, Mapping) or item.get("id") not in rows:
1771
1773
  continue
1774
+ # `seed --prior-state` 는 이월 표시를 항목에 찍는다. 이번 run 이 항목을
1775
+ # 다시 판정하면 그 표시는 지워지므로, 표시가 남은 항목의 표는 모두 직전 run 것이다.
1776
+ if str(item.get("carriedForwardFromSeq") or "").strip():
1777
+ continue
1772
1778
  for row in item.get("verdicts") or []:
1773
1779
  if not isinstance(row, Mapping):
1774
1780
  continue
1775
- if str(row.get("carriedForwardFromSeq") or "").strip():
1776
- continue
1777
1781
  recorded_round = row.get("round")
1778
1782
  if (
1779
1783
  isinstance(recorded_round, int)
@@ -37,6 +37,10 @@ def _agent_row(row: dict) -> dict[str, str]:
37
37
  there would read as a missing measurement instead of an absent charge.
38
38
  """
39
39
  cli_tokens = _number(row.get("cliTotalTokens")) or 0
40
+ if cli_tokens == _number(row.get("totalTokens")):
41
+ # A CLI-only agent's raw figure is its CLI figure; a second line would
42
+ # print the same number twice.
43
+ cli_tokens = 0
40
44
  cli_cost = _number(row.get("cliCostUsd")) or 0
41
45
  cost = _number(row.get("costUsd"))
42
46
  if cost is None and cli_cost:
@@ -124,10 +128,15 @@ def run_usage(data: dict) -> dict[str, object] | None:
124
128
  if not measured:
125
129
  return None
126
130
  cli_cost = _number((usage.get("cli") or {}).get("costUsd")) or 0
131
+ grand_cost = _number((usage.get("grand") or {}).get("costUsd")) or 0
132
+ # The total holds API spend only, while a CLI-only agent's charge sits in
133
+ # its row's cost cell; this line is what the cost column adds up to.
134
+ grand_with_cli = grand_cost + cli_cost if cli_cost else 0
127
135
  return {
128
136
  "rows": [_agent_row(row) for row in rows],
129
137
  "unaccounted": _unaccounted(rows, usage.get("grand") or {}),
130
138
  **totals,
131
139
  "taskCumulative": _task_cumulative(usage.get("taskCumulative")),
132
140
  "cliCost": format_usd(cli_cost) if cli_cost else "",
141
+ "grandWithCli": format_usd(grand_with_cli) if grand_with_cli else "",
133
142
  }
@@ -342,14 +342,6 @@ def stage_map_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]
342
342
  )
343
343
 
344
344
 
345
- def change_map_figure(*, nodes: Sequence[VisualNode], title: str) -> FigureModel:
346
- return replace(
347
- dependency_figure(nodes=nodes, edges=(), title=title),
348
- kind="change-map",
349
- figure_id="change-map",
350
- )
351
-
352
-
353
345
  def matrix_figure(*, items: Iterable[VisualNode], title: str, x_label: str, y_label: str) -> FigureModel:
354
346
  nodes = tuple(items)
355
347
  summary = f"{len(nodes)} items compared by {x_label} and {y_label}"
@@ -33,6 +33,22 @@ _STATUS = {
33
33
  }
34
34
 
35
35
 
36
+ def _report_workers(team_state: Mapping[str, Any]) -> list[dict]:
37
+ """리드 자신의 워커 항목을 뺀 워커 행.
38
+
39
+ team-state 는 리드를 `workerId: lead` 워커로도 적는다(agent 없음, 상태
40
+ `dispatched`). 리드 행은 `lead`·`leadUsage` 에서 따로 만들므로 이 항목을
41
+ 표에 올리면 "Codex · lead · unknown · 미실행" 행이 하나 더 생긴다.
42
+ """
43
+ return [
44
+ row for row in accounting_workers(team_state)
45
+ if "lead" not in {
46
+ str(row.get("workerId") or "").strip().lower(),
47
+ str(row.get("role") or "").strip().lower(),
48
+ }
49
+ ]
50
+
51
+
36
52
  def _text(value: object, fallback: str) -> str:
37
53
  text = str(value or "").strip()
38
54
  return text or fallback
@@ -148,7 +164,7 @@ def project_execution(
148
164
  "role": "Okstra lead",
149
165
  }
150
166
  lead_usage = team_state.get("leadUsage")
151
- worker_rows = accounting_workers(team_state)
167
+ worker_rows = _report_workers(team_state)
152
168
  result: dict[str, Any] = {
153
169
  "executionStatus": [
154
170
  _execution_row(
@@ -180,7 +196,7 @@ def _usage_row(summary: Mapping[str, Any], prefix: str, cost: object) -> dict[st
180
196
 
181
197
  def _worker_details(team_state: Mapping[str, Any]) -> list[dict[str, Any]]:
182
198
  result = []
183
- for worker in accounting_workers(team_state):
199
+ for worker in _report_workers(team_state):
184
200
  if not isinstance(worker, Mapping):
185
201
  continue
186
202
  usage = worker.get("usage") if isinstance(worker.get("usage"), Mapping) else {}
@@ -295,6 +295,7 @@ class ReportSynthesisPacket:
295
295
  "Do not invent a value when a source is missing or contradictory.",
296
296
  "Write only the narrative, pointer, and audit artifacts named by the dispatch.",
297
297
  *_INTERNAL_IDENTIFIER_INSTRUCTION,
298
+ *_READER_PROSE_INSTRUCTION,
298
299
  *self._schema_instructions(),
299
300
  *self._task_instructions(),
300
301
  *self._carry_instructions(),
@@ -335,6 +336,7 @@ class ReportSynthesisPacket:
335
336
  # 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
336
337
  # 도달하지 않는다.
337
338
  lines.extend(f"- {text}" for text in _INTERNAL_IDENTIFIER_INSTRUCTION)
339
+ lines.extend(f"- {text}" for text in _READER_PROSE_INSTRUCTION)
338
340
  lines.extend(f"- {text}" for text in self._schema_instructions())
339
341
  lines.extend(f"- {text}" for text in self._task_instructions())
340
342
  lines.extend(f"- {text}" for text in self._carry_instructions())
@@ -573,6 +575,23 @@ _INTERNAL_IDENTIFIER_INSTRUCTION = (
573
575
  )
574
576
 
575
577
 
578
+ # 리포트를 읽는 사람은 run 을 보지 않았다. 아래 규칙도 집행 코드가 없고(서사는
579
+ # 자유 서술이다) 저자 지시로만 전달한다 — dev-11126 #003 서사가 어긴 네 가지.
580
+ _READER_PROSE_INSTRUCTION = (
581
+ "Write every reader-facing field for a reader who did not watch the run: "
582
+ "each sentence must make sense without the worker results, the plan, or "
583
+ "the lead's notes.",
584
+ "Explain a plan- or run-internal id (`VC-`, `OOP-`, `P-Step-`, `OF-`, "
585
+ "`IO-`, `C-`) in words the first time a field uses it, or leave it out; "
586
+ "never let a bare id carry the meaning of a sentence.",
587
+ "Keep commit hashes and file paths out of narrative sentences; put them "
588
+ "only in evidence lines (`evidence`, `evidenceRefs` and other citation "
589
+ "fields).",
590
+ "Name a term by what it does (\"the function that decides whether the "
591
+ "server is local\") rather than by jargon (\"predicate\").",
592
+ )
593
+
594
+
576
595
  CARRIED_STAGES_LABEL = "Carried plan stages"
577
596
 
578
597
 
@@ -14,7 +14,9 @@ reader merely reads is offered, and the translator decides.
14
14
 
15
15
  When a key name carries load-bearing values in even one place, it counts as
16
16
  structural everywhere. Missing a translation leaves English on the page;
17
- translating an anchor id breaks the link.
17
+ translating an anchor id breaks the link. ``PROSE_FIELDS`` is the one
18
+ exception: a field named by its enclosing keys, where that key holds prose
19
+ even though the same name is an id or a path elsewhere.
18
20
  """
19
21
  from __future__ import annotations
20
22
 
@@ -328,6 +330,23 @@ STRUCTURAL_KEYS = frozenset({
328
330
  })
329
331
 
330
332
 
333
+ # Fields whose key is structural by name but holds a sentence at this position.
334
+ # Each tuple matches the tail of the key path with array indices dropped;
335
+ # `endStateCoverage[].coveredBy` is absent because there it is a linked id.
336
+ PROSE_FIELDS = frozenset({
337
+ ("requirementCoverage", "coveredBy"),
338
+ ("commitList", "files"),
339
+ ("manualUserTest", "items", "target"),
340
+ ("manualUserTest", "items", "steps"),
341
+ })
342
+
343
+
344
+ def _is_prose_field(keys: tuple[str, ...]) -> bool:
345
+ if keys and keys[-1] in PROSE_KEYS:
346
+ return True
347
+ return any(keys[-len(field):] == field for field in PROSE_FIELDS)
348
+
349
+
331
350
  # A key name says what a field is *for*; these say what one value actually is.
332
351
  # `evidence` is the largest prose bucket in a real report and also holds bare
333
352
  # `src/server.ts:33` citations — the key cannot separate them, the value can.
@@ -367,16 +386,18 @@ def unescape_token(token: str) -> str:
367
386
  return token.replace("~1", "/").replace("~0", "~")
368
387
 
369
388
 
370
- def _walk(node: Any, pointer: str, key: str) -> Iterator[tuple[str, str]]:
389
+ def _walk(node: Any, pointer: str, keys: tuple[str, ...]) -> Iterator[tuple[str, str]]:
371
390
  if isinstance(node, Mapping):
372
391
  for child_key, value in node.items():
373
- yield from _walk(value, f"{pointer}/{escape_token(str(child_key))}", str(child_key))
392
+ yield from _walk(
393
+ value, f"{pointer}/{escape_token(str(child_key))}", (*keys, str(child_key))
394
+ )
374
395
  elif isinstance(node, (list, tuple)):
375
396
  for index, value in enumerate(node):
376
397
  # An array element inherits the key that named the array, so
377
398
  # `evidence: [...]` translates each entry.
378
- yield from _walk(value, f"{pointer}/{index}", key)
379
- elif isinstance(node, str) and key in PROSE_KEYS and is_translatable_value(node):
399
+ yield from _walk(value, f"{pointer}/{index}", keys)
400
+ elif isinstance(node, str) and _is_prose_field(keys) and is_translatable_value(node):
380
401
  yield pointer, node
381
402
 
382
403
 
@@ -386,7 +407,7 @@ def extract(data: Mapping[str, Any]) -> dict[str, str]:
386
407
  The translator fills in this map rather than authoring pointers, so a
387
408
  sidecar cannot cite a path the document does not have.
388
409
  """
389
- return dict(_walk(data, "", ""))
410
+ return dict(_walk(data, "", ()))
390
411
 
391
412
 
392
413
  def group_translation_pointers(
@@ -377,6 +377,25 @@ def _initial_cross_verification_requirement(
377
377
  ), None)
378
378
 
379
379
 
380
+ def _assigned_models_by_role(payload: dict[str, Any]) -> dict[str, list[tuple[str, str]]]:
381
+ """run-inputs 의 `workerAssignments` 를 역할별 (provider, model) 로.
382
+
383
+ 지금 run-inputs 의 `workers` 는 provider 가 아니라 worker id(`claude-planner`)를
384
+ 적는다. 그 값으로 `<provider>Model` 을 찾으면 아무 모델도 복원되지 않는다.
385
+ """
386
+ rows = payload.get("workerAssignments")
387
+ assigned: dict[str, list[tuple[str, str]]] = {}
388
+ for row in rows if isinstance(rows, list) else ():
389
+ if not isinstance(row, dict):
390
+ continue
391
+ role, provider, model = row.get("role"), row.get("provider"), row.get("model")
392
+ if isinstance(role, str) and isinstance(provider, str) and provider:
393
+ assigned.setdefault(role, []).append(
394
+ (provider, model if isinstance(model, str) else "")
395
+ )
396
+ return assigned
397
+
398
+
380
399
  def _role_selections_from_run_inputs(
381
400
  state: WizardState,
382
401
  payload: dict[str, Any],
@@ -385,7 +404,10 @@ def _role_selections_from_run_inputs(
385
404
  profile = _load_role_profile_for_state(state)
386
405
  pool = ModelPool.from_registry(default_provider_registry())
387
406
  cross_requirement = _initial_cross_verification_requirement(profile)
407
+ assigned = _assigned_models_by_role(payload)
388
408
  providers = _legacy_worker_providers(payload)
409
+ if cross_requirement is not None and cross_requirement.role in assigned:
410
+ providers = [provider for provider, _ in assigned[cross_requirement.role]]
389
411
  if cross_requirement is not None:
390
412
  selected_count = cross_requirement.recommended_count
391
413
  if providers and cross_requirement.min_count < cross_requirement.max_count:
@@ -400,8 +422,13 @@ def _role_selections_from_run_inputs(
400
422
  model.provider_id
401
423
  for model in pool.default_candidates(cross_requirement.role)
402
424
  ]
425
+ assigned_models = [model for _, model in assigned.get(cross_requirement.role, ())]
403
426
  for ordinal, provider in enumerate(providers[:selected_count], start=1):
404
- raw_model = payload.get(f"{provider}Model", "")
427
+ raw_model = (
428
+ assigned_models[ordinal - 1]
429
+ if ordinal <= len(assigned_models)
430
+ else payload.get(f"{provider}Model", "")
431
+ )
405
432
  model_ref = _legacy_model_ref(
406
433
  pool,
407
434
  provider,
@@ -433,6 +460,8 @@ def _role_selections_from_run_inputs(
433
460
  if not requirement.dynamic and requirement.min_count > 0
434
461
  }
435
462
  for role, provider, raw_model in provider_rows:
463
+ if assigned.get(role):
464
+ provider, raw_model = assigned[role][0]
436
465
  if role not in static_roles or not isinstance(provider, str) or not provider:
437
466
  continue
438
467
  model_ref = _legacy_model_ref(pool, provider, role, raw_model)
@@ -2,10 +2,12 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from dataclasses import dataclass
5
+ from datetime import datetime
5
6
  from pathlib import Path
6
7
  from typing import Callable, Optional
7
8
 
8
9
  from okstra_ctl.clarification_items import user_response_sidecars
10
+ from okstra_ctl.manager_paths import MANAGER_CONTEXT_DIRECTIVE_PREFIX
9
11
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
10
12
  from okstra_ctl.report_language import is_language_tag
11
13
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
@@ -169,12 +171,29 @@ def _submit_optional_cached_pick(
169
171
  )
170
172
 
171
173
 
174
+ def _directive_context_note(state: WizardState, directive: str) -> str:
175
+ """manager directive 는 파일을 가리킬 뿐이라, 그 파일이 언제 쓰였는지 보여 준다.
176
+
177
+ 직전 run 이후 정한 결정은 그 파일에 없다. 날짜가 없으면 사용자는 재사용할
178
+ directive 가 새 범위를 담는지 판단할 수 없었다(fontsninja dev-11127 re-plan).
179
+ """
180
+ if not directive.startswith(MANAGER_CONTEXT_DIRECTIVE_PREFIX):
181
+ return ""
182
+ path = Path(directive[len(MANAGER_CONTEXT_DIRECTIVE_PREFIX):].strip()).expanduser()
183
+ labels = _p(state.workspace_root, "directive_pick")["labels"]
184
+ if not path.is_file():
185
+ return labels["context_missing"].format(path=path)
186
+ written = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
187
+ return labels["context_file"].format(path=path, date=written)
188
+
189
+
172
190
  _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
173
191
  step=S_DIRECTIVE_PICK, prompt_key="directive_pick",
174
192
  recommend_token=_REUSE_LAST_TOKEN, label_key="reuse_last", echo_suffix_key="reuse",
175
193
  suggest=_suggest_last_directive, snippet_style="prefix",
176
194
  cache_attr="last_directive_cached", target_attr="directive",
177
195
  pending_attr="directive_pending_text",
196
+ recommend_note=_directive_context_note,
178
197
  )
179
198
 
180
199
 
@@ -63,7 +63,13 @@ def _assignment_model(inputs: dict, worker_id: str, provider: str) -> str:
63
63
 
64
64
 
65
65
  def _build_reuse_previous(state: WizardState) -> Prompt:
66
- t = _p(state.workspace_root, "reuse_previous")
66
+ directive = _latest_run_inputs(
67
+ state, phase_segment=slugify_task_segment(state.task_type)
68
+ ).get("directive")
69
+ t = _p(
70
+ state.workspace_root, "reuse_previous",
71
+ directive=directive if isinstance(directive, str) and directive else "(없음)",
72
+ )
67
73
  return Prompt(
68
74
  step=S_REUSE_PREVIOUS, kind="pick",
69
75
  label=t["label"],
@@ -9949,6 +9949,10 @@
9949
9949
  "planStep": {
9950
9950
  "type": "string",
9951
9951
  "minLength": 1
9952
+ },
9953
+ "summary": {
9954
+ "type": "string",
9955
+ "minLength": 1
9952
9956
  }
9953
9957
  }
9954
9958
  },
@@ -10009,6 +10013,13 @@
10009
10013
  },
10010
10014
  "tddEvidence": {
10011
10015
  "type": "string"
10016
+ },
10017
+ "checkId": {
10018
+ "type": "string",
10019
+ "pattern": "^VC-\\d{3,}$"
10020
+ },
10021
+ "advisory": {
10022
+ "type": "boolean"
10012
10023
  }
10013
10024
  }
10014
10025
  },
@@ -12,7 +12,8 @@ body { margin: 0; background: Canvas; color: CanvasText; }
12
12
  .report-meta dd { margin: 0; }
13
13
  .lede { max-width: 70ch; font-size: clamp(1.05rem, 2vw, 1.35rem); line-height: 1.4; margin: 0 0 1rem; }
14
14
  .eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; color: GrayText; }
15
- main { display: grid; gap: 1rem; padding-bottom: 3rem; }
15
+ /* minmax(0, 1fr): auto 열은 가장 넓은 자식만큼 커져서 긴 토큰 하나가 모든 section 을 넓혔다. */
16
+ main { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; padding-bottom: 3rem; }
16
17
  section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
17
18
  h2 { margin-top: 0; font-size: 1.45rem; }
18
19
  .report-section-toggle > summary { cursor: pointer; }
@@ -188,4 +189,5 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
188
189
  /* A seven-column table needs 42em of floor, more than a phone can give. */
189
190
  @media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
190
191
  @media print { .skip-link, .back-to-top-wrap, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
191
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
192
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); overflow-wrap: anywhere; }
193
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -27,14 +27,12 @@
27
27
  <thead><tr>
28
28
  <th scope="col">{{ t('base.role') }}</th>
29
29
  <th scope="col">{{ t('base.execution-label') }}</th>
30
- <th scope="col">{{ t('base.status') }}</th>
31
30
  </tr></thead>
32
31
  <tbody>
33
32
  {% for row in executionRoles %}
34
33
  <tr>
35
- <td>{{ row.role }}</td>
34
+ <td>{{ row.role | enum_label('executionRole') }}</td>
36
35
  <td><code>{{ row.executionLabel }}</code></td>
37
- <td>{{ row.status }}</td>
38
36
  </tr>
39
37
  {% endfor %}
40
38
  </tbody>
@@ -45,14 +43,20 @@
45
43
  <main id="main-content" data-report-role="human-main">
46
44
  {% block human_content %}{% endblock %}
47
45
  {% if businessFlow | default([]) %}
46
+ {% set flow_failed = businessFlow | selectattr('status', 'equalto', 'failed') | list %}
48
47
  <section data-report-section="business-flow">
49
- <h2>Business Flow Explanations</h2>
50
- {% for row in businessFlow %}
51
- <p>{{ row.mode }}: {{ row.status }}. {{ row.summary }}</p>
52
- <ul>{% for kind, path in row.reports.items() %}<li><a href="{{ path }}">{{ kind }}</a></li>{% endfor %}</ul>
53
- {% if row.error %}<p>Explanation error: {{ row.error }}</p>{% endif %}
54
- {% if row.id %}<p>Retry: <code>okstra explain-flow rerun --execution {{ row.id }}</code></p>{% endif %}
48
+ <h2>{{ t('base.business-flow') }}</h2>
49
+ {% for row in businessFlow if row.status != 'failed' %}
50
+ <p>{{ row.mode | enum_label('businessFlowMode') }} · {{ row.status | enum_label('businessFlowStatus') }}. {{ row.summary }}</p>
51
+ {% if row.reports %}<ul>{% for kind, path in row.reports.items() %}<li><a href="{{ path }}">{{ kind }}</a></li>{% endfor %}</ul>{% endif %}
55
52
  {% endfor %}
53
+ {% if flow_failed %}
54
+ {# 실패 행의 오류·재실행 명령은 운영자용이라 접어 둔다. #}
55
+ <p>{{ t('base.business-flow-failed-count') | replace('{count}', flow_failed | length) }}</p>
56
+ <details><summary>{{ t('base.business-flow-failure-details') }}</summary>
57
+ <ul>{% for row in flow_failed %}<li>{{ row.mode | enum_label('businessFlowMode') }}{% if row.error %} · {{ t('base.business-flow-error') }} {{ row.error }}{% endif %}{% if row.id %} · {{ t('base.business-flow-rerun') }} <code>okstra explain-flow rerun --execution {{ row.id }}</code>{% endif %}</li>{% endfor %}</ul>
58
+ </details>
59
+ {% endif %}
56
60
  </section>
57
61
  {% endif %}
58
62
  {% if endStates %}
@@ -146,6 +150,7 @@
146
150
  <tr><th scope="row">{{ t('runUsage.row-workers') }}</th><td class="figure">{{ runUsage.worker.rawTokens }}</td><td class="figure">{{ runUsage.worker.cacheReadTokens }}</td><td class="figure">{{ runUsage.worker.billableTokens }}</td><td class="figure">{{ runUsage.worker.cost }}</td><td class="figure"></td></tr>
147
151
  <tr class="grand-total"><th scope="row">{{ t('runUsage.row-total') }}</th><td class="figure">{{ runUsage.grand.rawTokens }}</td><td class="figure">{{ runUsage.grand.cacheReadTokens }}</td><td class="figure">{{ runUsage.grand.billableTokens }}</td><td class="figure">{{ runUsage.grand.cost }}</td><td class="figure"></td></tr>
148
152
  {% if runUsage.cliCost %}<tr><th scope="row">{{ t('runUsage.row-cli') }}</th><td class="figure"></td><td class="figure"></td><td class="figure"></td><td class="figure">{{ runUsage.cliCost }}</td><td class="figure"></td></tr>{% endif %}
153
+ {% if runUsage.grandWithCli %}<tr class="grand-total"><th scope="row">{{ t('runUsage.row-total-with-cli') }}</th><td class="figure"></td><td class="figure"></td><td class="figure"></td><td class="figure">{{ runUsage.grandWithCli }}</td><td class="figure"></td></tr>{% endif %}
149
154
  {% if runUsage.taskCumulative %}<tr class="task-cumulative"><th scope="row">{{ t('runUsage.row-task-cumulative') | replace('{runs}', runUsage.taskCumulative.runCount) }}</th><td class="figure">{{ runUsage.taskCumulative.rawTokens }}</td><td class="figure">{{ runUsage.taskCumulative.cacheReadTokens }}</td><td class="figure">{{ runUsage.taskCumulative.billableTokens }}</td><td class="figure">{{ runUsage.taskCumulative.cost }}</td><td class="figure"></td></tr>{% endif %}
150
155
  </tfoot>
151
156
  </table>
@@ -147,6 +147,74 @@
147
147
  "deferred": "Deferred",
148
148
  "not-applicable": "Not applicable",
149
149
  "blocked": "Blocked"
150
+ },
151
+ "clarificationKind": {
152
+ "material": "Missing information",
153
+ "decision": "Your decision",
154
+ "data-point": "A fact to confirm"
155
+ },
156
+ "clarificationStatus": {
157
+ "open": "Open",
158
+ "answered": "Answered",
159
+ "resolved": "Resolved",
160
+ "obsolete": "No longer relevant"
161
+ },
162
+ "approvalDisposition": {
163
+ "select": "Choose this option",
164
+ "accept-risk": "Accept the risk",
165
+ "request-revision": "Request a revision",
166
+ "reject": "Reject"
167
+ },
168
+ "approvalClassification": {
169
+ "user-decision": "Needs your decision",
170
+ "noncritical-dissent": "Non-critical disagreement",
171
+ "correctness-critical": "Affects correctness"
172
+ },
173
+ "clarificationScope": {
174
+ "in-repo": "This repository only",
175
+ "cross-repo": "Other repositories too",
176
+ "new-schema": "New schema",
177
+ "deferrable": "Can be deferred"
178
+ },
179
+ "executionRole": {
180
+ "leader": "Lead",
181
+ "analyser": "Analyser",
182
+ "critic": "Critic",
183
+ "designer": "Designer",
184
+ "implementer": "Implementer",
185
+ "planner": "Planner",
186
+ "report-writer": "Report writer",
187
+ "translator": "Translator",
188
+ "verifier": "Verifier"
189
+ },
190
+ "requirementStatus": {
191
+ "covered": "Covered",
192
+ "gap": "Not covered",
193
+ "documented-deviation": "Approved deviation",
194
+ "blocked": "Blocked"
195
+ },
196
+ "rollbackCategory": {
197
+ "Pure code": "Code only",
198
+ "Feature-flag-gated": "Behind a feature flag",
199
+ "Schema/config/stateful": "Schema, config or stored state"
200
+ },
201
+ "rollbackResult": {
202
+ "ok": "Passed",
203
+ "not-applicable": "Not applicable",
204
+ "advisory — human-run": "Advisory: a person runs it"
205
+ },
206
+ "businessFlowMode": {
207
+ "pre": "Before the change",
208
+ "contribute": "This phase's contribution",
209
+ "post": "After the change",
210
+ "unknown": "Unknown phase"
211
+ },
212
+ "businessFlowStatus": {
213
+ "prepared": "Prepared",
214
+ "running": "Running",
215
+ "failed": "Failed",
216
+ "partial": "Partly explained",
217
+ "complete": "Explained"
150
218
  }
151
219
  },
152
220
  "enumHint": {
@@ -194,7 +262,12 @@
194
262
  "agent-activity": "Agent activity",
195
263
  "agent-activity-intro": "What each agent was asked to do in this run and how it ended. Decision cards and prose cite these rows by id.",
196
264
  "agent": "Agent",
197
- "activity": "Activity"
265
+ "activity": "Activity",
266
+ "business-flow": "Business flow explanations",
267
+ "business-flow-failed-count": "{count} business flow explanations failed",
268
+ "business-flow-failure-details": "Failure details and rerun commands",
269
+ "business-flow-error": "Error:",
270
+ "business-flow-rerun": "Rerun:"
198
271
  },
199
272
  "runUsage": {
200
273
  "heading": "What this run cost",
@@ -214,7 +287,8 @@
214
287
  "row-total": "Total",
215
288
  "row-cli": "CLI calls (billed separately)",
216
289
  "cache-read-tokens": "Cache re-reads",
217
- "row-task-cumulative": "This task so far ({runs} runs)"
290
+ "row-task-cumulative": "This task so far ({runs} runs)",
291
+ "row-total-with-cli": "Total including CLI calls"
218
292
  },
219
293
  "macros": {
220
294
  "forms": {
@@ -585,6 +659,21 @@
585
659
  "result": "Result",
586
660
  "no-rollback-check-was-recorded": "No rollback check was recorded.",
587
661
  "stage-carry-over": "Stage carry-over",
662
+ "carry-sidecar": "Sidecar",
663
+ "carry-commit-range": "Commit range",
664
+ "carry-steps": "Steps",
665
+ "carry-files-changed": "Files changed",
666
+ "carry-raw": "Sidecar JSON as written",
667
+ "file": "File",
668
+ "change": "Change",
669
+ "lines": "Lines",
670
+ "plan-step": "Plan step",
671
+ "action-created": "Created",
672
+ "action-modified": "Modified",
673
+ "action-deleted": "Deleted",
674
+ "what-it-does": "What it does",
675
+ "advisory": "advisory",
676
+ "entry-count": "{count} entries",
588
677
  "manual-user-test": "Manual user test",
589
678
  "environment": "Environment:",
590
679
  "expected": "Expected:",
@@ -147,6 +147,74 @@
147
147
  "deferred": "보류",
148
148
  "not-applicable": "해당 없음",
149
149
  "blocked": "차단"
150
+ },
151
+ "clarificationKind": {
152
+ "material": "빠진 정보",
153
+ "decision": "결정 필요",
154
+ "data-point": "확인할 사실"
155
+ },
156
+ "clarificationStatus": {
157
+ "open": "답변 대기",
158
+ "answered": "답변함",
159
+ "resolved": "해결됨",
160
+ "obsolete": "더 이상 해당 없음"
161
+ },
162
+ "approvalDisposition": {
163
+ "select": "이 선택지 채택",
164
+ "accept-risk": "위험 감수",
165
+ "request-revision": "수정 요청",
166
+ "reject": "반려"
167
+ },
168
+ "approvalClassification": {
169
+ "user-decision": "사용자 결정 필요",
170
+ "noncritical-dissent": "치명적이지 않은 이견",
171
+ "correctness-critical": "정확성에 영향"
172
+ },
173
+ "clarificationScope": {
174
+ "in-repo": "이 저장소 안",
175
+ "cross-repo": "다른 저장소까지",
176
+ "new-schema": "새 스키마",
177
+ "deferrable": "미룰 수 있음"
178
+ },
179
+ "executionRole": {
180
+ "leader": "리드",
181
+ "analyser": "분석",
182
+ "critic": "비평",
183
+ "designer": "설계",
184
+ "implementer": "구현",
185
+ "planner": "계획",
186
+ "report-writer": "리포트 작성",
187
+ "translator": "번역",
188
+ "verifier": "검증"
189
+ },
190
+ "requirementStatus": {
191
+ "covered": "충족",
192
+ "gap": "미충족",
193
+ "documented-deviation": "승인된 이탈",
194
+ "blocked": "차단"
195
+ },
196
+ "rollbackCategory": {
197
+ "Pure code": "코드만",
198
+ "Feature-flag-gated": "기능 플래그로 제어",
199
+ "Schema/config/stateful": "스키마·설정·저장 상태"
200
+ },
201
+ "rollbackResult": {
202
+ "ok": "통과",
203
+ "not-applicable": "해당 없음",
204
+ "advisory — human-run": "권고: 사람이 직접 실행"
205
+ },
206
+ "businessFlowMode": {
207
+ "pre": "변경 전",
208
+ "contribute": "이번 단계 기여",
209
+ "post": "변경 후",
210
+ "unknown": "알 수 없는 단계"
211
+ },
212
+ "businessFlowStatus": {
213
+ "prepared": "준비됨",
214
+ "running": "실행 중",
215
+ "failed": "실패",
216
+ "partial": "일부 설명",
217
+ "complete": "설명 완료"
150
218
  }
151
219
  },
152
220
  "enumHint": {
@@ -194,7 +262,12 @@
194
262
  "agent-activity": "에이전트 활동",
195
263
  "agent-activity-intro": "이번 run 에서 각 에이전트가 맡은 일과 그 결과입니다. 결정 카드와 본문이 이 행을 아이디로 인용합니다.",
196
264
  "agent": "에이전트",
197
- "activity": "활동"
265
+ "activity": "활동",
266
+ "business-flow": "업무 흐름 설명",
267
+ "business-flow-failed-count": "업무 흐름 설명 생성 실패 {count}건",
268
+ "business-flow-failure-details": "실패 내역과 재실행 명령",
269
+ "business-flow-error": "오류:",
270
+ "business-flow-rerun": "재실행:"
198
271
  },
199
272
  "runUsage": {
200
273
  "heading": "이번 실행에 든 비용",
@@ -214,7 +287,8 @@
214
287
  "row-total": "합계",
215
288
  "row-cli": "CLI 호출 (별도 청구)",
216
289
  "cache-read-tokens": "캐시 재읽기",
217
- "row-task-cumulative": "이 태스크 누적 (run {runs}회)"
290
+ "row-task-cumulative": "이 태스크 누적 (run {runs}회)",
291
+ "row-total-with-cli": "CLI 호출 포함 합계"
218
292
  },
219
293
  "macros": {
220
294
  "forms": {
@@ -585,6 +659,21 @@
585
659
  "result": "결과",
586
660
  "no-rollback-check-was-recorded": "롤백 확인 기록이 없습니다.",
587
661
  "stage-carry-over": "stage 인계",
662
+ "carry-sidecar": "인계 파일",
663
+ "carry-commit-range": "commit 범위",
664
+ "carry-steps": "단계",
665
+ "carry-files-changed": "바뀐 파일 수",
666
+ "carry-raw": "인계 JSON 원문",
667
+ "file": "파일",
668
+ "change": "변경",
669
+ "lines": "줄 수",
670
+ "plan-step": "계획 단계",
671
+ "action-created": "추가",
672
+ "action-modified": "수정",
673
+ "action-deleted": "삭제",
674
+ "what-it-does": "하는 일",
675
+ "advisory": "권고",
676
+ "entry-count": "{count}개 항목",
588
677
  "manual-user-test": "수동 사용자 테스트",
589
678
  "environment": "환경:",
590
679
  "expected": "기대 결과:",
@@ -57,18 +57,18 @@
57
57
  {%- endmacro %}
58
58
 
59
59
  {% macro clarification_article(row, compact=false) -%}
60
- {% set is_closed = row.status in ['resolved', 'obsolete'] %}
60
+ {% set is_closed = compact or row.status in ['resolved', 'obsolete'] %}
61
61
  {% set approval_context = row.approvalContext | default(None) %}
62
62
  {% set resolution = row.resolution | default(None) %}
63
63
  {% set options = row.options | default([]) %}
64
64
  <article class="clarification-item{% if compact %} is-closed{% endif %}" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}" data-status="{{ row.status }}">
65
- {% if compact %}<details><summary>{{ row.id | inline_code }} · {{ row.status }}</summary>{% endif %}
66
- <p class="eyebrow">{{ row.id | inline_code }} · {{ row.kind }}</p>
65
+ {% if compact %}<details><summary>{{ row.id | inline_code }} · {{ row.status | enum_label('clarificationStatus') }}</summary>{% endif %}
66
+ <p class="eyebrow">{{ row.id | inline_code }} · {{ row.kind | enum_label('clarificationKind') }}</p>
67
67
  {{ row.statement | paragraphs }}
68
68
  <dl class="clarification-expected"><dt>{{ t('macros.forms.answer-as') }}</dt><dd>{{ row.expectedForm | inline_code }}</dd></dl>
69
69
  {% if approval_context %}
70
70
  <div class="approval-context" data-approval-classification="{{ approval_context.classification }}">
71
- <p class="eyebrow">{{ approval_context.classification }}</p>
71
+ <p class="eyebrow">{{ approval_context.classification | enum_label('approvalClassification') }}</p>
72
72
  <p><strong>{{ t('tasks.implementation-planning.unblock-condition') }}</strong> {{ approval_context.unblockCondition | inline_code }}</p>
73
73
  {% set check_refs = resolution.checkRefs if resolution else approval_context.activityIds | default([]) %}
74
74
  {% if check_refs %}<p><strong>{{ t('tasks.implementation-planning.agent-evidence') }}</strong>
@@ -81,10 +81,10 @@
81
81
  {% for option in options %}
82
82
  <li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}" data-option-value="{{ option.answer }}">
83
83
  <p class="clarification-option-answer">{{ option.answer | inline_code }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
84
- {% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
84
+ {% if option.disposition | default(None) %}<p class="clarification-option-disposition">{{ option.disposition | enum_label('approvalDisposition') }}</p>{% endif %}
85
85
  <p class="clarification-option-rationale">{{ option.rationale | inline_code }}</p>
86
86
  <dl class="clarification-option-impact">
87
- <dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{% if option.reach | default(None) %}{{ option.reach }}{% if option.scopeEffects | default([]) %}, {{ option.scopeEffects | join(', ') }}{% endif %}{% else %}{{ option.scopeImpact | join(', ') }}{% endif %}</dd>
87
+ <dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{% if option.reach | default(None) %}{{ option.reach | enum_label('clarificationScope') }}{% if option.scopeEffects | default([]) %}, {{ option.scopeEffects | map('enum_label', 'clarificationScope') | join(', ') }}{% endif %}{% else %}{{ option.scopeImpact | map('enum_label', 'clarificationScope') | join(', ') }}{% endif %}</dd>
88
88
  <dt>{{ t('macros.forms.added-work') }}</dt><dd>{{ option.addedWork | inline_code }}</dd>
89
89
  <dt>{{ t('macros.forms.direction-change') }}</dt><dd>{{ option.directionChange | inline_code }}</dd>
90
90
  </dl>
@@ -114,7 +114,8 @@
114
114
  {% if items %}
115
115
  {% set ns = namespace(open=[], closed=[]) %}
116
116
  {% for row in items %}
117
- {% if row.status in ['open', 'answered'] %}
117
+ {# 답이 실린 answered 행은 기다리는 질문이 아니다 — 닫힌 목록으로 간다. #}
118
+ {% if row.status == 'open' or (row.status == 'answered' and not row.userInput | default('')) %}
118
119
  {% set ns.open = ns.open + [row] %}
119
120
  {% else %}
120
121
  {% set ns.closed = ns.closed + [row] %}