okstra 0.72.0 → 0.73.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.
@@ -496,7 +496,7 @@ task manifest, task index, instruction-set, runs, history가 이 루트 아래
496
496
  - `reports/final-report-<task-type>-<seq>.md`
497
497
  - `reports/final-report-<task-type>-<seq>.slim.md` *(Phase 7 결정론적 후처리: AI 다음-phase 입력용 슬림 markdown)*
498
498
  - `reports/final-report-<task-type>-<seq>.html` *(Phase 7 결정론적 후처리: 사람 reviewer 용 self-contained HTML, CSS/JS 인라인)*
499
- - `user-responses/user-response-<task-type>-<seq>.md` *(HTML 의 `Export user response` 버튼이 생성하는 사이드카; 사용자가 채워 저장하면 다음 phase 입력으로 소비)*
499
+ - `user-responses/user-response-<task-type>-<seq>.md` *(HTML 의 `Export user response` 버튼이 같은 이름으로 다운로드해 주는 사이드카; 여기 저장해 두면 `--resume-clarification` instruction-set `clarification-response.md` 에 자동 첨부한다 — `clarification_items.clarification_response_with_sidecars`)*
500
500
  - `worker-results/<worker>-audit-<task-type>-<seq>.md` *(워커별 Reading Confirmation 사이드카; 본문이 아니라 audit 용)*
501
501
  - `status/final-<task-type>-<seq>.status`
502
502
  - `carry/stage-<N>.json` *(implementation 전용: stage N 실행 evidence sidecar; 다음 stage 가 자동 carry-in)*
@@ -969,6 +969,7 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 두 view 를 결정
969
969
  - billable-equivalent token math와 USD cost estimation을 함께 기록합니다. Anthropic billing ratio(`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`)를 반영합니다. transcript 의 `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` 분해가 있으면 분리 집계합니다.
970
970
  - 가격표는 `scripts/okstra_token_usage/pricing.py` 에서 중앙 관리합니다. 모델 가격이 바뀌면 거기서 갱신합니다. 가격 매칭에 실패한 모델 id 는 `usageSummary.unmatchedModels` 필드로 사용자에게 노출됩니다 (silent zero 사고 방지).
971
971
  - **증분 스캔 캐시 (P6)**: 세션 jsonl 재스캔을 피하기 위해 `$OKSTRA_HOME/cache/token-usage/<transcript-dir>/<sessionId>.json` 에 파일별 byte cursor + 윈도우 적용 전 usage 이벤트 추출본을 보존합니다 (`scripts/okstra_token_usage/cursor.py`). run 윈도우(since/until)는 매 호출 시 이벤트 위에서 재평가하므로 재실행으로 윈도우가 좁아져도 합계는 전체 스캔과 동일합니다. 캐시는 파생 데이터라 식별자 불일치·truncate·손상 시 자동으로 전체 재스캔으로 폴백하며, `okstra-token-usage.py --no-cache` 로 강제 우회할 수 있습니다.
972
+ - **단계 타임라인 (P0 계측)**: 수집기가 lead 세션 jsonl 의 `PROGRESS: phase-*` 체크포인트 라인(agents/SKILL.md "Progress reporting")을 run 윈도우로 스코핑해 추출하고, team-state 에 `phaseTimeline` 블록(`{source, phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`)으로 기록합니다 (`scripts/okstra_token_usage/collect.py :: phase_timeline`). run 내부 단계별 wall-clock 의 측정 지점이며, `okstra-inspect` time facet 의 "Per-run phase breakdown" 이 소비합니다. 마커가 없는 run 은 `phases: []` 로 측정 불가를 명시합니다.
972
973
 
973
974
  ## Validators
974
975
 
package/docs/kr/cli.md CHANGED
@@ -384,7 +384,7 @@ scripts/okstra.sh --task-type implementation \
384
384
 
385
385
  Phase 5.6 의 opt-in critic pass 를 수행할 provider 를 선택합니다. 값은 `off` | `claude` | `codex` | `gemini` 중 하나이며 기본값은 `off` 입니다.
386
386
 
387
- - critic pass convergence(Phase 5.5) 직후 reused-worker 1라운드로 수행됩니다 — discovery / error-analysis / implementation-planning 에서는 coverage gap 탐지, final-verification 에서는 acceptance devil's-advocate 역할입니다. 상세 규약은 `okstra-convergence` skill 의 "Coverage critic pass" / "Acceptance critic pass" 섹션을 따릅니다.
387
+ - critic 디스패치는 convergence(Phase 5.5) reverify 라운드와 동시에 실행되고(critic 입력은 Round 0 통합 결과로 고정), gap/blocker 검증 1라운드는 convergence 종료 후 Phase 5.6 에서 수행됩니다 — discovery / error-analysis / implementation-planning 에서는 coverage gap 탐지, final-verification 에서는 acceptance devil's-advocate 역할입니다. 상세 규약은 `okstra-convergence` skill 의 "Coverage critic pass" / "Acceptance critic pass" 섹션을 따릅니다.
388
388
  - in-session `okstra-run` wizard 의 critic 선택 단계와 동일한 값 공간을 공유합니다. 허용값 검증은 `prepare_task_bundle` 의 `_resolve_model_bindings` 가 수행하며, 그 외 값은 `PrepareError` 로 즉시 거부됩니다.
389
389
 
390
390
  ### `--approved-plan`
@@ -10,6 +10,7 @@
10
10
  - [2.4 worker 구조](#24-worker-구조)
11
11
  - [3. 성능 병목 가설](#3-성능-병목-가설)
12
12
  - [4. 측정 기준](#4-측정-기준)
13
+ - [4.1 실측 결과 (2026-06-11)](#41-실측-결과-2026-06-11-fontradar-v2-api-dev-9186)
13
14
  - [5. 개선 우선순위](#5-개선-우선순위)
14
15
  - [P0. Baseline 계측과 용어 정리](#p0-baseline-계측과-용어-정리)
15
16
  - [P1. Convergence 재검증 범위 축소](#p1-convergence-재검증-범위-축소)
@@ -98,12 +99,13 @@
98
99
 
99
100
  | ID | 병목 | 영향도 | 근거 / 확인 위치 | 판정 |
100
101
  |---|---|---:|---|---|
101
- | B1 | Convergence reverify round가 worker 수만큼 추가 dispatch를 만든다 | 높음 | `skills/okstra-convergence/SKILL.md` Round 1-N | 1 개선 대상 |
102
- | B2 | analysis worker prompt와 required reading이 길다 | 높음 | `agents/SKILL.md`, `okstra-team-contract`, worker definitions | 별도 prompt diet 작업 |
103
- | B3 | report-writer가 worker 결과 + convergence + final-report-template을 다시 읽는다 | 중간 | `skills/okstra-report-writer/SKILL.md` | 구조상 필요. 중복 축소만 검토 |
102
+ | B1 | Convergence reverify round가 worker 수만큼 추가 dispatch를 만든다 | 높음 | `skills/okstra-convergence/SKILL.md` Round 1-N | **해소** — P1 queue pruning 구현 + critic 직렬화 병렬화(§4.1 ②) |
103
+ | B2 | analysis worker prompt와 required reading이 길다 | 높음 | `agents/SKILL.md`, `okstra-team-contract`, worker definitions | **대부분 해소** analysis-packet-primary 실측 22KB/worker(§4.1) |
104
+ | B3 | report-writer가 worker 결과 + convergence + final-report-template을 다시 읽는다 | ~~중간~~ 낮음 | `skills/okstra-report-writer/SKILL.md` | **입력 압축 기각** 실측상 읽기 1~2분, 생성이 지배(§4.1 ③) |
104
105
  | B4 | prepare 단계에서 여러 render/write가 직렬 수행된다 | 낮음~중간 | `scripts/okstra_ctl/run.py` render block | 후순위 |
105
- | B5 | token usage collector가 session jsonl을 선형 스캔한다 | 낮음~중간 | `scripts/okstra_token_usage/` | run 종료 비용. 후순위 |
106
- | B6 | 단순 작업도 같은 full workflow를 탄다 | 중간 | task-type lifecycle / requirements routing | fast-track 설계 필요 |
106
+ | B5 | token usage collector가 session jsonl을 선형 스캔한다 | 낮음~중간 | `scripts/okstra_token_usage/` | **해소** P6 증분 캐시 구현 완료 |
107
+ | B6 | 단순 작업도 같은 full workflow를 탄다 | 중간 | task-type lifecycle / requirements routing | fast-track 설계 필요 — 남은 최대 레버 |
108
+ | B7 | report-writer 의 final-report/data.json **생성량 자체**가 크다 (90~140KB) | 높음 | 실측 §4.1 ③ — 생성 8~19분/run | 보고서 계약 슬림화 또는 report-writer 모델 선택 — **사용자 결정 필요** |
107
109
 
108
110
  확정 전제:
109
111
 
@@ -128,6 +130,18 @@ P1 구현 전 최소 fixture:
128
130
  2. mixed/unresolved 사례: Round 1 후 일부 finding만 남아 선택적 Round 2가 필요한 run.
129
131
  3. worker failure 사례: reverify dispatch 일부가 `timeout`/`error`인 run.
130
132
 
133
+ ### 4.1 실측 결과 (2026-06-11, fontradar-v2-api dev-9186)
134
+
135
+ 실 run 3개(req-discovery / impl-planning / implementation stage-1)의 산출물 mtime, team-state `phaseTimeline`, report-writer 세션 jsonl 툴콜 타임라인으로 측정했다.
136
+
137
+ ① **run 단계별 wall 분해** — 분석 run 에서 "본 분석"은 wall 의 17~24%에 불과하다. 셋업 4~6분 / 분석 16~24분 / convergence+critic 23분 / report-writer 12~24분.
138
+
139
+ ② **critic 직렬화** — reverify 종료 후에야 critic 이 디스패치되어 런당 6~12분이 직렬로 낭비됐다. critic 입력은 Round 0 통합 결과로 고정이므로 첫 reverify 라운드와 병렬 디스패치로 변경했다 (`skills/okstra-convergence/SKILL.md` §When).
140
+
141
+ ③ **report-writer 는 생성 지배** — 세션 툴콜 실측: 전체 입력(~404KB, 16 files) 읽기는 **1~2분**, data.json 생성(스켈레톤 Write 후 섹션별 Edit 증분 작성)이 **8~19분**, 자체 검증·audit 이 ~3분. 따라서 B3 입력 압축의 wall 절감은 ~1분 수준이라 **기각**. 남는 레버는 출력 측(B7): 보고서 계약 슬림화(품질 trade-off, 사용자 결정) 또는 `--report-writer-model` 하향(기존 knob).
142
+
143
+ ④ **계측 자동화** — Phase 7 수집기가 lead 의 `PROGRESS: phase-*` 마커를 추출해 team-state `phaseTimeline` 으로 영속한다 (`scripts/okstra_token_usage/collect.py :: phase_timeline`). 이후 run 부터 본 섹션의 ①을 수작업 mtime 분석 없이 `/okstra-inspect time` 으로 확인할 수 있다. `okstra context-cost` 의 reportWriter 표면도 실 dispatch 계약(현재 seq + analysis-packet)에 정렬했다.
144
+
131
145
  ## 5. 개선 우선순위
132
146
 
133
147
  ### P0. Baseline 계측과 용어 정리
@@ -347,8 +361,14 @@ Optional Round 2:
347
361
  3. P3 fast-track과 P4 prompt caching은 별도 설계/검증이 필요한 후속 작업으로 둔다.
348
362
  4. prepare render 병렬화와 token usage 증분화는 효과가 작거나 종료 단계 비용이므로 P1 이후 병렬 보조 작업으로 처리한다.
349
363
 
364
+ **2026-06-11 갱신** — §4.1 실측으로 P0/P1/P6 + critic 병렬화 + phaseTimeline 계측이 완료됐고, B3(report-writer 입력 압축)는 기각됐다. 남은 우선순위는 두 개뿐이며 둘 다 사용자 결정이 선행한다:
365
+
366
+ 1. **B7 — report-writer 출력 측 단축**: 보고서 계약 슬림화(템플릿/스키마 축소, validator 계약 변경 수반) 또는 기본 report-writer 모델 하향. 런당 5~12분 절감 추정.
367
+ 2. **B6/P3 — fast-track routing**: 단순 작업의 lifecycle 단축. 승인 게이트 정책 결정 필요.
368
+
350
369
  ### 구현 plan 링크
351
370
 
352
- - P0 + P1: `docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md`
371
+ - P0 + P1: `docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md` (구현 완료 — convergence state v1.2 에 반영)
353
372
  - P6: `docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md` (구현 완료)
354
- - P2 / P3 / P4 / P5: 미작성 ( 트랙별로 별도 plan 작성 필요)
373
+ - critic 병렬화 + phaseTimeline 계측: plan 없이 직접 구현 (2026-06-11, `CHANGES.md` 해당 항목 참조)
374
+ - P3 / P4 / P5 / B7: 미작성 (각 트랙별로 별도 plan 작성 필요)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.72.0",
3
- "builtAt": "2026-06-11T07:01:57.638Z",
2
+ "package": "0.73.0",
3
+ "builtAt": "2026-06-11T14:40:52.876Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -42,7 +42,7 @@ This SKILL.md is the operating contract and phase index. Detailed procedures liv
42
42
  | 4. Execution | Spawn analysis workers (Teams preferred) | `okstra-team-contract` |
43
43
  | 5. Fallback | Sequential/background dispatch when Teams unavailable | `okstra-team-contract` |
44
44
  | 5.5 Convergence | Cross-verify findings across workers | `okstra-convergence` |
45
- | 5.6 Critic pass | (opt-in) reused-worker critic pass: coverage gaps (discovery/error-analysis/impl-planning) or acceptance devil's-advocate (final-verification), each verified one round | `okstra-convergence` "Coverage critic pass" / "Acceptance critic pass" |
45
+ | 5.6 Critic pass | (opt-in) reused-worker critic pass: coverage gaps (discovery/error-analysis/impl-planning) or acceptance devil's-advocate (final-verification). The critic dispatch fires concurrently with the first 5.5 reverify round (its input is fixed at Round 0); gap/blocker verification (one round) completes here | `okstra-convergence` "Coverage critic pass" / "Acceptance critic pass" |
46
46
  | 6. Synthesis | Dispatch Report writer worker, review draft. **For `implementation-planning`: then run the Phase 6 plan-body verification sub-step (see Phase 6 section below).** | `okstra-report-writer` + `okstra-convergence` (sub-step) |
47
47
  | 7. Persist | Run token-usage collector, update manifests, ask about residual tmux panes, then ask whether to disband the worker team (reconcile stale members + `TeamDelete` only on approval) | `okstra-report-writer` + `_common-contract.md` "Phase wrap-up" / "Run-end teammate teardown" |
48
48
 
@@ -94,7 +94,7 @@ Required checkpoints:
94
94
  - `PROGRESS: phase-5-poll pending=<n> done=<m>` — emitted on each wakeup while the pending set is non-empty.
95
95
  - `PROGRESS: phase-5-collect worker=<role> status=<terminal-status>` — once per worker, immediately after the result file is verified.
96
96
  - `PROGRESS: phase-5.5-convergence round=<N> queue=<count>` — at the start of each convergence round (Phase 5.5).
97
- - `PROGRESS: phase-5.6-critic provider=<provider> gaps=<n>` — when the coverage critic pass runs (Phase 5.6, opt-in). Omitted when `convergence.critic.enabled == false`.
97
+ - `PROGRESS: phase-5.6-critic provider=<provider> gaps=<n>` — after the critic result is collected (Phase 5.6, opt-in; the critic dispatch itself fires concurrently with the first 5.5 reverify round). Omitted when `convergence.critic.enabled == false`.
98
98
  - `PROGRESS: phase-6-synthesis dispatching report-writer-worker` — at the start of Phase 6.
99
99
  - `PROGRESS: phase-7-persist updating manifests` — at the start of Phase 7.
100
100
  - `PROGRESS: phase-7-teardown disbanding team` — after token-usage collection and after the pane-disposition prompt, only when the user approved worker teammate cleanup, immediately before reconciling stale members + `TeamDelete` (Teams mode only; see `_common-contract.md` "Run-end teammate teardown"). Skipped in the no-`team_name` fallback or when the user keeps the team.
@@ -32,7 +32,7 @@
32
32
  - **Evidence note required inside `Statement`**: every clarification row includes `Evidence checked: <path:line>` or `Evidence checked: none — <reporter-only reason>` in the `Statement` cell. `none` is allowed ONLY when the row's nature is "only the reporter can answer this" (reporter-side data, business priority, environment they observed). A row with `none` that *could* have been answered by code or logs is a defect.
33
33
  - Cross-verification mode:
34
34
  - Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each root-cause / reproduction claim by directly re-inspecting the cited code, logs, or config; the burden of proof sits on the claim. See `skills/okstra-convergence/SKILL.md` §"Adversarial Verification Mode". A single evidence-backed refutation prevents a finding from reaching consensus.
35
- - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass runs after convergence to surface missed findings; its gaps are merged only after a 1-round adversarial reverify. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
35
+ - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass is dispatched concurrently with the first convergence reverify round to surface missed findings; its gaps are merged only after a 1-round adversarial reverify that follows convergence. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
36
36
  - Non-goals:
37
37
  - implementation details unless they are necessary to validate the cause
38
38
  - **source code edits, builds, migrations, or deployments** — this run produces evidence and cause analysis only; the fix belongs to a later `implementation-planning` run followed by an `implementation` run
@@ -48,7 +48,7 @@
48
48
  4. **Verifier dissent preserved** — if workers reach different verdicts, the disagreement is visible in section 1.2; synthesis hides nothing.
49
49
  5. **No source-mutation audit** — scan the run's session transcripts for Edit / Write or state-mutating Bash commands that touch paths OUTSIDE `<PROJECT_ROOT>/.okstra/**` and outside the assigned run-artifact paths. Writes to worker prompts, audit sidecars, team-state, the final-report `data.json`, and rendered reports under the run directory are allowed okstra artifacts. Any source/schema/deployment mutation means the run has crossed into implementation and MUST be re-routed; do NOT silently strip the evidence.
50
50
  - Cross-verification mode:
51
- - **Acceptance critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker **acceptance devil's-advocate** pass runs after convergence to surface candidate acceptance blockers the verifiers may have missed. Each candidate is verified **confirm-or-downgrade**: confirmed → an `Acceptance Blockers` row (which, since `accepted` requires zero blockers, moves the verdict to `conditional-accept` / `blocked`); unconfirmed → a `Residual Risk` row (never dropped). See `skills/okstra-convergence/SKILL.md` "Acceptance critic pass (final-verification)".
51
+ - **Acceptance critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker **acceptance devil's-advocate** pass is dispatched concurrently with the first convergence reverify round to surface candidate acceptance blockers the verifiers may have missed; candidates are verified only after convergence completes. Each candidate is verified **confirm-or-downgrade**: confirmed → an `Acceptance Blockers` row (which, since `accepted` requires zero blockers, moves the verdict to `conditional-accept` / `blocked`); unconfirmed → a `Residual Risk` row (never dropped). See `skills/okstra-convergence/SKILL.md` "Acceptance critic pass (final-verification)".
52
52
  - Non-goals:
53
53
  - proposing unrelated refactors beyond the delivered scope
54
54
  - **source code edits, follow-up bug fixes, or scope expansion** — this run renders a verdict only; defects detected here become inputs to a new `error-analysis` or `implementation-planning` run
@@ -41,7 +41,7 @@
41
41
  - Cross-verification mode:
42
42
  - Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / option) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `skills/okstra-convergence/SKILL.md` §"Adversarial Verification Mode".
43
43
  - §5.5.9 plan-body verification runs with an **adversarial posture** (`skills/okstra-convergence/SKILL.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is unchanged — a *majority* `DISAGREE` (`majority-disagree`) is still required to block approval; a single dissent does not.
44
- - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass runs after convergence to surface missed findings; its gaps are merged only after a 1-round adversarial reverify. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
44
+ - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass is dispatched concurrently with the first convergence reverify round to surface missed findings; its gaps are merged only after a 1-round adversarial reverify that follows convergence. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
45
45
  - Non-goals:
46
46
  - code-level micro-optimization unless it changes the implementation approach
47
47
  - **source code edits of any kind** — this run produces a plan document only; Edit/Write on project source files is forbidden until the plan is approved and a separate `implementation` run starts
@@ -53,7 +53,7 @@
53
53
  - **Evidence note required inside `Statement`**: every clarification row includes `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>` in the `Statement` cell. `none` is allowed ONLY when the row's nature is "only a human can answer this" (reporter intent, business priority, external authority). A row with `none` that *could* have been answered by the codebase is a defect.
54
54
  - Cross-verification mode:
55
55
  - Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker's finding by directly re-inspecting the cited evidence; the burden of proof sits on the claim. See `skills/okstra-convergence/SKILL.md` §"Adversarial Verification Mode". A single evidence-backed refutation prevents a finding from reaching consensus.
56
- - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass runs after convergence to surface missed findings; its gaps are merged only after a 1-round adversarial reverify. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
56
+ - **Coverage critic (opt-in)**: when `convergence.critic.enabled=true` (chosen via the okstra-run picker or `--critic`), a reused-worker critic pass is dispatched concurrently with the first convergence reverify round to surface missed findings; its gaps are merged only after a 1-round adversarial reverify that follows convergence. See `skills/okstra-convergence/SKILL.md` "Coverage critic pass".
57
57
  - Non-goals:
58
58
  - full implementation design unless it is required to decide the next phase
59
59
  - **source code edits, plan authoring, builds, or deployments** — this run only classifies the work and routes it; deeper analysis and planning belong to subsequent phases
@@ -299,3 +299,29 @@ def section_1_present_but_unparsed(report_text: str) -> bool:
299
299
  if SECTION_HEADING_PATTERN.search(report_text):
300
300
  return False
301
301
  return bool(_LOOSE_SECTION_1_RE.search(report_text))
302
+
303
+
304
+ def clarification_response_with_sidecars(source: Path) -> str:
305
+ """clarification-response 원문 뒤에 `user-responses/` 사이드카를 덧붙인 본문.
306
+
307
+ ``source`` 가 ``runs/<task-type>/reports/final-report-*.md`` 레이아웃일 때만
308
+ 형제 ``user-responses/`` 디렉토리를 찾는다(HTML 뷰의 `Export user response`
309
+ 가 내려준 파일을 사용자가 거기 저장). 그 외 경로·사이드카 부재 시 원문 그대로 —
310
+ 호출자는 항상 이 함수를 거쳐 instruction-set 사본을 쓰면 된다.
311
+ """
312
+ text = source.read_text(encoding="utf-8")
313
+ responses_dir = source.parent.parent / "user-responses"
314
+ if source.parent.name != "reports" or not responses_dir.is_dir():
315
+ return text
316
+ sidecars = sorted(
317
+ p for p in responses_dir.glob("user-response-*.md") if p.is_file()
318
+ )
319
+ if not sidecars:
320
+ return text
321
+ parts = [text.rstrip("\n"), "\n\n---\n\n# Attached User Responses\n"]
322
+ for sidecar in sidecars:
323
+ parts.append(
324
+ f"\n## {sidecar.name}\n\n"
325
+ f"{sidecar.read_text(encoding='utf-8').strip()}\n"
326
+ )
327
+ return "".join(parts)
@@ -306,19 +306,46 @@ def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
306
306
  }
307
307
 
308
308
 
309
+ _SEQ_RESULT_RE = re.compile(r"-(\d{3,})\.md$")
310
+
311
+
312
+ def _current_seq_worker_results(run_dir: Path) -> list[Path]:
313
+ """현재 run seq 의 분석 worker 결과만 추린다.
314
+
315
+ 같은 worker-results 디렉토리에 seq-less 레거시(`codex-worker.md`)와 이전
316
+ seq 결과가 공존한다(관측: dev-9186 에 5월 레거시 72KB 혼입 → reportWriter
317
+ 표면 과대 측정). 실 dispatch 계약은 현재 seq 파일만 열거하므로 max seq 로
318
+ 스코핑한다."""
319
+ candidates = [
320
+ path for path in (run_dir / "worker-results").glob("*.md")
321
+ if "-audit-" not in path.name and "report-writer" not in path.name
322
+ ]
323
+ by_seq: dict[str, list[Path]] = {}
324
+ for path in candidates:
325
+ match = _SEQ_RESULT_RE.search(path.name)
326
+ if match:
327
+ by_seq.setdefault(match.group(1), []).append(path)
328
+ if not by_seq:
329
+ return []
330
+ return sorted(by_seq[max(by_seq)])
331
+
332
+
309
333
  def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: Path) -> dict:
310
334
  instruction_set = task_root / "instruction-set"
311
335
  files = [instruction_set / name for name in INPUT_FILES]
336
+ # 실 dispatch 계약(report-writer prompt 의 required reading)은 packet 이
337
+ # 있으면 analysis-material 대신 analysis-packet 을 읽는다 — worker metric
338
+ # 의 packet-primary 규칙과 동일.
339
+ packet = instruction_set / "analysis-packet.md"
340
+ if packet.is_file():
341
+ files = [path for path in files if path.name != "analysis-material.md"]
342
+ files.append(packet)
312
343
  files.extend([
313
344
  instruction_set / "final-report-template.md",
314
345
  instruction_set / "final-report-schema.json",
315
346
  ])
316
347
  if run_dir is not None:
317
- worker_results = run_dir / "worker-results"
318
- files.extend(
319
- path for path in worker_results.glob("*.md")
320
- if "-audit-" not in path.name and "report-writer" not in path.name
321
- )
348
+ files.extend(_current_seq_worker_results(run_dir))
322
349
  convergence = _latest_matching_file(
323
350
  run_dir / "state", f"convergence-{run_dir.name}-*.json"
324
351
  )
@@ -109,6 +109,8 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
109
109
 
110
110
  title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
111
111
  response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
112
+ sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
113
+ sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
112
114
 
113
115
  return (
114
116
  f"<!DOCTYPE html>\n"
@@ -127,6 +129,8 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
127
129
  f"<main>{body_html}</main>\n"
128
130
  f"<footer class=\"report-footer\">\n"
129
131
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
132
+ f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
133
+ f"<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n"
130
134
  f" <pre id=\"user-response-output\" aria-live=\"polite\"></pre>\n"
131
135
  f" <button type=\"button\" data-action=\"copy-user-response\">Copy</button>\n"
132
136
  f"</footer>\n"
@@ -353,6 +357,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
353
357
  (j for j, h in enumerate(header_cells) if h.startswith("Statement")),
354
358
  -1,
355
359
  )
360
+ expected_form_col = next(
361
+ (j for j, h in enumerate(header_cells) if h.startswith("Expected form")),
362
+ -1,
363
+ )
356
364
  user_input_col = (
357
365
  header_cells.index("User input") if "User input" in header_cells else -1
358
366
  )
@@ -367,11 +375,14 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
367
375
  statement = (
368
376
  row[statement_col] if 0 <= statement_col < len(row) else ""
369
377
  )
378
+ expected_form = (
379
+ row[expected_form_col] if 0 <= expected_form_col < len(row) else ""
380
+ )
370
381
  cells_html: list[str] = []
371
382
  for idx, cell in enumerate(row):
372
383
  if idx == user_input_col:
373
384
  cells_html.append(
374
- f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement)}</td>"
385
+ f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement, expected_form)}</td>"
375
386
  )
376
387
  else:
377
388
  cells_html.append(
@@ -439,12 +450,51 @@ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
439
450
  return out if len(out) >= 2 else []
440
451
 
441
452
 
453
+ _RECOMMENDED_CUE = "Recommended:"
454
+ _ALTERNATIVES_CUE = "Alternatives:"
455
+
456
+
457
+ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
458
+ """Parse the ``Expected form`` contract format
459
+ (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
460
+ `_common-contract.md` §Clarification request policy) into select
461
+ ``(value, label)`` options. Returns ``[]`` when the cell carries no
462
+ ``Recommended:`` cue — the caller falls back to the statement enum."""
463
+ if not expected_form:
464
+ return []
465
+ rec_idx = expected_form.find(_RECOMMENDED_CUE)
466
+ if rec_idx < 0:
467
+ return []
468
+ rec_body = expected_form[rec_idx + len(_RECOMMENDED_CUE):]
469
+ alt_body = ""
470
+ alt_idx = rec_body.find(_ALTERNATIVES_CUE)
471
+ if alt_idx >= 0:
472
+ alt_body = rec_body[alt_idx + len(_ALTERNATIVES_CUE):]
473
+ rec_body = rec_body[:alt_idx]
474
+ # The rationale follows " — "; the option keeps only the answer part.
475
+ answer = rec_body.split(" — ", 1)[0].strip(" .,;—-")
476
+ options: list[tuple[str, str]] = []
477
+ if answer:
478
+ options.append(("recommended", f"추천: {answer}"))
479
+ enum_alts = _parse_enum_options(alt_body)
480
+ if enum_alts:
481
+ options.extend(
482
+ (letter, f"({letter}) {text}") for letter, text in enum_alts
483
+ )
484
+ else:
485
+ alt_text = alt_body.strip(" .,;—-")
486
+ if alt_text:
487
+ options.append(("alternative", alt_text))
488
+ return options
489
+
490
+
442
491
  def _form_control(
443
492
  response_id: str,
444
493
  kind: str,
445
494
  status: str,
446
495
  current_value: str,
447
496
  statement: str = "",
497
+ expected_form: str = "",
448
498
  ) -> str:
449
499
  rid = html.escape(response_id)
450
500
  disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
@@ -456,14 +506,21 @@ def _form_control(
456
506
  "data-point": "값",
457
507
  }.get(kind_lc, "응답")
458
508
 
459
- # decision 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
509
+ # decision 후보는 Expected form `Recommended: …; Alternatives: …`
510
+ # 계약(_common-contract.md §Clarification request policy)이 1순위,
511
+ # statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
460
512
  if kind_lc == "decision":
461
- opts = _parse_enum_options(statement)
513
+ opts = _parse_expected_form_options(expected_form)
514
+ if not opts:
515
+ opts = [
516
+ (letter, f"({letter}) {text}")
517
+ for letter, text in _parse_enum_options(statement)
518
+ ]
462
519
  if opts:
463
520
  select_opts = "".join(
464
- f'<option value="{letter}">'
465
- f'{_inline(f"({letter}) " + text)}</option>'
466
- for letter, text in opts
521
+ f'<option value="{html.escape(value)}">'
522
+ f"{_inline(label)}</option>"
523
+ for value, label in opts
467
524
  )
468
525
  select_html = (
469
526
  f'<select name="{rid}" data-response-id="{rid}" '
@@ -30,7 +30,7 @@ from okstra_project import project_json_path, upsert_project_json
30
30
  from okstra_project.state import slugify
31
31
  from . import fix_cycles
32
32
  from .analysis_packet import build_analysis_packet
33
- from .clarification_items import scan_approval_gate
33
+ from .clarification_items import clarification_response_with_sidecars, scan_approval_gate
34
34
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
35
35
  from .material import (
36
36
  build_analysis_material,
@@ -1776,9 +1776,9 @@ def _write_instruction_set_sources(
1776
1776
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
1777
1777
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
1778
1778
  if inp.clarification_response_path:
1779
- shutil.copyfile(
1780
- inp.clarification_response_path,
1781
- instruction_set / "clarification-response.md",
1779
+ (instruction_set / "clarification-response.md").write_text(
1780
+ clarification_response_with_sidecars(Path(inp.clarification_response_path)),
1781
+ encoding="utf-8",
1782
1782
  )
1783
1783
  if inp.directive:
1784
1784
  (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
@@ -2,12 +2,18 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  from datetime import datetime
6
7
  from pathlib import Path
7
8
 
8
9
  from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
9
10
  from .paths import claude_project_dir, ts_in_window
10
11
 
12
+ # lead 의 단계 체크포인트 라인(agents/SKILL.md "Progress reporting") — phase id 가
13
+ # `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
14
+ _PROGRESS_RE = re.compile(r"^PROGRESS:\s+(phase-\d\S*(?:[ \t].*)?)$", re.MULTILINE)
15
+ _PROGRESS_MARKER_MAX_CHARS = 160
16
+
11
17
 
12
18
  def _event_from_record(rec: dict) -> dict | None:
13
19
  """jsonl 레코드 1개 → 압축 이벤트. 집계에 기여하지 않으면 None.
@@ -48,6 +54,18 @@ def _event_from_record(rec: dict) -> dict | None:
48
54
  if isinstance(b, dict) and b.get("type") == "tool_use")
49
55
  if tools:
50
56
  ev["u"] = tools
57
+ markers = [
58
+ m.group(1).strip()[:_PROGRESS_MARKER_MAX_CHARS]
59
+ for b in (msg.get("content") or [])
60
+ if isinstance(b, dict) and b.get("type") == "text"
61
+ for m in _PROGRESS_RE.finditer(b.get("text") or "")
62
+ ]
63
+ # 서로 다른 phase id 가 한 메시지에 섞이면 계약 인용/회고 요약이라 그
64
+ # 시각은 단계 경계가 아니다(관측: dev-9186 run 의 막판 요약 1건이 전
65
+ # phase 의 lastAt 을 오염). 같은 phase id 의 반복(병렬 dispatch 3줄,
66
+ # intake reading+complete)은 라이브 체크포인트로 인정한다.
67
+ if markers and len({m.split(None, 1)[0] for m in markers}) == 1:
68
+ ev["p"] = markers
51
69
  ts = rec.get("timestamp") or msg.get("timestamp")
52
70
  if ts:
53
71
  ev["t"] = ts
@@ -133,6 +151,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
133
151
  input_t = output_t = cache_create_t = cache_read_t = 0
134
152
  cache_create_5m_t = cache_create_1h_t = 0
135
153
  tool_uses = 0
154
+ progress_markers: list[dict] = []
136
155
  first_ts: str | None = None
137
156
  last_ts: str | None = None
138
157
  for ev in events:
@@ -146,6 +165,8 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
146
165
  cache_create_1h_t += ev.get("c1", 0)
147
166
  cache_read_t += ev.get("r", 0)
148
167
  tool_uses += ev.get("u", 0)
168
+ for marker in ev.get("p", ()):
169
+ progress_markers.append({"at": ts, "marker": marker})
149
170
  if ts:
150
171
  if first_ts is None or ts < first_ts:
151
172
  first_ts = ts
@@ -179,6 +200,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
179
200
  "model": model,
180
201
  "startedAt": first_ts,
181
202
  "endedAt": last_ts,
203
+ "progressMarkers": progress_markers,
182
204
  }
183
205
 
184
206
 
@@ -141,6 +141,51 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
141
141
  return since, until
142
142
 
143
143
 
144
+ def _wall_ms(start_iso: str | None, end_iso: str | None) -> int | None:
145
+ if not start_iso or not end_iso:
146
+ return None
147
+ try:
148
+ a = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
149
+ b = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
150
+ except ValueError:
151
+ return None
152
+ return max(0, int((b - a).total_seconds() * 1000))
153
+
154
+
155
+ def phase_timeline(markers: list[dict]) -> dict:
156
+ """lead 의 PROGRESS 체크포인트(agents/SKILL.md "Progress reporting")로
157
+ run 내부 단계 경계 wall-clock 을 복원한다 (perf plan v2 P0 계측).
158
+
159
+ phase id = marker 의 첫 토큰(`phase-5.5-convergence` 등). 같은 phase 의
160
+ 반복 마커(poll/collect/dispatch)는 first/last 로 접는다. `wallMsToNext` 는
161
+ 다음 phase 의 firstAt 까지 — 마지막 phase 는 None(run 종료 신호가 마커에
162
+ 없으므로 추정하지 않는다). 마커가 하나도 없으면 phases 가 빈 채로 남아
163
+ "이 run 은 측정 불가"를 명시적으로 표현한다.
164
+ """
165
+ phases: list[dict] = []
166
+ by_id: dict[str, dict] = {}
167
+ for m in markers:
168
+ at = m.get("at")
169
+ phase_id = (m.get("marker") or "").split(None, 1)[0]
170
+ if not phase_id:
171
+ continue
172
+ entry = by_id.get(phase_id)
173
+ if entry is None:
174
+ entry = {"phase": phase_id, "firstAt": at, "lastAt": at,
175
+ "markerCount": 0, "wallMsToNext": None}
176
+ by_id[phase_id] = entry
177
+ phases.append(entry)
178
+ entry["markerCount"] += 1
179
+ if at:
180
+ if entry["firstAt"] is None or at < entry["firstAt"]:
181
+ entry["firstAt"] = at
182
+ if entry["lastAt"] is None or at > entry["lastAt"]:
183
+ entry["lastAt"] = at
184
+ for current, nxt in zip(phases, phases[1:]):
185
+ current["wallMsToNext"] = _wall_ms(current["firstAt"], nxt["firstAt"])
186
+ return {"source": "lead-progress-markers", "phases": phases}
187
+
188
+
144
189
  def resolve_team_name(state: dict) -> str:
145
190
  """team-state 에서 이 run 의 team name 을 해소한다.
146
191
 
@@ -232,6 +277,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
232
277
  incremental=incremental)
233
278
  state["leadUsage"] = usage_block(totals, source="claude-jsonl")
234
279
  state["leadUsage"]["sessionId"] = lead_sid
280
+ state["phaseTimeline"] = phase_timeline(totals.get("progressMarkers") or [])
235
281
  else:
236
282
  state["leadUsage"] = na_block(
237
283
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
@@ -18,7 +18,9 @@ from pathlib import Path
18
18
 
19
19
  from okstra_project.dirs import okstra_home
20
20
 
21
- CACHE_SCHEMA_VERSION = 1
21
+ # v2: usage 이벤트에 `p`(lead PROGRESS 마커) 키 추가 — v1 캐시는 이미 스캔한
22
+ # 구간의 마커가 없으므로 폐기하고 전체 재스캔한다(fail-open).
23
+ CACHE_SCHEMA_VERSION = 2
22
24
  IDENTITY_PREFIX_BYTES = 256
23
25
  MAX_NEEDLES = 16
24
26
 
@@ -1242,9 +1242,10 @@
1242
1242
 
1243
1243
  "ValidationCheckRow": {
1244
1244
  "type": "object",
1245
- "required": ["phase", "ticketId", "check", "commandOrObservation", "expectedOutcome"],
1245
+ "required": ["id", "phase", "ticketId", "check", "commandOrObservation", "expectedOutcome"],
1246
1246
  "additionalProperties": false,
1247
1247
  "properties": {
1248
+ "id": { "type": "string", "pattern": "^VC-\\d{3,}$" },
1248
1249
  "phase": { "enum": ["pre", "mid", "post"] },
1249
1250
  "ticketId": { "$ref": "#/$defs/TicketId" },
1250
1251
  "check": { "type": "string", "minLength": 1 },
@@ -519,14 +519,18 @@ Schema rules:
519
519
  Runs only when `convergence.critic.enabled == true` (set by `--critic <provider>` or the okstra-run `critic_pick` step; default off). Applies to the three finding-producing phases (`requirements-discovery`, `error-analysis`, `implementation-planning`); for `final-verification` the critic runs in a different mode — see §"Acceptance critic pass (final-verification)". This pass targets **coverage** (missed findings), distinct from convergence which targets **agreement quality**.
520
520
 
521
521
  ### When
522
- After Phase 5.5 finding convergence completes (findings classified) and BEFORE the Phase 6 report-writer dispatch.
522
+
523
+ The critic input is the Round 0 consolidated finding list. Reverify rounds only classify findings — they never add or remove them (in-round queue insertions are forbidden, see §"Convergence State Artifact" `carriedForwardCount`) — so the critic dispatch MUST NOT wait for classification to finish:
524
+
525
+ - **Dispatch**: immediately after Round 0 grouping, CONCURRENTLY with the first reverify round's dispatches. When the verification queue is empty after Round 0 (no reverify round runs), dispatch right after grouping. Concurrent dispatch to the same provider is safe — the critic result path (`<provider>-critic-...`) never collides with a reverify result path.
526
+ - **Gap verification + merge**: only after BOTH the finding-convergence loop has exited AND the critic result is collected, and BEFORE the Phase 6 report-writer dispatch. If the loop exited `aborted-non-result`, do NOT dispatch a gap-verification round — surface the gaps as unverified `clarification` items per §"Gap verification" and record that fact.
523
527
 
524
528
  ### Dispatch (reused worker)
525
529
  Dispatch ONE pass to the `config.critic.provider`'s existing subagent (`claude-worker` / `codex-worker` / `gemini-worker`) with `model = config.critic.modelExecutionValue` — no new agent type. If `config.critic.modelExecutionValue` is null/empty (model could not be resolved), skip the critic pass and record `critic-skipped: model-unresolved` in the convergence state rather than dispatching with no model. Result path: `runs/<task-type>/worker-results/<provider>-critic-<task-type>-<seq>.md`. The critic prompt seeds the consolidated findings and asks ONLY for coverage gaps:
526
530
 
527
531
  ```
528
- You are the coverage critic for <task-key>. Below are the findings the workers
529
- already agreed on. Your ONLY job is to name what is MISSING:
532
+ You are the coverage critic for <task-key>. Below are the consolidated findings
533
+ the workers produced. Your ONLY job is to name what is MISSING:
530
534
  - files / directories / execution paths nobody inspected,
531
535
  - requirements or acceptance points with zero findings,
532
536
  - claims raised but never verified.
@@ -543,7 +547,7 @@ Each critic gap enters the verification queue as a finding with `originWorker =
543
547
 
544
548
  ## Acceptance critic pass (final-verification)
545
549
 
546
- The `final-verification` phase reuses the SAME reused-worker dispatch as §"Coverage critic pass" (provider + `config.critic.modelExecutionValue` from the `convergence.critic` block; default off; same model-unresolved skip rule). Only the prompt, the verification semantics, and the output sink differ — final-verification's findings are defects/blockers, so the critic acts as an **acceptance devil's advocate** (find reasons NOT to accept), and its candidate blockers are NEVER dropped (that would suppress real defects).
550
+ The `final-verification` phase reuses the SAME reused-worker dispatch AND the same dispatch timing as §"Coverage critic pass" §"When" (provider + `config.critic.modelExecutionValue` from the `convergence.critic` block; default off; same model-unresolved skip rule) — the delivered work the critic inspects is likewise fixed before the reverify round starts. Only the prompt, the verification semantics, and the output sink differ — final-verification's findings are defects/blockers, so the critic acts as an **acceptance devil's advocate** (find reasons NOT to accept), and its candidate blockers are NEVER dropped (that would suppress real defects).
547
551
 
548
552
  ### Prompt
549
553
 
@@ -425,6 +425,15 @@ Display rule for `workerId` vs `agent`:
425
425
 
426
426
  Never write `claude (claude)` — the parenthesized agent is shown only when it adds information.
427
427
 
428
+ **C. Per-run phase breakdown (when `phaseTimeline` is present):**
429
+
430
+ team-state written by collector ≥ v0.73 carries a `phaseTimeline` block — the Phase 7 collector reconstructs lead operating-phase boundaries from the lead session's `PROGRESS: phase-*` checkpoint lines (`{source: "lead-progress-markers", phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`).
431
+
432
+ - When the user asks for a per-phase / per-run breakdown ("단계별", "phase별", "어느 단계가 오래"), render one table per requested run: `| Phase | Start | Wall to next |` using `firstAt` and `wallMsToNext`.
433
+ - `wallMsToNext` is `null` on the last phase (the run-end signal is not a marker) — render `--`.
434
+ - `phases: []` means the lead emitted no markers (run predates the contract, or markers were skipped) — say "phase timeline 측정 불가(마커 없음)" rather than estimating from other fields.
435
+ - Older team-state files have no `phaseTimeline` key at all — omit the section silently.
436
+
428
437
  **Timestamp parsing:** for `startedAt` / `endedAt`, normalize ISO-8601: replace trailing `Z` with `+00:00`, accept explicit offsets as-is, parse via `datetime.fromisoformat(s.replace("Z", "+00:00"))`. Strings without an offset are assumed UTC. Mixed-form comparisons must be done as `datetime` objects, never raw strings.
429
438
 
430
439
  ### time.4 — Format output
@@ -458,6 +467,17 @@ Never write `claude (claude)` — the parenthesized agent is shown only when it
458
467
  | report-writer | 2 | 00:01:00 | 00:00:30 |
459
468
 
460
469
  > Unavailable: 1 run (implementation / 2026-04-30_03-03-48) — team-state has no durationMs (Phase 7 not reached)
470
+
471
+ ### Phase breakdown — requirements-discovery (run 002)
472
+
473
+ | Phase | Start (UTC) | Wall to next |
474
+ |------------------------|------------------|--------------|
475
+ | phase-1-intake | 2026-06-11 09:00 | 00:04:00 |
476
+ | phase-2-prompts | 2026-06-11 09:04 | 00:02:10 |
477
+ | phase-4-dispatch | 2026-06-11 09:06 | 00:18:30 |
478
+ | phase-5.5-convergence | 2026-06-11 09:25 | 00:21:00 |
479
+ | phase-6-synthesis | 2026-06-11 09:46 | 00:15:00 |
480
+ | phase-7-persist | 2026-06-11 10:01 | -- |
461
481
  ```
462
482
 
463
483
  **Rules:**
@@ -105,7 +105,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
105
105
  ```
106
106
 
107
107
  Output (idempotent — re-running overwrites):
108
- - `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — single-file self-contained human view, **generated only when the report has at least one §5 `C-*` clarification row**. Those rows with `Status` ∈ {`open`, `answered`} embed form widgets (`<select>` for enum-style decisions, `<input>` for material / data-point kinds, `<textarea>` fallback); an `Export user response` button serialises form values to a markdown sidecar (schema in [`templates/reports/user-response.template.md`](../../templates/reports/user-response.template.md)) that the user pastes to `runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md`. The original final-report MD is **never** mutated by user input — the sidecar is the single write target.
108
+ - `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — single-file self-contained human view, **generated only when the report has at least one §5 `C-*` clarification row**. Those rows with `Status` ∈ {`open`, `answered`} embed form widgets (`<select>` for enum-style decisions, `<input>` for material / data-point kinds, `<textarea>` fallback); an `Export user response` button serialises form values to a markdown sidecar (schema in [`templates/reports/user-response.template.md`](../../templates/reports/user-response.template.md)) and downloads it as `user-response-<task-type>-<seq>.md`; the user saves it to `runs/<task-type>/user-responses/`, and `--resume-clarification` auto-appends every sidecar found there to the next run's `clarification-response.md` (`clarification_items.clarification_response_with_sidecars`). The original final-report MD is **never** mutated by user input — the sidecar is the single write target.
109
109
  - When the report has **no** `C-*` clarification rows, the html carries no interactive forms (it would only duplicate the MD), so the renderer prints `html: skipped (...)` and writes nothing. This is the expected state for clarification-free runs — `validators/validate-report-views.py` treats "no C-* rows + no html" as a pass, not a missing artifact.
110
110
 
111
111
  Must run AFTER step 1 (so token placeholders are substituted in any rendered html) and BEFORE step 2 (so the html artifact, when generated, exists for the validator step that checks it).
@@ -276,7 +276,7 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
276
276
  3. **Evidence and Detailed Analysis** — primary evidence rows (file path, line, snippet); secondary evidence / alternate interpretations. If `reference-expectations.md` lists explicit expected values, record match/gap per row.
277
277
  4. **Missing Information and Risks** — uncertain / "I don't know" items. `implementation-planning` adds §5.5 (see heading contract below); `release-handoff` adds §5.6.
278
278
  5. **Clarification Items** — single unified `C-*` table; column schema (4 columns with the short fields stacked in one record-meta cell), ID convention, and rerun behaviour are owned by `_common-contract.md §Clarification request policy` (SSOT). The deprecated `5.5.9 Open Questions` / `1.1 추가 자료 요청` / `1.2 사용자 확인 질문` sub-sections are removed; the validator fails reports that reintroduce them.
279
- 6. **Recommended Next Steps** — prioritized actions. After Phase 7's follow-up spawner runs, append a row per newly created task-key (see "Phase 6 → Phase 7 execution sequence" above).
279
+ 6. **Recommended Next Steps** — prioritized actions. After Phase 7's follow-up spawner runs, append a row per newly created task-key (see "Phase 6 → Phase 7 execution sequence" above). **Approval-gate consistency:** when §1 carries any `Blocks: approval` row with `Status` ∈ {open, answered}, the Verdict Card `Next Step` and the first recommended step MUST point to the clarification rerun (`resume-clarification` of the SAME task-type) — never to "frontmatter `approved: true` 플립 → `implementation` 직행". Run-prep enforces this gate (`run.py _validate_approved_plan` fail-closes on those rows and on a blocking data.json `gateResult`), so a direct-implementation next-step is an instruction the reader cannot actually follow.
280
280
  7. **Follow-up Tasks** — auto-spawn-eligible table. Each row drives `okstra-spawn-followups.py`; see template §7 for the row schema.
281
281
 
282
282
  **§5.10 Fix History (data-presence gated).** When the run-manifest carries a `fixCycleId`, fill the data.json `fixCycle` block (`cycle` / `targetReport` / `symptom` / `runs`). Read the values from the task root's `history/fix-cycles.jsonl`: `cycle` MUST equal `fixCycleId`, `targetReport` / `symptom` come from that cycle's `opened` row, and `runs` lists its attached `run` rows (`taskType` / `runSeq` / `runManifest`). The validator (`validators/validate-run.py` → `_validate_fix_cycle`) fails the run when the block is missing or `fixCycle.cycle` does not match `fixCycleId`. When the run-manifest has no `fixCycleId`, OMIT the `fixCycle` block entirely — the renderer omits §5.10.
@@ -202,10 +202,10 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
202
202
 
203
203
  ### Validation Checklist{% if t("sectionAside.validationChecklist") != "Validation Checklist" %} ({{ t("sectionAside.validationChecklist") }}){% endif %}
204
204
 
205
- | Phase | Ticket ID | Check | Command / Observation | Expected outcome |
206
- |-------|-----------|-------|------------------------|-------------------|
205
+ | ID | Phase | Ticket ID | Check | Command / Observation | Expected outcome |
206
+ |----|-------|-----------|-------|------------------------|-------------------|
207
207
  {% for row in implementationPlanning.validationChecklist -%}
208
- | {{ row.phase }} | `{{ row.ticketId }}` | {{ row.check }} | `{{ row.commandOrObservation }}` | {{ row.expectedOutcome }} |
208
+ | {{ row.id }} | {{ row.phase }} | `{{ row.ticketId }}` | {{ row.check }} | `{{ row.commandOrObservation }}` | {{ row.expectedOutcome }} |
209
209
  {% endfor %}
210
210
 
211
211
  ### Rollback Strategy{% if t("sectionAside.rollbackStrategy") != "Rollback Strategy" %} ({{ t("sectionAside.rollbackStrategy") }}){% endif %}
@@ -9,8 +9,11 @@
9
9
  * (everything else / fallback).
10
10
  * 2. Serialise the entries into markdown whose bytes are IDENTICAL
11
11
  * to scripts/okstra_ctl/report_views.py serialize_user_response.
12
- * 3. Write the result to <pre id="user-response-output"> and offer a
13
- * [Copy] button.
12
+ * 3. Write the result to <pre id="user-response-output">, offer a
13
+ * [Copy] button, and download it as
14
+ * user-response-<task-type>-<seq>.md — the user drops that file in
15
+ * runs/<task-type>/user-responses/ and --resume-clarification
16
+ * auto-appends it to the next run's clarification-response.md.
14
17
  *
15
18
  * The byte-identity contract is enforced by tests/test_report_views.py
16
19
  * which spawns Node to execute buildUserResponseMarkdown and diffs the
@@ -143,12 +146,30 @@
143
146
  return head + chunks;
144
147
  }
145
148
 
149
+ function downloadUserResponse(md, runMeta) {
150
+ var name =
151
+ "user-response-" + (runMeta["task-type"] || "report") + "-" +
152
+ (runMeta["seq"] || "000") + ".md";
153
+ var blob = new Blob([md], { type: "text/markdown" });
154
+ var url = URL.createObjectURL(blob);
155
+ var a = document.createElement("a");
156
+ a.href = url;
157
+ a.download = name;
158
+ document.body.appendChild(a);
159
+ a.click();
160
+ document.body.removeChild(a);
161
+ setTimeout(function () { URL.revokeObjectURL(url); }, 0);
162
+ }
163
+
146
164
  function exportUserResponse() {
147
165
  var runMeta = readRunMeta();
148
166
  var entries = collectEntries();
149
167
  var md = buildUserResponseMarkdown(runMeta, entries, isoNowUtc());
150
168
  var out = document.getElementById("user-response-output");
151
169
  if (out) out.textContent = md;
170
+ // Nothing answered yet — show the empty serialisation as feedback but
171
+ // don't download a useless file.
172
+ if (entries.length > 0) downloadUserResponse(md, runMeta);
152
173
  return md;
153
174
  }
154
175