okstra 0.58.0 → 0.60.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.
- package/docs/kr/architecture.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +24 -13
- package/runtime/python/okstra_ctl/clarification_items.py +88 -39
- package/runtime/python/okstra_ctl/render_final_report.py +40 -0
- package/runtime/python/okstra_ctl/report_views.py +18 -175
- package/runtime/python/okstra_ctl/run.py +12 -1
- package/runtime/python/okstra_ctl/wizard.py +56 -9
- package/runtime/schemas/final-report-v1.0.schema.json +4 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/templates/reports/final-report.template.md +7 -6
- package/runtime/templates/reports/i18n/en.json +1 -1
- package/runtime/templates/reports/i18n/ko.json +1 -1
- package/runtime/templates/reports/report.css +0 -32
- package/runtime/templates/worker-prompt-preamble.md +10 -0
- package/runtime/validators/validate-implementation-plan-stages.py +0 -3
- package/runtime/validators/validate-report-views.py +12 -1
- package/runtime/validators/validate-run.py +26 -6
package/docs/kr/architecture.md
CHANGED
|
@@ -239,7 +239,7 @@ per-process 환경 변수에 task 정체성·경로·workflow 상태를 보관
|
|
|
239
239
|
- standard workflow의 기본 worker role은 `Claude worker`, `Codex worker`, `Report writer worker`이며, `Gemini worker`는 `--workers` 또는 프로필에서 명시할 때만 포함되는 옵션입니다.
|
|
240
240
|
- worker 역할 분담과 최종 판단은 Claude가 task bundle을 읽고 수행합니다.
|
|
241
241
|
- 사용자 홈에 설치된 okstra Claude assets(`~/.claude/skills`, `~/.claude/agents`) 는 Agent Teams 를 우선 시도하고, 팀 구성이 불가능할 때만 sequential/background fallback 을 사용하도록 Claude 를 유도합니다.
|
|
242
|
-
- **팀 lifecycle**: lead 는 Phase 3 에서 `TeamCreate(team_name: "okstra-<task-key>")` 로 팀을 만들고 워커를 그 멤버로 dispatch 합니다. run 종료 시
|
|
242
|
+
- **팀 lifecycle**: lead 는 Phase 3 에서 `TeamCreate(team_name: "okstra-<task-key>")` 로 팀을 만들고 워커를 그 멤버로 dispatch 합니다. run 종료 시 Phase 7 토큰 집계 이후 먼저 잔여 tmux pane 을 사용자에게 보여 주고 닫을지 확인한 뒤, Teams mode 에서만 worker teammate 팀 `okstra-<task-key>` 을 해제할지 다시 확인합니다. 사용자가 승인하면 `okstra-team-reconcile.sh` 로 dead-pane stale-active 멤버를 정리하고 `TeamDelete` 로 팀을 해제합니다. `TeamDelete` 는 `~/.claude/teams/<team>/`·`~/.claude/tasks/<team>/` 만 지우고 토큰 집계 소스인 `~/.claude/projects/` jsonl 은 보존합니다. 사용자가 유지하거나 teardown 이 실패하면 worker teammate 가 FleetView roster 에 남으며, lead 는 Teams/FleetView 에서 해당 team 을 삭제하라고 안내합니다 (`prompts/profiles/_common-contract.md` 의 *Run-end teammate teardown*). no-`team_name` fallback 에서는 팀이 없으므로 silent-skip.
|
|
243
243
|
|
|
244
244
|
## Claude prompt contract
|
|
245
245
|
|
|
@@ -913,7 +913,7 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 두 view 를 결정
|
|
|
913
913
|
- tmux 가 reachable 한 lead 환경이면 wrapper 가 sibling pane 을 자동 분할해 `tail -F <log-path>` 를 띄웁니다. trace pane title 은 caller (worker) pane title 에 `-tail` 을 붙인 `<cli>-<role>-<pid>-tail` (e.g. `codex-worker-93421-tail`); 동일 시점에 caller (worker) pane title 은 `<cli>-<role>-<pid>` 로 셋팅됩니다. `<pid>` 는 wrapper 자기 자신의 PID 라서 동일 role 의 worker 가 둘 이상 동시에 spawn 돼도 서로 구분되고, 운영자는 `<caller> ↔ <caller>-tail` 로 시각적으로 매핑할 수 있습니다. **caller pane 해석** — Claude Code Bash tool 은 이제 `$TMUX` 와 `$TMUX_PANE` 를 둘 다 환경에서 제거하므로 env 변수에 의존하지 않습니다. wrapper 는 (1) prompt path 로부터 `<RUN_DIR>` (= `dirname(dirname(prompt_path))`, paths.py SSOT) 를 도출하고, (2) lead 가 자기 foreground pane 에서 1회 기록한 `<RUN_DIR>/state/lead-pane.id` 를 읽어 split anchor 로 씁니다 (background dispatch 에서도 신뢰 가능 — active-pane 추정과 달리 사용자가 pane 을 옮겨도 안전). 기록 파일이 없거나 pane 이 stale 이면 `tmux display-message -p '#{pane_id}'` (active pane) 으로 fallback. trace pane split 은 그 caller pane 을 `-t` 로 명시 anchor 합니다. role 은 wrapper 의 5번째 optional positional 인자이며, 누락 시 기본값 `worker`. caller pane title 은 capture 해두고 EXIT trap 에서 복원하므로 dispatch 사이의 stale title 이 남지 않습니다. focus 는 caller pane 으로 복귀하고, CLI 종료 후 pane 은 유지돼 스크롤백 가능. tmux 미reachable, split 실패, 구버전 tmux 등 모든 경로는 silent degrade.
|
|
914
914
|
- **run-scoped 태깅으로 정리**: trace pane 의 `tail -F` 는 tmux 셸의 자식이라 Claude 가 종료돼도 살아남습니다. wrapper 는 spawn 한 pane 을 `tmux set-option -p @okstra_trace_run=<RUN_DIR>` 로 태깅하고, `okstra-trace-cleanup.sh` 는 `tmux list-panes -a` 에서 그 태그로 pane 을 server-wide 발견해 `tmux kill-pane` 합니다. tmux env 변수·pane-id registry 없이 동작하며, run-scoped 태그라 동시에 도는 다른 okstra run 의 trace pane 을 죽이지 않습니다. cleanup 은 두 진입 형태를 가집니다 — lead 가 `--run-dir <RUN_DIR>` 로 호출(해당 run 의 trace + worker-agent pane 정리)하거나, `templates/reports/settings.template.json` 의 `hooks.SessionEnd` 가 `--reap` 로 호출(`$CLAUDE_PROJECT_DIR/.okstra/` 하위 태그를 가진 trace pane 일괄 정리; 단일 run-dir 이 없는 종료 시점용). tmux 가 없거나 stale pane id 인 경우 silent degrade.
|
|
915
915
|
- **phase 전환 시 자동 정리 + worker-agent pane 포함**: `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` 는 태깅된 trace pane 뿐 아니라 dispatch 된 서브에이전트가 점유하는 worker-agent pane(title `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`)도 lead 세션(`tmux list-panes -s -t <lead-pane>`) 범위에서 title allowlist 로 식별해 닫습니다(worker-agent pane 은 harness 소유라 태깅 불가). 세션 scope 와 lead 자기 pane 제외는 `<RUN_DIR>/state/lead-pane.id` 로 결정되며, lead 자신의 pane 은 title 이 걸려도 절대 죽이지 않습니다. lead 는 새 phase 의 worker 를 dispatch 하기 직전(`PROGRESS: phase-5.5-convergence` / `phase-6-synthesis` 마커 직전) 이 스크립트를 `--run-dir` 로 호출해 이전 phase 의 pane 을 prompt 없이 정리합니다.
|
|
916
|
-
- **Phase 종료 시 사용자 확인**: run 최종 종료 시점(마지막 단계)에 lead 가 `okstra-trace-cleanup.sh --list --run-dir <RUN_DIR>` 로 잔여 okstra pane(worker-agent + trace) 목록을 출력한 뒤 사용자에게 "모두 닫기 / 그대로 두기" 양자택일을 묻고 응답대로 처리합니다 (`prompts/profiles/_common-contract.md` 의 *Phase wrap-up* 항목). `<RUN_DIR>/state/lead-pane.id` 가 비어 있는(=tmux 밖) 환경에서는
|
|
916
|
+
- **Phase 종료 시 사용자 확인**: run 최종 종료 시점(마지막 단계)에 lead 가 `okstra-trace-cleanup.sh --list --run-dir <RUN_DIR>` 로 잔여 okstra pane(worker-agent + trace) 목록을 출력한 뒤 사용자에게 "모두 닫기 / 그대로 두기" 양자택일을 묻고 응답대로 처리합니다 (`prompts/profiles/_common-contract.md` 의 *Phase wrap-up* 항목). 그 다음 Teams mode 인 경우 worker teammate 팀을 해제할지 별도로 묻고, 승인 시 `okstra-team-reconcile.sh` + `TeamDelete` 를 실행합니다. `<RUN_DIR>/state/lead-pane.id` 가 비어 있는(=tmux 밖) 환경에서는 pane 단계만 silent-skip 하며, teammate 단계는 team-state 의 `teamCreate.status` 로 판단합니다. `--list` 모드는 pane 을 죽이지 않고 `<pane_id>\t<pane_title>` 만 출력하므로 사용자가 무엇이 닫힐지 시각적으로 확인할 수 있습니다.
|
|
917
917
|
- 디스크 누적은 `okstra-inspect logs` 흐름이 read-only 로 인벤토리 + cleanup 명령을 제안합니다 (실행은 사용자 copy-paste).
|
|
918
918
|
|
|
919
919
|
### Linked-worktree `.git/` write 권한 (codex / gemini)
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
package/runtime/agents/SKILL.md
CHANGED
|
@@ -44,7 +44,7 @@ This SKILL.md is the operating contract and phase index. Detailed procedures liv
|
|
|
44
44
|
| 5.5 Convergence | Cross-verify findings across workers | `okstra-convergence` |
|
|
45
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" |
|
|
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
|
-
| 7. Persist | Run token-usage collector, update manifests, then disband the worker team (reconcile stale members + `TeamDelete
|
|
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
|
|
|
49
49
|
## Core operating contract
|
|
50
50
|
|
|
@@ -96,7 +96,7 @@ Required checkpoints:
|
|
|
96
96
|
- `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
97
|
- `PROGRESS: phase-6-synthesis dispatching report-writer-worker` — at the start of Phase 6.
|
|
98
98
|
- `PROGRESS: phase-7-persist updating manifests` — at the start of Phase 7.
|
|
99
|
-
- `PROGRESS: phase-7-teardown disbanding team` — after token-usage collection, immediately before reconciling stale members + `TeamDelete` (Teams mode only; see `_common-contract.md` "Run-end
|
|
99
|
+
- `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.
|
|
100
100
|
- `PROGRESS: complete final-report=<relative-path>` — final summary line, after all persistence.
|
|
101
101
|
|
|
102
102
|
These lines are the only structured signal the user has during a long run. Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a checkpoint because "the previous message already said that", and do NOT batch multiple checkpoints into one. Each line stands alone so the user (or any operator scraping stdout) can timestamp it externally.
|
|
@@ -36,15 +36,7 @@ profile document.
|
|
|
36
36
|
- okstra creates two kinds of tmux pane per run: (a) **worker-agent panes** the harness gives to dispatched subagents (titled `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`), and (b) **trace panes** the codex/gemini wrappers spawn (`<cli>-<role>-<pid>-tail`). Both accumulate across internal phases because each new phase dispatches a fresh worker batch and the prior panes are never reclaimed.
|
|
37
37
|
- When `<RUN_DIR>/state/lead-pane.id` is non-empty (the lead is in tmux), the lead MUST run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"` **immediately before** dispatching the next phase's workers — i.e. just before emitting each `PROGRESS: phase-5.5-convergence round=<N>` marker and just before `PROGRESS: phase-6-synthesis dispatching report-writer-worker`. This closes every prior-phase okstra pane (worker-agent + trace) for this run, while NEVER killing the lead's own pane.
|
|
38
38
|
- This is **automatic and silent** — NO user prompt. Report it in one short line (e.g. `이전 phase okstra pane 3개 정리`) and proceed. It is silent-skipped when the lead is not in tmux; the lead MUST NOT fabricate a synthetic pane list in that case.
|
|
39
|
-
-
|
|
40
|
-
- The lead created the worker team in Phase 3 (`TeamCreate(team_name: "okstra-<task-key>")`). Worker teammates are NOT reclaimed on their own — without an explicit teardown they linger in the FleetView roster across this and later runs in the session. The lead MUST release them once the run's work is done.
|
|
41
|
-
- This step is **automatic and silent** — NO user prompt (workers are idle sessions that have already delivered their results; there is nothing for the user to preserve). It runs only when team-state's `teamCreate.status == "ok"` (Teams mode was actually used); in the no-`team_name` fallback there is no team to delete, so silent-skip.
|
|
42
|
-
- Why a reconcile step exists: each worker clears its own `isActive` flag in `~/.claude/teams/<team>/config.json` when its `Agent()` dispatch returns, so by Phase 7 every worker is normally already inactive and `TeamDelete()` succeeds immediately. The one failure mode is a worker whose tmux pane died WITHOUT clearing the flag (e.g. killed mid-turn): it stays `isActive: true`, and `TeamDelete` then refuses the entire team with an "active members" error that no amount of re-sending `shutdown_request` can clear — the addressee is already gone. `okstra-team-reconcile.sh` deterministically flips exactly those dead-pane stale-active members to inactive (never a live-pane member, never the lead).
|
|
43
|
-
- Sequence (token-usage collection MUST already be complete — `TeamDelete` removes `~/.claude/teams/<team>/` + `~/.claude/tasks/<team>/` but NOT the `~/.claude/projects/` jsonls Phase 7 reads, yet the read MUST precede teardown):
|
|
44
|
-
1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "okstra-<task-key>"` exactly once. It flips dead-pane stale-active members to inactive, and no-ops when tmux is unavailable or nothing is stale. Do NOT loop it.
|
|
45
|
-
2. Call `TeamDelete()` — the single synchronization point for teardown. If it STILL errors with an active-members message, one worker pane is genuinely still live (rare at Phase 7, since every `Agent()` dispatch has already returned): send that one member a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field (a stringified payload is delivered as a plain message and the shutdown protocol never fires) — wait briefly, then retry `TeamDelete()` once and proceed regardless of the result. NEVER loop, never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address), and never let teardown block run completion once the work and final report already exist.
|
|
46
|
-
- Report it in one short line (e.g. `stale 멤버 1명 정리 + 팀 해제`, or just `worker 팀 해제` when nothing was stale) and proceed. Emit `PROGRESS: phase-7-teardown disbanding team` immediately before step 1.
|
|
47
|
-
- Phase wrap-up — okstra pane disposition (shared, MUST be the *last* step before returning control to the user):
|
|
39
|
+
- Phase wrap-up — okstra pane disposition (shared, runs AFTER Phase 7 persistence/token collection and BEFORE teammate teardown):
|
|
48
40
|
- At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/gemini trace pane). `okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` returns one tab-separated `<pane_id>\t<pane_title>` line per residual okstra pane (worker-agent + trace) for this run.
|
|
49
41
|
- When `<RUN_DIR>/state/lead-pane.id` is non-empty, after the final-report file has been written and the routing recommendation has been issued, the lead MUST run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` exactly once. The output lists every residual okstra pane (worker-agent + trace) for this run, never the lead's own pane.
|
|
50
42
|
- If the list is empty, skip the question — there is nothing to ask about (the phase-start resets above usually already cleared prior phases).
|
|
@@ -56,6 +48,18 @@ profile document.
|
|
|
56
48
|
- On `아니오` / `n` / `keep` → leave the panes intact; remind the user that they will be cleaned up automatically when Claude `/exit` fires the `SessionEnd` hook (`--reap`).
|
|
57
49
|
- The question MUST be a clean yes/no — do NOT offer "close some / keep some" partial answers, do NOT propose alternatives like "close only codex panes". The whole-set decision keeps the wrap-up predictable.
|
|
58
50
|
- This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). It is silent-skipped when `<RUN_DIR>/state/lead-pane.id` is empty/absent (lead running outside tmux); the lead MUST NOT fabricate a synthetic pane list in that case.
|
|
51
|
+
- Run-end teammate teardown (shared — MUST run AFTER the pane disposition question and BEFORE `PROGRESS: complete`):
|
|
52
|
+
- The lead created the worker team in Phase 3 (`TeamCreate(team_name: "okstra-<task-key>")`). Worker teammates are NOT reclaimed on their own — without an explicit teardown they linger in the FleetView roster across this and later runs in the session.
|
|
53
|
+
- This step applies only when team-state's `teamCreate.status == "ok"` (Teams mode was actually used). In the no-`team_name` fallback there is no team to delete, so silent-skip.
|
|
54
|
+
- After the pane disposition step above, the lead MUST ask the user whether to disband the worker teammates. This is a strict binary choice:
|
|
55
|
+
> worker teammate 팀 `okstra-<task-key>` 을 해제할까요?
|
|
56
|
+
> (예) 팀원 정리 / (아니오) 그대로 두기
|
|
57
|
+
- On `아니오` / `n` / `keep` → do not call `TeamDelete()`. Tell the user that the team will remain visible in FleetView/Teams and can be removed later from the Teams UI, or by asking the lead in this thread to clean up `okstra-<task-key>`.
|
|
58
|
+
- On `예` / `y` / `cleanup` → emit `PROGRESS: phase-7-teardown disbanding team`, then run the sequence below. Token-usage collection MUST already be complete — `TeamDelete` removes `~/.claude/teams/<team>/` + `~/.claude/tasks/<team>/` but NOT the `~/.claude/projects/` jsonls Phase 7 reads, yet the read MUST precede teardown.
|
|
59
|
+
1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "okstra-<task-key>"` exactly once. It flips dead-pane stale-active members to inactive, and no-ops when tmux is unavailable or nothing is stale. Do NOT loop it.
|
|
60
|
+
2. Call `TeamDelete()` — the single synchronization point for teammate teardown. If it errors with an active-members message, send each named active member a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field — wait briefly, run `okstra-team-reconcile.sh` one more time, retry `TeamDelete()` once, and proceed regardless of the result. NEVER loop and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
|
|
61
|
+
- If `TeamDelete()` is unavailable in the current host, or teardown still fails after the single retry, do not pretend cleanup succeeded. Report the exact residual action instead: `Teams/FleetView 에서 team okstra-<task-key> 삭제`, and if tmux panes were kept earlier also show the pane command `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"`.
|
|
62
|
+
- Report successful teardown in one short line (e.g. `worker teammate 팀 해제`, or `stale 멤버 1명 reconcile 후 팀 해제`) and proceed to the final `PROGRESS: complete ...` line.
|
|
59
63
|
- Brief handoff contract (shared — applies whenever the run consumes a task brief produced by `okstra-brief`):
|
|
60
64
|
- the brief is a **pre-discovery artifact**: it converts a domain-reporter's words (non-expert *or* developer) into expert-consumable form so this and later phases can run with zero fill-in questions to the operator. The brief is **not** authoritative on solution decisions; it is authoritative on the reporter's intent.
|
|
61
65
|
- **Reporter confirmation precondition (BLOCKING)**: the brief's frontmatter carries `reporter-confirmations: <complete | partial | pending | skipped>` set by `okstra-brief` Step 6.5. Every phase that consumes the brief MUST read this field before doing analysis. The handling matrix is:
|
|
@@ -78,14 +82,21 @@ profile document.
|
|
|
78
82
|
- Any decision in this run that contradicts the brief's `Source Material` must be raised back to the reporter via a `Clarification Items` row; it must NOT be silently overridden. Disagreement with the reporter is allowed only after the row is resolved.
|
|
79
83
|
- This contract is the single authority on brief consumption. Phase-specific addenda may *tighten* these rules but may not relax them.
|
|
80
84
|
- Clarification request policy (shared — applies whenever a profile uses `## 1. Clarification Items`):
|
|
81
|
-
- **Canonical column schema (SSOT — must match `templates/reports/final-report.template.md` §1 exactly):** every `## 1. Clarification Items` table has exactly these
|
|
82
|
-
`|
|
|
83
|
-
|
|
85
|
+
- **Canonical column schema (SSOT — must match `templates/reports/final-report.template.md` §1 exactly):** every `## 1. Clarification Items` table has exactly these 4 columns, in this order:
|
|
86
|
+
`| <record-meta> | Statement | Expected form | User input |` (the first header is the i18n `columns.recordMeta` label — `항목` / `Record`).
|
|
87
|
+
The five short fields (ID, Ticket ID, Kind, Blocks, Status) are stacked inside the single record-meta cell, one per line separated by `<br>`, in this fixed order (mirrors the §2.1 Primary-Evidence meta column):
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
**<ID>**<br>Ticket: `<TicketId>`<br>Kind: `<Kind>`<br>Blocks: `<Blocks>`<br>Status: <Status>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The labels `Ticket:` / `Kind:` / `Blocks:` / `Status:` stay English in every locale so the approval-gate parser (`clarification_items.parse_meta_cell`) reads them regardless of report language.
|
|
94
|
+
Profile-specific addenda may tighten cell content but MUST NOT add, remove, rename, or reorder columns, nor change the meta-cell field order. The `ID` is `C-NNN` (3-digit zero-padded), the `Status` ∈ `{open, answered, resolved, obsolete}`, and the `Kind` / `Blocks` legal values are listed below.
|
|
84
95
|
- section 1 is a **single unified table** per `final-report-template.md`. Every clarification item — whether the user must attach a file, choose between options, or supply a single number/path — is one row of that table. Do not split it into sub-sections (`1.1 추가 자료 요청` / `1.2 사용자 확인 질문` / `5.5.9 Open Questions` are removed and the validator fails reports that reintroduce them), do not create a parallel table elsewhere in the report, and do not duplicate the same item into an approval block or any other section.
|
|
85
96
|
- each row's `Kind` column picks one of `{material, decision, data-point}`: `material` for files / snapshots / logs / screenshots the user must attach (the `User input` cell will hold a path or URL); `decision` for choices and yes/no confirmations only the user can make; `data-point` for a single number, ID, date, or short string the user can answer inline. Items that mix "yes/no + file path if yes" are one row of `Kind=material` with the combined expectation written into `Expected form`.
|
|
86
97
|
- each row's `Blocks` column picks one of `{approval, next-phase, none}`. `approval` is reserved for items that gate an approval action, especially the `implementation-planning` `approved:` frontmatter flip; outside `implementation-planning`, unresolved brief reporter-confirmation rows use `next-phase` instead. `next-phase` blocks the next run from starting cleanly. `none` is informational/audit-only.
|
|
87
98
|
- write every entry in full, descriptive sentences that a non-developer can act on without further context. Avoid abbreviations and internal jargon. The `Statement` cell must state *what* is needed, *why* the answer / attachment changes the next step, and (for `material`) *where* the user can find it and *where* to place it. The `Expected form` cell must state the answer shape (예/아니오, 보기 중 하나, 숫자/날짜, 파일 경로, 짧은 서술 등); supply concrete option choices when applicable.
|
|
88
|
-
- if a phase requires a recommended answer, alternatives, or an evidence-check note, encode it inside the existing
|
|
99
|
+
- if a phase requires a recommended answer, alternatives, or an evidence-check note, encode it inside the existing 4-column schema: put evidence notes in `Statement` as `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>`, and put recommendations/options in `Expected form` as `Recommended: <answer> — <rationale>; Alternatives: <options>`. Do not add `Recommended`, `Evidence`, `Alternatives`, or `evidence-checked` columns, and do not break the merged record-meta cell back into separate columns.
|
|
89
100
|
- the same `final-report.md` file is the canonical artifact carried into the next run; the user appends answers inline before rerunning. The preferred turn-around is `scripts/okstra.sh --resume-clarification --task-key <project-id>:<task-group>:<task-id>` (opens the latest report in `$EDITOR`, then auto-reruns the same phase with `--clarification-response` carry-in). The lower-level form `--clarification-response <path>` remains available for scripted runs.
|
|
90
101
|
- if a clarification response was carried in for this run, render the conditional `## 0. Clarification Response Carried In From Previous Run` section (the template's `RENDER_IF` guard activates it), walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile each one against new evidence, and update its `Status` to `resolved` or `obsolete` before issuing the next decision/verdict. When no carry-in path was provided, omit the `## 0.` heading entirely — the validator fails reports that emit an empty Section 0 stub (e.g. "No prior clarification response was provided for this run.").
|
|
91
102
|
- Verdict Card (shared — applies to every final-report regardless of profile):
|
|
@@ -26,7 +26,15 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Optional
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# The final-report renderer (render_final_report.py:_inject_anchors) appends a
|
|
30
|
+
# ` <a id="slug"></a>` scroll anchor to every heading. The §1 slice MUST tolerate
|
|
31
|
+
# that trailing anchor — otherwise `_section_1_slice` fails on every *rendered*
|
|
32
|
+
# report, parse returns None, and the Blocks=approval approval gate is silently
|
|
33
|
+
# bypassed (run.py:_validate_approved_plan soft-passes None).
|
|
34
|
+
SECTION_HEADING_PATTERN = re.compile(
|
|
35
|
+
r'^##\s+1\.\s+Clarification Items\s*(?:<a id="[^"]+"></a>\s*)?$',
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
30
38
|
NEXT_TOP_LEVEL_HEADING_PATTERN = re.compile(r"^##\s+(?!1\.)", re.MULTILINE)
|
|
31
39
|
|
|
32
40
|
|
|
@@ -97,10 +105,55 @@ def _section_1_slice(report_text: str) -> Optional[str]:
|
|
|
97
105
|
return rest[: end_match.start()] if end_match else rest
|
|
98
106
|
|
|
99
107
|
|
|
108
|
+
_META_ID_RE = re.compile(r"([A-Za-z][A-Za-z0-9]*-\d+)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _meta_field(cell: str, key: str) -> str:
|
|
112
|
+
"""Extract a ``<key>: value`` field from a stacked §1 meta cell.
|
|
113
|
+
|
|
114
|
+
The §1 table collapses the short columns into one ``<br>``-delimited
|
|
115
|
+
metadata cell (``**C-101**<br>Ticket: `DEV-1`<br>Kind: `decision`<br>
|
|
116
|
+
Blocks: `approval`<br>Status: open``). Values may be backtick-wrapped.
|
|
117
|
+
"""
|
|
118
|
+
m = re.search(
|
|
119
|
+
rf"{re.escape(key)}:\s*`?\s*([^`<|]+?)\s*`?\s*(?:<br\s*/?>|$)",
|
|
120
|
+
cell,
|
|
121
|
+
re.IGNORECASE,
|
|
122
|
+
)
|
|
123
|
+
return m.group(1).strip() if m else ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _meta_id(cell: str) -> str:
|
|
127
|
+
"""The bold headline ID of a §1 meta cell (the part before the first <br>)."""
|
|
128
|
+
headline = re.split(r"<br\s*/?>", cell, maxsplit=1)[0]
|
|
129
|
+
m = _META_ID_RE.search(headline)
|
|
130
|
+
return m.group(1) if m else ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_meta_cell(cell: str) -> Optional[ClarificationItem]:
|
|
134
|
+
"""Parse one §1 stacked meta cell into a ``ClarificationItem``, or ``None``
|
|
135
|
+
when the cell is not a §1 meta cell (no ``Blocks:``/``Status:`` markers —
|
|
136
|
+
e.g. a header or an unrelated table). Shared by the approval-gate parser
|
|
137
|
+
and the HTML view's form-attach pass so both read the cell identically.
|
|
138
|
+
"""
|
|
139
|
+
raw_blocks = _meta_field(cell, "Blocks")
|
|
140
|
+
raw_status = _meta_field(cell, "Status")
|
|
141
|
+
if not (raw_blocks and raw_status):
|
|
142
|
+
return None
|
|
143
|
+
return ClarificationItem(
|
|
144
|
+
row_id=_meta_id(cell),
|
|
145
|
+
kind=_meta_field(cell, "Kind").lower(),
|
|
146
|
+
blocks=raw_blocks.lower(),
|
|
147
|
+
status=raw_status.lower(),
|
|
148
|
+
raw_blocks=raw_blocks,
|
|
149
|
+
raw_status=raw_status,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
100
153
|
def parse_clarification_items(report_text: str) -> Optional[list[ClarificationItem]]:
|
|
101
|
-
"""Return the list of §1 rows. ``None`` means "no
|
|
102
|
-
|
|
103
|
-
|
|
154
|
+
"""Return the list of §1 rows. ``None`` means "no §1 meta table detected"
|
|
155
|
+
(legacy report or missing section) — caller must NOT treat that as "table
|
|
156
|
+
is empty".
|
|
104
157
|
|
|
105
158
|
An empty list ``[]`` means "table exists but has no data rows" (e.g.,
|
|
106
159
|
just the ``- 추가 정보 요청 없음.`` placeholder); that IS a confident
|
|
@@ -111,30 +164,21 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
|
|
|
111
164
|
return None
|
|
112
165
|
|
|
113
166
|
lines = section.splitlines()
|
|
114
|
-
# Locate the
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
167
|
+
# Locate the §1 data table by its header. The merged-meta layout collapses
|
|
168
|
+
# ID/Ticket/Kind/Blocks/Status into one metadata cell and keeps the
|
|
169
|
+
# English `Statement` + `User input` columns; detect on those two (any
|
|
170
|
+
# other table — intro, legacy 5.1/5.2 — is rejected by returning None).
|
|
118
171
|
header_idx = -1
|
|
119
|
-
headers: list[str] = []
|
|
120
172
|
for idx, line in enumerate(lines):
|
|
121
173
|
if not line.lstrip().startswith("|"):
|
|
122
174
|
continue
|
|
123
175
|
cells = [c.lower() for c in _split_pipe_row(line)]
|
|
124
|
-
if "
|
|
176
|
+
if "user input" in cells and any(c.startswith("statement") for c in cells):
|
|
125
177
|
header_idx = idx
|
|
126
|
-
headers = cells
|
|
127
178
|
break
|
|
128
179
|
if header_idx < 0:
|
|
129
|
-
# Section heading present but no Blocks/Status header — legacy
|
|
130
|
-
# layout or schema not yet adopted.
|
|
131
180
|
return None
|
|
132
181
|
|
|
133
|
-
blocks_col = headers.index("blocks")
|
|
134
|
-
status_col = headers.index("status")
|
|
135
|
-
id_col = headers.index("id") if "id" in headers else 0
|
|
136
|
-
kind_col = headers.index("kind") if "kind" in headers else None
|
|
137
|
-
|
|
138
182
|
items: list[ClarificationItem] = []
|
|
139
183
|
body_started = False
|
|
140
184
|
for line in lines[header_idx + 1:]:
|
|
@@ -146,30 +190,13 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
|
|
|
146
190
|
body_started = True
|
|
147
191
|
continue
|
|
148
192
|
if not body_started:
|
|
149
|
-
# A second header (unlikely) or a malformed row before the
|
|
150
|
-
# separator — skip.
|
|
151
193
|
continue
|
|
152
194
|
cells = _split_pipe_row(line)
|
|
153
|
-
if
|
|
154
|
-
# malformed row; skip rather than crash
|
|
195
|
+
if not cells:
|
|
155
196
|
continue
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
kind = cells[kind_col] if kind_col is not None and kind_col < len(cells) else ""
|
|
160
|
-
# Treat template-placeholder rows (e.g. literal ``C-001`` with
|
|
161
|
-
# angle-bracket sample text) as still-real rows but caller can
|
|
162
|
-
# filter on raw_id == "C-001" if needed.
|
|
163
|
-
items.append(
|
|
164
|
-
ClarificationItem(
|
|
165
|
-
row_id=row_id,
|
|
166
|
-
kind=kind.lower(),
|
|
167
|
-
blocks=raw_blocks.lower(),
|
|
168
|
-
status=raw_status.lower(),
|
|
169
|
-
raw_blocks=raw_blocks,
|
|
170
|
-
raw_status=raw_status,
|
|
171
|
-
)
|
|
172
|
-
)
|
|
197
|
+
item = parse_meta_cell(cells[0])
|
|
198
|
+
if item is not None:
|
|
199
|
+
items.append(item)
|
|
173
200
|
return items
|
|
174
201
|
|
|
175
202
|
|
|
@@ -191,3 +218,25 @@ def unresolved_approval_blockers(report_text: str) -> Optional[list[Clarificatio
|
|
|
191
218
|
it for it in items
|
|
192
219
|
if it.blocks == "approval" and it.status in UNRESOLVED_STATUSES
|
|
193
220
|
]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
|
|
224
|
+
# "§1 헤딩은 있는데 형태가 어긋나 파싱에 실패" 한 상태다. trailing 부분을 보지
|
|
225
|
+
# 않으므로 앵커 변형·수동 편집·미래 렌더 변경 어디서든 헤딩의 존재만 잡는다.
|
|
226
|
+
_LOOSE_SECTION_1_RE = re.compile(r"^##\s+1\.\s+Clarification Items\b", re.MULTILINE)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def section_1_present_but_unparsed(report_text: str) -> bool:
|
|
230
|
+
"""§1 헤딩이 느슨 탐지엔 잡히지만 엄격 SECTION_HEADING_PATTERN 에는 매칭하지
|
|
231
|
+
못하는 경우 True — 헤딩 형태가 어긋나(앵커·포맷 drift) §1 슬라이스 자체가
|
|
232
|
+
실패하는 상태다.
|
|
233
|
+
|
|
234
|
+
이때 ``_section_1_slice`` 가 None 을 반환해 parse 가 통째로 None 이 되고 승인
|
|
235
|
+
게이트가 "schema 없음 → soft-pass" 로 조용히 열린다. §1 앵커 버그가 정확히 이
|
|
236
|
+
메커니즘으로 터졌다. 헤딩 자체가 없는 legacy 리포트(둘 다 불매칭)와, 엄격
|
|
237
|
+
매칭에 성공하는 정상 헤딩(테이블이 없는 emptyState placeholder 포함)은 False —
|
|
238
|
+
placeholder 는 헤딩이 멀쩡하므로 fail-closed 로 오인하지 않는다. 정규식만 넓혀 온
|
|
239
|
+
과거 수정과 달리, 이 판별은 "헤딩 형태 drift" 자체를 차단해 재발 클래스를 닫는다."""
|
|
240
|
+
if SECTION_HEADING_PATTERN.search(report_text):
|
|
241
|
+
return False
|
|
242
|
+
return bool(_LOOSE_SECTION_1_RE.search(report_text))
|
|
@@ -50,6 +50,7 @@ from jinja2 import ChainableUndefined, Environment, FileSystemLoader
|
|
|
50
50
|
|
|
51
51
|
from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
|
+
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
|
|
@@ -351,6 +352,44 @@ def _enforce_schema(data: dict) -> None:
|
|
|
351
352
|
)
|
|
352
353
|
|
|
353
354
|
|
|
355
|
+
# 일반 alias('opus'/'sonnet'/'haiku')가 런타임에 해소되는, okstra 가 아는 최신
|
|
356
|
+
# 구체버전 — final-report '표시 전용'. 실행 인자(execution value)는 바꾸지 않으며
|
|
357
|
+
# (여전히 'opus' 가 `claude --model` 로 전달됨), 이 매핑은 보고서에서 "어느
|
|
358
|
+
# 구체버전 계열로 돌았는지" 가독성만 준다. CLI 가 실제 고른 버전과 다를 수
|
|
359
|
+
# 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
|
|
360
|
+
_DISPLAY_CONCRETE_CLAUDE = {
|
|
361
|
+
"opus": "claude-opus-4-7",
|
|
362
|
+
"sonnet": "claude-sonnet-4-6",
|
|
363
|
+
"haiku": "claude-haiku-4-5",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _model_detail(display: Any) -> str:
|
|
368
|
+
"""모델 display alias(예: 'opus')를 'opus (claude-opus-4-7)' 형태로 상세화한다.
|
|
369
|
+
|
|
370
|
+
lead·report-writer 헤더 모델은 항상 claude provider 다. 구체 alias('opus-4-7'
|
|
371
|
+
등)는 claude 매핑의 실행 ID 를, 일반 alias('opus' 등)는 표시 전용
|
|
372
|
+
`_DISPLAY_CONCRETE_CLAUDE` 의 권장 구체버전을 병기한다. 어느 쪽에도 없는 값
|
|
373
|
+
(이미 실행 ID 이거나 'default' 등)은 원본을 그대로 돌려준다. 바깥 백틱은
|
|
374
|
+
템플릿이 감싸므로 여기서는 넣지 않는다."""
|
|
375
|
+
text = str(display or "").strip()
|
|
376
|
+
if not text:
|
|
377
|
+
return text
|
|
378
|
+
try:
|
|
379
|
+
meta = resolve_model_metadata(
|
|
380
|
+
provider="claude", raw_value=text,
|
|
381
|
+
default_display=text, default_execution="",
|
|
382
|
+
)
|
|
383
|
+
except UnknownModelError:
|
|
384
|
+
meta = None
|
|
385
|
+
if meta and meta.execution and meta.execution != text:
|
|
386
|
+
return f"{text} ({meta.execution})"
|
|
387
|
+
concrete = _DISPLAY_CONCRETE_CLAUDE.get(text.lower())
|
|
388
|
+
if concrete:
|
|
389
|
+
return f"{text} ({concrete})"
|
|
390
|
+
return text
|
|
391
|
+
|
|
392
|
+
|
|
354
393
|
def _build_environment(template_dir: Path) -> Environment:
|
|
355
394
|
# ChainableUndefined lets optional fields (e.g.
|
|
356
395
|
# ``clarificationCarryIn``, ``ticketCoverage.omit``) silently evaluate
|
|
@@ -370,6 +409,7 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
370
409
|
env.filters["format_duration_ms"] = _format_duration_ms
|
|
371
410
|
env.filters["yaml_scalar"] = _yaml_scalar
|
|
372
411
|
env.filters["yaml_inline_list"] = _yaml_inline_list
|
|
412
|
+
env.filters["model_detail"] = _model_detail
|
|
373
413
|
return env
|
|
374
414
|
|
|
375
415
|
|
|
@@ -60,6 +60,7 @@ from .clarification_items import (
|
|
|
60
60
|
_section_1_slice,
|
|
61
61
|
_split_pipe_row,
|
|
62
62
|
parse_clarification_items,
|
|
63
|
+
parse_meta_cell,
|
|
63
64
|
)
|
|
64
65
|
|
|
65
66
|
|
|
@@ -319,16 +320,16 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
319
320
|
rows.append(_split_pipe_row(lines[i]))
|
|
320
321
|
i += 1
|
|
321
322
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
# §1 Clarification Items is the only interactive table. Its short columns
|
|
324
|
+
# are collapsed into one stacked meta cell (`**C-101**<br>Ticket: …<br>
|
|
325
|
+
# Kind: …<br>Blocks: …<br>Status: …`) in the markdown; the HTML view
|
|
326
|
+
# re-attaches a form widget to the `User input` column by re-parsing that
|
|
327
|
+
# meta cell (the SAME parser the approval gate uses).
|
|
326
328
|
is_clarification_table = (
|
|
327
329
|
not _section_forbids_form(section_path)
|
|
328
330
|
and any("Clarification Items" in h for h in section_path)
|
|
329
|
-
and "
|
|
330
|
-
and "
|
|
331
|
-
and "Status" in header_cells
|
|
331
|
+
and "User input" in header_cells
|
|
332
|
+
and any(h.startswith("Statement") for h in header_cells)
|
|
332
333
|
)
|
|
333
334
|
|
|
334
335
|
narrow_cols = _narrow_columns(header_cells, rows)
|
|
@@ -348,47 +349,38 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
348
349
|
+ "</tr></thead>"
|
|
349
350
|
)
|
|
350
351
|
|
|
351
|
-
body_rows: list[str] = []
|
|
352
|
-
id_col = header_cells.index("ID") if "ID" in header_cells else -1
|
|
353
|
-
kind_col = header_cells.index("Kind") if "Kind" in header_cells else -1
|
|
354
|
-
status_col = header_cells.index("Status") if "Status" in header_cells else -1
|
|
355
352
|
statement_col = next(
|
|
356
|
-
(
|
|
353
|
+
(j for j, h in enumerate(header_cells) if h.startswith("Statement")),
|
|
357
354
|
-1,
|
|
358
355
|
)
|
|
359
356
|
user_input_col = (
|
|
360
357
|
header_cells.index("User input") if "User input" in header_cells else -1
|
|
361
358
|
)
|
|
359
|
+
body_rows: list[str] = []
|
|
362
360
|
for row in rows:
|
|
361
|
+
meta = parse_meta_cell(row[0]) if (is_clarification_table and row) else None
|
|
363
362
|
if (
|
|
364
|
-
|
|
365
|
-
and
|
|
366
|
-
and id_col < len(row)
|
|
367
|
-
and re.fullmatch(r"C-\d+", row[id_col])
|
|
363
|
+
meta is not None
|
|
364
|
+
and re.fullmatch(r"C-\d+", meta.row_id)
|
|
368
365
|
and user_input_col >= 0
|
|
369
366
|
):
|
|
370
|
-
response_id = row[id_col]
|
|
371
|
-
kind = row[kind_col] if 0 <= kind_col < len(row) else ""
|
|
372
|
-
status = row[status_col] if 0 <= status_col < len(row) else ""
|
|
373
367
|
statement = (
|
|
374
|
-
row[statement_col]
|
|
375
|
-
if 0 <= statement_col < len(row)
|
|
376
|
-
else ""
|
|
368
|
+
row[statement_col] if 0 <= statement_col < len(row) else ""
|
|
377
369
|
)
|
|
378
370
|
cells_html: list[str] = []
|
|
379
371
|
for idx, cell in enumerate(row):
|
|
380
372
|
if idx == user_input_col:
|
|
381
373
|
cells_html.append(
|
|
382
|
-
f"<td>{_form_control(
|
|
374
|
+
f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement)}</td>"
|
|
383
375
|
)
|
|
384
376
|
else:
|
|
385
377
|
cells_html.append(
|
|
386
378
|
f"<td{_col_class(idx, narrow_cols)}>{_inline(cell)}</td>"
|
|
387
379
|
)
|
|
388
380
|
body_rows.append(
|
|
389
|
-
f'<tr data-response-id="{html.escape(
|
|
390
|
-
f'data-kind="{html.escape(kind)}" '
|
|
391
|
-
f'data-status="{html.escape(status)}">'
|
|
381
|
+
f'<tr data-response-id="{html.escape(meta.row_id)}" '
|
|
382
|
+
f'data-kind="{html.escape(meta.kind)}" '
|
|
383
|
+
f'data-status="{html.escape(meta.status)}">'
|
|
392
384
|
+ "".join(cells_html)
|
|
393
385
|
+ "</tr>"
|
|
394
386
|
)
|
|
@@ -406,155 +398,6 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
406
398
|
return f"<table>{head}{body}</table>", i - start
|
|
407
399
|
|
|
408
400
|
|
|
409
|
-
@dataclass(frozen=True)
|
|
410
|
-
class _GroupedSpec:
|
|
411
|
-
"""Plan for rendering a wide table as a compact grouped layout: the
|
|
412
|
-
short columns (``group_cols``) collapse into one stacked ``key:
|
|
413
|
-
value`` metadata cell led by ``headline_col``; the long columns
|
|
414
|
-
(``wide_cols``) each keep their own min-width column.
|
|
415
|
-
|
|
416
|
-
``kind == "clarification"`` additionally re-attaches the §1 form
|
|
417
|
-
widget to the ``user_input_col`` cell and the ``data-*`` row attrs."""
|
|
418
|
-
headline_col: int
|
|
419
|
-
group_cols: tuple[int, ...]
|
|
420
|
-
wide_cols: tuple[int, ...]
|
|
421
|
-
kind: str # "plain" | "clarification"
|
|
422
|
-
id_col: int = -1
|
|
423
|
-
kind_col: int = -1
|
|
424
|
-
status_col: int = -1
|
|
425
|
-
statement_col: int = -1
|
|
426
|
-
user_input_col: int = -1
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def _grouped_table_spec(
|
|
430
|
-
header_cells: list[str], section_path: list[str]
|
|
431
|
-
) -> Optional[_GroupedSpec]:
|
|
432
|
-
"""Only §1 Clarification Items is grouped in the HTML view (it keeps the
|
|
433
|
-
interactive form and stays flat in the .md). All other narrative tables are
|
|
434
|
-
already rendered compactly by the template, so no grouping is applied here."""
|
|
435
|
-
norm = [h.strip() for h in header_cells]
|
|
436
|
-
|
|
437
|
-
def _spec(headline: int, wide: tuple[int, ...], **kw) -> _GroupedSpec:
|
|
438
|
-
wide_set = set(wide)
|
|
439
|
-
group = tuple(c for c in range(len(norm)) if c != headline and c not in wide_set)
|
|
440
|
-
return _GroupedSpec(headline_col=headline, group_cols=group, wide_cols=wide, **kw)
|
|
441
|
-
|
|
442
|
-
# §1 Clarification Items — keep the interactive form, and widen the three
|
|
443
|
-
# long-prose columns (Expected form is prose too, not a code column).
|
|
444
|
-
if (
|
|
445
|
-
any("Clarification Items" in h for h in section_path)
|
|
446
|
-
and not _section_forbids_form(section_path)
|
|
447
|
-
and "ID" in norm
|
|
448
|
-
and "User input" in norm
|
|
449
|
-
and any(h.startswith("Statement") for h in norm)
|
|
450
|
-
):
|
|
451
|
-
statement_col = next(i for i, h in enumerate(norm) if h.startswith("Statement"))
|
|
452
|
-
user_input_col = norm.index("User input")
|
|
453
|
-
expected_col = next(
|
|
454
|
-
(i for i, h in enumerate(norm) if h.startswith("Expected form")), -1
|
|
455
|
-
)
|
|
456
|
-
wide_cols = tuple(
|
|
457
|
-
c for c in (expected_col, statement_col, user_input_col) if c >= 0
|
|
458
|
-
)
|
|
459
|
-
return _spec(
|
|
460
|
-
norm.index("ID"),
|
|
461
|
-
wide_cols,
|
|
462
|
-
kind="clarification",
|
|
463
|
-
id_col=norm.index("ID"),
|
|
464
|
-
kind_col=norm.index("Kind") if "Kind" in norm else -1,
|
|
465
|
-
status_col=norm.index("Status") if "Status" in norm else -1,
|
|
466
|
-
statement_col=statement_col,
|
|
467
|
-
user_input_col=user_input_col,
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
return None
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def _grouped_meta_cell(
|
|
474
|
-
header_cells: list[str], row: list[str], spec: _GroupedSpec
|
|
475
|
-
) -> str:
|
|
476
|
-
"""The leading metadata ``<td>``: a bold headline (``headline_col``)
|
|
477
|
-
above one ``key: value`` line per collapsed short column."""
|
|
478
|
-
headline = row[spec.headline_col] if spec.headline_col < len(row) else ""
|
|
479
|
-
fields = "".join(
|
|
480
|
-
'<div class="grp-field">'
|
|
481
|
-
f'<span class="grp-key">{_inline(header_cells[col])}</span>'
|
|
482
|
-
f'<span class="grp-val">{_inline(row[col] if col < len(row) else "")}</span>'
|
|
483
|
-
"</div>"
|
|
484
|
-
for col in spec.group_cols
|
|
485
|
-
)
|
|
486
|
-
return (
|
|
487
|
-
'<td class="grp-meta">'
|
|
488
|
-
f'<div class="grp-headline">{_inline(headline)}</div>'
|
|
489
|
-
f"{fields}</td>"
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def _grouped_clarification_row(
|
|
494
|
-
row: list[str], spec: _GroupedSpec
|
|
495
|
-
) -> tuple[str, str]:
|
|
496
|
-
"""Return ``(tr_attrs, wide_cells_html)`` for one §1 row, re-attaching
|
|
497
|
-
the form widget + ``data-*`` attrs to ``C-\\d+`` rows exactly as the
|
|
498
|
-
non-grouped path does."""
|
|
499
|
-
rid = row[spec.id_col] if 0 <= spec.id_col < len(row) else ""
|
|
500
|
-
is_form_row = bool(re.fullmatch(r"C-\d+", rid)) and spec.user_input_col >= 0
|
|
501
|
-
kind = row[spec.kind_col] if is_form_row and 0 <= spec.kind_col < len(row) else ""
|
|
502
|
-
status = row[spec.status_col] if is_form_row and 0 <= spec.status_col < len(row) else ""
|
|
503
|
-
statement = (
|
|
504
|
-
row[spec.statement_col]
|
|
505
|
-
if is_form_row and 0 <= spec.statement_col < len(row)
|
|
506
|
-
else ""
|
|
507
|
-
)
|
|
508
|
-
tr_attrs = (
|
|
509
|
-
f' data-response-id="{html.escape(rid)}" '
|
|
510
|
-
f'data-kind="{html.escape(kind)}" '
|
|
511
|
-
f'data-status="{html.escape(status)}"'
|
|
512
|
-
if is_form_row
|
|
513
|
-
else ""
|
|
514
|
-
)
|
|
515
|
-
cells: list[str] = []
|
|
516
|
-
for col in spec.wide_cols:
|
|
517
|
-
value = row[col] if col < len(row) else ""
|
|
518
|
-
if is_form_row and col == spec.user_input_col:
|
|
519
|
-
cells.append(
|
|
520
|
-
f'<td class="grp-wide grp-form">'
|
|
521
|
-
f"{_form_control(rid, kind, status, value, statement)}</td>"
|
|
522
|
-
)
|
|
523
|
-
else:
|
|
524
|
-
cells.append(f'<td class="grp-wide">{_inline(value)}</td>')
|
|
525
|
-
return tr_attrs, "".join(cells)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
def _emit_grouped_table(
|
|
529
|
-
header_cells: list[str], rows: list[list[str]], spec: _GroupedSpec
|
|
530
|
-
) -> str:
|
|
531
|
-
"""Render a wide table in the compact grouped layout described by
|
|
532
|
-
``spec`` — one metadata cell plus the min-width long columns."""
|
|
533
|
-
head = (
|
|
534
|
-
"<thead><tr>"
|
|
535
|
-
f"<th>{_inline(header_cells[spec.headline_col])}</th>"
|
|
536
|
-
+ "".join(
|
|
537
|
-
f'<th class="grp-wide">{_inline(header_cells[col])}</th>'
|
|
538
|
-
for col in spec.wide_cols
|
|
539
|
-
)
|
|
540
|
-
+ "</tr></thead>"
|
|
541
|
-
)
|
|
542
|
-
body_rows: list[str] = []
|
|
543
|
-
for row in rows:
|
|
544
|
-
meta = _grouped_meta_cell(header_cells, row, spec)
|
|
545
|
-
if spec.kind == "clarification":
|
|
546
|
-
tr_attrs, wide_cells = _grouped_clarification_row(row, spec)
|
|
547
|
-
else:
|
|
548
|
-
tr_attrs = ""
|
|
549
|
-
wide_cells = "".join(
|
|
550
|
-
f'<td class="grp-wide">{_inline(row[col] if col < len(row) else "")}</td>'
|
|
551
|
-
for col in spec.wide_cols
|
|
552
|
-
)
|
|
553
|
-
body_rows.append(f"<tr{tr_attrs}>{meta}{wide_cells}</tr>")
|
|
554
|
-
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
|
555
|
-
return f'<table class="grouped-table">{head}{body}</table>'
|
|
556
|
-
|
|
557
|
-
|
|
558
401
|
_ENUM_LETTERS = "abcde"
|
|
559
402
|
_ENUM_CUE_WORDS: tuple[str, ...] = (
|
|
560
403
|
"권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
|
|
@@ -28,7 +28,10 @@ from pathlib import Path
|
|
|
28
28
|
|
|
29
29
|
from okstra_project import project_json_path, upsert_project_json
|
|
30
30
|
from .analysis_packet import build_analysis_packet
|
|
31
|
-
from .clarification_items import
|
|
31
|
+
from .clarification_items import (
|
|
32
|
+
section_1_present_but_unparsed,
|
|
33
|
+
unresolved_approval_blockers,
|
|
34
|
+
)
|
|
32
35
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
33
36
|
from .material import (
|
|
34
37
|
build_analysis_material,
|
|
@@ -341,6 +344,14 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
341
344
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
342
345
|
# Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
|
|
343
346
|
blockers = unresolved_approval_blockers(body)
|
|
347
|
+
if blockers is None and section_1_present_but_unparsed(body):
|
|
348
|
+
raise PrepareError(
|
|
349
|
+
f"approved plan has a `## 1. Clarification Items` heading but its table "
|
|
350
|
+
f"could not be parsed (heading/anchor/format drift): {path}\n"
|
|
351
|
+
" the approval gate cannot confirm there are no unresolved "
|
|
352
|
+
"`Blocks=approval` rows, so it refuses to soft-pass. Re-render the report "
|
|
353
|
+
"with scripts/okstra-render-final-report.py so §1 matches the schema, then retry."
|
|
354
|
+
)
|
|
344
355
|
if blockers:
|
|
345
356
|
lines = [
|
|
346
357
|
f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
|
|
@@ -31,7 +31,10 @@ from okstra_ctl.models import (
|
|
|
31
31
|
UnknownModelError,
|
|
32
32
|
resolve_model_metadata,
|
|
33
33
|
)
|
|
34
|
-
from okstra_ctl.clarification_items import
|
|
34
|
+
from okstra_ctl.clarification_items import (
|
|
35
|
+
section_1_present_but_unparsed,
|
|
36
|
+
unresolved_approval_blockers,
|
|
37
|
+
)
|
|
35
38
|
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
36
39
|
from okstra_ctl.run import (
|
|
37
40
|
APPROVED_FRONTMATTER_PATTERN,
|
|
@@ -455,6 +458,13 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
|
455
458
|
# frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
|
|
456
459
|
# 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
|
|
457
460
|
blockers = unresolved_approval_blockers(body)
|
|
461
|
+
if blockers is None and section_1_present_but_unparsed(body):
|
|
462
|
+
raise WizardError(
|
|
463
|
+
f"approved plan has a `## 1. Clarification Items` heading but its table "
|
|
464
|
+
f"could not be parsed (heading/anchor/format drift): {p}\n"
|
|
465
|
+
" the approval gate cannot confirm there are no unresolved "
|
|
466
|
+
"`Blocks=approval` rows — re-render the report so §1 matches the schema."
|
|
467
|
+
)
|
|
458
468
|
if blockers:
|
|
459
469
|
lines = [
|
|
460
470
|
f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
|
|
@@ -716,7 +726,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
716
726
|
latest_key = latest.get("taskKey") or ""
|
|
717
727
|
latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
|
|
718
728
|
options: list[Option] = []
|
|
719
|
-
for entry in tasks[:
|
|
729
|
+
for entry in tasks[:16]:
|
|
720
730
|
key = entry.get("taskKey") or ""
|
|
721
731
|
ttype = entry.get("taskType") or ""
|
|
722
732
|
phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
|
|
@@ -985,11 +995,31 @@ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
|
|
|
985
995
|
return f"task-id: {state.task_id}"
|
|
986
996
|
|
|
987
997
|
|
|
998
|
+
def _existing_task_next_phase(state: WizardState) -> str:
|
|
999
|
+
"""새 task 로 시작했더라도 입력한 task-key 가 이미 존재하면(=사실상 이어가기)
|
|
1000
|
+
그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
|
|
1001
|
+
|
|
1002
|
+
사용자가 picker 에서 기존 task-key 를 고르지 않고 new-task 흐름으로 같은
|
|
1003
|
+
task-group/task-id 를 다시 입력한 경우에도 직전 phase 의 추천이 끊기지 않게
|
|
1004
|
+
하는 안전장치."""
|
|
1005
|
+
if not (state.project_id and state.task_group and state.task_id):
|
|
1006
|
+
return ""
|
|
1007
|
+
key = f"{state.project_id}:{state.task_group}:{state.task_id}"
|
|
1008
|
+
root = find_task_root(Path(state.project_root), key)
|
|
1009
|
+
if root is None:
|
|
1010
|
+
return ""
|
|
1011
|
+
workflow = (read_task_manifest(root) or {}).get("workflow") or {}
|
|
1012
|
+
nxt = workflow.get("nextRecommendedPhase") or ""
|
|
1013
|
+
return nxt if isinstance(nxt, str) else ""
|
|
1014
|
+
|
|
1015
|
+
|
|
988
1016
|
def _build_task_type(state: WizardState) -> Prompt:
|
|
989
1017
|
t = _p(state.workspace_root, "task_type")
|
|
990
1018
|
recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
|
|
991
1019
|
options: list[Option] = []
|
|
992
1020
|
recommended = state.task_type if not state.is_new_task else ""
|
|
1021
|
+
if not recommended and state.is_new_task:
|
|
1022
|
+
recommended = _existing_task_next_phase(state)
|
|
993
1023
|
seen: list[str] = []
|
|
994
1024
|
if recommended and recommended in TASK_TYPE_VALUES:
|
|
995
1025
|
d = dict(TASK_TYPES)[recommended]
|
|
@@ -1340,8 +1370,11 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
1340
1370
|
runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1341
1371
|
if not runs_base.is_dir():
|
|
1342
1372
|
return ""
|
|
1373
|
+
# run 산출물 구조는 runs/<task-type>/reports/final-report-*.md (1단계).
|
|
1374
|
+
# 과거 `*/*/reports/...` (2단계) glob 은 실제 구조와 어긋나 항상 빈 결과여서
|
|
1375
|
+
# clarification 단계가 직전 final-report 를 추천하지 못했다.
|
|
1343
1376
|
candidates = [
|
|
1344
|
-
p for p in runs_base.glob("
|
|
1377
|
+
p for p in runs_base.glob("*/reports/final-report-*.md")
|
|
1345
1378
|
if p.is_file()
|
|
1346
1379
|
]
|
|
1347
1380
|
if not candidates:
|
|
@@ -1671,7 +1704,10 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
|
|
|
1671
1704
|
if value not in ("defaults", "customize"):
|
|
1672
1705
|
raise WizardError(f"expected 'defaults' or 'customize', got: {value!r}")
|
|
1673
1706
|
state.use_defaults = value == "defaults"
|
|
1674
|
-
|
|
1707
|
+
mode = ("defaults (recommended models as-is)"
|
|
1708
|
+
if state.use_defaults
|
|
1709
|
+
else "customize (manual model pick)")
|
|
1710
|
+
return f"model-mode: {mode}"
|
|
1675
1711
|
|
|
1676
1712
|
|
|
1677
1713
|
def _build_workers_override(state: WizardState) -> Prompt:
|
|
@@ -1719,7 +1755,13 @@ def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
|
|
|
1719
1755
|
|
|
1720
1756
|
|
|
1721
1757
|
def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
|
|
1722
|
-
|
|
1758
|
+
# "default" picks the role's recommended model — leaving it here yields
|
|
1759
|
+
# the SAME result as the 'Use defaults' branch. Spell that out on the
|
|
1760
|
+
# label so default ↔ customize never reads as "no difference".
|
|
1761
|
+
opts = [
|
|
1762
|
+
_opt(o, "default (recommended model)" if o == "default" else o)
|
|
1763
|
+
for o in options
|
|
1764
|
+
]
|
|
1723
1765
|
return Prompt(step=step, kind="pick", label=label,
|
|
1724
1766
|
options=opts, echo_template=echo)
|
|
1725
1767
|
|
|
@@ -2192,14 +2234,19 @@ STEPS: list[Step] = [
|
|
|
2192
2234
|
and S_RELATED_TASKS not in s.answered),
|
|
2193
2235
|
build=_build_related_tasks, submit=_submit_related_tasks,
|
|
2194
2236
|
owns=("related_tasks_raw", "related_tasks_pending_text")),
|
|
2237
|
+
# clarification(직전 phase final-report 입력)은 customize 전용이 아니다.
|
|
2238
|
+
# 이어가기에 필수인 직전 final-report 가 존재하면 use_defaults 와 무관하게
|
|
2239
|
+
# 입력 기회를 노출한다. (과거: use_defaults 게이트 뒤에 숨어 "Use defaults"
|
|
2240
|
+
# 를 고르면 직전 리포트가 통째로 누락됐다.)
|
|
2195
2241
|
Step(S_CLARIFICATION_PICK,
|
|
2196
|
-
applies=lambda s: (s.
|
|
2197
|
-
and
|
|
2242
|
+
applies=lambda s: (S_CLARIFICATION_PICK not in s.answered
|
|
2243
|
+
and s.use_defaults is not None
|
|
2244
|
+
and (s.use_defaults is False
|
|
2245
|
+
or bool(_suggest_latest_final_report(s)))),
|
|
2198
2246
|
build=_build_clarification_pick, submit=_submit_clarification_pick,
|
|
2199
2247
|
owns=("clarification_response_path", "clarification_pending_text")),
|
|
2200
2248
|
Step(S_CLARIFICATION,
|
|
2201
|
-
applies=lambda s: (s.
|
|
2202
|
-
and s.clarification_pending_text
|
|
2249
|
+
applies=lambda s: (s.clarification_pending_text
|
|
2203
2250
|
and S_CLARIFICATION not in s.answered),
|
|
2204
2251
|
build=_build_clarification, submit=_submit_clarification,
|
|
2205
2252
|
owns=("clarification_response_path", "clarification_pending_text")),
|
|
@@ -536,7 +536,10 @@
|
|
|
536
536
|
"type": "array",
|
|
537
537
|
"items": { "$ref": "#/$defs/DiffFileRow" }
|
|
538
538
|
}
|
|
539
|
-
}
|
|
539
|
+
},
|
|
540
|
+
"$comment": "rawStat(git diff --stat 원문)이 비어있지 않으면(=변경 있음) files 표는 최소 1행이어야 한다. 변경을 stat 으로는 보고하면서 files 표를 비워 미선언-surface conformance 게이트를 우회하는 fail-open(SUSP-1)을 차단. 0파일 변경은 rawStat 이 빈 문자열이라 허용된다.",
|
|
541
|
+
"if": { "required": ["rawStat"], "properties": { "rawStat": { "minLength": 1 } } },
|
|
542
|
+
"then": { "properties": { "files": { "minItems": 1 } } }
|
|
540
543
|
},
|
|
541
544
|
"outOfPlanEdits": {
|
|
542
545
|
"type": "array",
|
|
@@ -131,7 +131,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
|
|
|
131
131
|
|
|
132
132
|
The status file is written after step 3 completes.
|
|
133
133
|
|
|
134
|
-
**Run-end
|
|
134
|
+
**Run-end teammate teardown follows this whole sequence.** Token-usage collection (step 1) reads the worker session jsonls, so the lead MUST NOT disband the team until every step above is done. Only then — after the pane-disposition prompt and only when the user approves teammate cleanup — does the lead shut down worker teammates + `TeamDelete` per `_common-contract.md` "Run-end teammate teardown" (Teams mode only; silent-skip in the no-`team_name` fallback; if the user keeps the team, leave it intact and surface the manual Teams/FleetView cleanup path).
|
|
135
135
|
|
|
136
136
|
## Final Report Structure
|
|
137
137
|
|
|
@@ -273,7 +273,7 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
|
|
|
273
273
|
2. **Final Verdict** — `Direction` ∈ `continue-investigation` / `begin-implementation` / `approve` / `reject` / `hold`. **Verdict Token** is `not-applicable` for every task-type except `final-verification` — see "Final-verification verdict token contract" below for that case.
|
|
274
274
|
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.
|
|
275
275
|
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.
|
|
276
|
-
5. **Clarification Items** — single unified `C-*` table; column schema, ID convention, and rerun behaviour are owned by `_common-contract.md §Clarification request policy` (
|
|
276
|
+
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.
|
|
277
277
|
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).
|
|
278
278
|
7. **Follow-up Tasks** — auto-spawn-eligible table. Each row drives `okstra-spawn-followups.py`; see template §7 for the row schema.
|
|
279
279
|
|
|
@@ -26,10 +26,11 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
|
|
|
26
26
|
- Created at: {{ header.createdAt }}
|
|
27
27
|
- Task Key: {{ header.taskKey }}
|
|
28
28
|
- Task Type: {{ header.taskType }}
|
|
29
|
-
- Report Owner: `{{ header.reportOwner }}`
|
|
29
|
+
- Report Owner: `{{ header.reportOwner }}` (model: `{{ header.leadModel | model_detail }}`)
|
|
30
30
|
- Report Author: `{{ header.reportAuthor }}`
|
|
31
|
-
- Lead Model: `{{ header.leadModel }}`
|
|
32
|
-
-
|
|
31
|
+
- Lead Model: `{{ header.leadModel | model_detail }}`
|
|
32
|
+
{% for row in followUpTasks if row.origin == 'phase-continuation' %}{% if loop.first %}- Next Step: run `/okstra-run`, select task `{{ header.taskKey }}`, choose task-type `{{ row.suggestedTaskType }}`
|
|
33
|
+
{% endif %}{% endfor %}- Okstra Version: `{{ header.okstraVersion }}`
|
|
33
34
|
|
|
34
35
|
## Verdict Card
|
|
35
36
|
|
|
@@ -55,10 +56,10 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
|
|
|
55
56
|
- Claude Code: `/okstra-run resume-clarification task-key={{ header.taskKey }}`
|
|
56
57
|
- {{ t("clarification.separateTerminalLabel") }}: `scripts/okstra.sh --resume-clarification --task-key {{ header.taskKey }}`
|
|
57
58
|
|
|
58
|
-
|
|
|
59
|
-
|
|
59
|
+
| {{ t("columns.recordMeta") }} | Statement | Expected form | User input |
|
|
60
|
+
|---|---|---|---|
|
|
60
61
|
{% for row in clarificationItems -%}
|
|
61
|
-
| {{ row.id }}
|
|
62
|
+
| **{{ row.id }}**<br>Ticket: `{{ row.ticketId }}`<br>Kind: `{{ row.kind }}`<br>Blocks: `{{ row.blocks }}`<br>Status: {{ row.status }} | {{ row.statement }} | {{ row.expectedForm }} | {{ row.userInput or '' }} |
|
|
62
63
|
{% endfor %}
|
|
63
64
|
|
|
64
65
|
{{ t("clarification.columnGuide") }}
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"clarification": {
|
|
128
128
|
"fillAndRerun": "Fill in your answers then re-run the same phase:",
|
|
129
129
|
"separateTerminalLabel": "Separate terminal",
|
|
130
|
-
"columnGuide": "
|
|
130
|
+
"columnGuide": "Record cell field values:"
|
|
131
131
|
},
|
|
132
132
|
"followUpTasks": {
|
|
133
133
|
"headingAside": "Follow-up Tasks"
|
|
@@ -132,38 +132,6 @@ td.td-narrow, th.td-narrow {
|
|
|
132
132
|
width: 5%;
|
|
133
133
|
white-space: nowrap;
|
|
134
134
|
}
|
|
135
|
-
/* Compact grouped layout for the wide final-report tables (Execution
|
|
136
|
-
* Status, §5 Clarification Items, §7 Follow-up Tasks): the short
|
|
137
|
-
* columns collapse into one stacked `key: value` metadata cell, while
|
|
138
|
-
* each long-prose column keeps its own guaranteed min-width so it never
|
|
139
|
-
* gets squashed into a one-character ladder. */
|
|
140
|
-
table.grouped-table td.grp-meta {
|
|
141
|
-
width: 24%;
|
|
142
|
-
}
|
|
143
|
-
table.grouped-table th.grp-wide,
|
|
144
|
-
table.grouped-table td.grp-wide {
|
|
145
|
-
min-width: 18ch;
|
|
146
|
-
}
|
|
147
|
-
.grp-meta .grp-headline {
|
|
148
|
-
font-weight: 600;
|
|
149
|
-
margin-bottom: 0.4em;
|
|
150
|
-
}
|
|
151
|
-
.grp-meta .grp-field {
|
|
152
|
-
display: flex;
|
|
153
|
-
gap: 0.45em;
|
|
154
|
-
font-size: 0.88rem;
|
|
155
|
-
line-height: 1.55;
|
|
156
|
-
}
|
|
157
|
-
.grp-meta .grp-key {
|
|
158
|
-
color: GrayText;
|
|
159
|
-
white-space: nowrap;
|
|
160
|
-
}
|
|
161
|
-
.grp-meta .grp-key::after {
|
|
162
|
-
content: ":";
|
|
163
|
-
}
|
|
164
|
-
.grp-meta .grp-val {
|
|
165
|
-
overflow-wrap: anywhere;
|
|
166
|
-
}
|
|
167
135
|
thead th {
|
|
168
136
|
position: sticky;
|
|
169
137
|
top: 3rem;
|
|
@@ -113,3 +113,13 @@ Every item in sections 1–5 (and any section 6) MUST carry a worker-internal it
|
|
|
113
113
|
For task types `requirements-discovery`, `error-analysis`, `implementation-planning`, or `implementation`, every item in sections 1–5 MUST carry a ticket identifier. Use the `Ticket ID` column in table-form items and the `[TICKETID: <id>]` prefix in bullet/numbered items.
|
|
114
114
|
|
|
115
115
|
See `skills/okstra-team-contract/SKILL.md` "Worker Output Contract" for the full frontmatter schema and section ordering rules. This preamble is consistent with that contract; the team-contract document is authoritative if the two ever diverge.
|
|
116
|
+
|
|
117
|
+
## Writing style (all prose output — analysis workers + report-writer)
|
|
118
|
+
|
|
119
|
+
When you write in Korean (`Report Language: ko` / `meta.reportLanguage = "ko"`), write so a Korean developer understands it on first read. Translate the **meaning**, never the dictionary word.
|
|
120
|
+
|
|
121
|
+
- Keep code identifiers, file paths, symbols, model names, CLI flags, and status tokens in English (`strategy`, `overlay`, `accepted`, …). Translate the ordinary words around them.
|
|
122
|
+
- Do NOT render a technical term as its literal dictionary noun. Use the term a developer actually says:
|
|
123
|
+
- `authoritative` → **기준값 / 정본 / 우선 적용** (NEVER 권위)
|
|
124
|
+
- e.g. "overlays this row as authoritative" → "이 행을 최종 기준값으로 덮어쓴다" (NEVER "이 행을 권위로 overlay")
|
|
125
|
+
- Self-check before emitting any Korean sentence: if it reads like an awkward word-for-word translation, rewrite it by meaning. Confident-but-unreadable direct translation is a defect.
|
|
@@ -13,9 +13,6 @@ from pathlib import Path
|
|
|
13
13
|
from typing import List, Tuple
|
|
14
14
|
|
|
15
15
|
STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
|
|
16
|
-
STAGE_SECTION = re.compile(
|
|
17
|
-
r"^##\s+5\.5\.(\d+)\s+Stage\s+\1\s*:\s*(.+)$", re.M
|
|
18
|
-
)
|
|
19
16
|
REQUIRED_SUBSECTIONS = (
|
|
20
17
|
"Carry-In",
|
|
21
18
|
"Stepwise Execution Order",
|
|
@@ -29,7 +29,10 @@ SCRIPTS_DIR = REPO_ROOT / "scripts"
|
|
|
29
29
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
30
30
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
31
31
|
|
|
32
|
-
from okstra_ctl.clarification_items import
|
|
32
|
+
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
33
|
+
parse_clarification_items,
|
|
34
|
+
section_1_present_but_unparsed,
|
|
35
|
+
)
|
|
33
36
|
from okstra_ctl.report_views import ( # noqa: E402
|
|
34
37
|
extract_html_digest,
|
|
35
38
|
source_digest,
|
|
@@ -80,6 +83,14 @@ def validate(report_path: Path) -> list[str]:
|
|
|
80
83
|
|
|
81
84
|
md = report_path.read_text(encoding="utf-8")
|
|
82
85
|
html_path = report_path.with_name(report_path.stem + ".html")
|
|
86
|
+
# §1 헤딩이 있는데 파싱이 실패하면 md_ids 가 빈 []이 되어 "clarification 없음
|
|
87
|
+
# → skip" 으로 흘러 HTML form parity 게이트가 조용히 열린다. fail-closed.
|
|
88
|
+
if section_1_present_but_unparsed(md):
|
|
89
|
+
return [
|
|
90
|
+
"final-report has a `## 1. Clarification Items` heading but its table "
|
|
91
|
+
"could not be parsed (heading/anchor/format drift) — cannot verify HTML "
|
|
92
|
+
"form parity. Re-render the report so §1 matches the schema."
|
|
93
|
+
]
|
|
83
94
|
md_ids = _md_response_ids(md)
|
|
84
95
|
|
|
85
96
|
# (1) sibling artifact exists — conditional on §1 clarification rows.
|
|
@@ -510,11 +510,20 @@ TOKEN_PLACEHOLDERS = (
|
|
|
510
510
|
)
|
|
511
511
|
|
|
512
512
|
|
|
513
|
+
# The final-report renderer (render_final_report.py:_inject_anchors) appends a
|
|
514
|
+
# scroll anchor ` <a id="slug"></a>` to every H2+ heading. Section-scoping
|
|
515
|
+
# regexes that pin a heading to end-of-line MUST tolerate this optional suffix
|
|
516
|
+
# or they silently fail to match the rendered markdown — yielding false
|
|
517
|
+
# "missing section" failures and dead consistency checks. Mirror of the strip
|
|
518
|
+
# pattern in scripts/okstra_ctl/report_views.py:_HEADING_ANCHOR_RE.
|
|
519
|
+
_HEADING_TAIL = r'[ \t]*(?:<a id="[^"]+"></a>[ \t]*)?$'
|
|
520
|
+
|
|
513
521
|
# Token Usage Summary section between its `##` heading and the next `##`
|
|
514
522
|
# heading (or end-of-file). Matched non-greedily so the body of the next
|
|
515
523
|
# section never bleeds in.
|
|
516
524
|
_TOKEN_USAGE_SECTION_RE = re.compile(
|
|
517
|
-
r"^##[ \t]+(?:Token Usage Summary|토큰 사용량 요약)
|
|
525
|
+
r"^##[ \t]+(?:Token Usage Summary|토큰 사용량 요약)" + _HEADING_TAIL
|
|
526
|
+
+ r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
|
|
518
527
|
re.DOTALL | re.MULTILINE,
|
|
519
528
|
)
|
|
520
529
|
|
|
@@ -695,7 +704,7 @@ _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
|
|
695
704
|
),
|
|
696
705
|
"deprecated `### 1.1 추가 자료 요청` / `Additional Materials` sub-section — "
|
|
697
706
|
"every clarification item lives as one row of the unified `## 1. "
|
|
698
|
-
"Clarification Items`
|
|
707
|
+
"Clarification Items` table (`Kind=material`).",
|
|
699
708
|
),
|
|
700
709
|
(
|
|
701
710
|
re.compile(
|
|
@@ -704,7 +713,7 @@ _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
|
|
704
713
|
),
|
|
705
714
|
"deprecated `### 1.2 사용자 확인 질문` / `Questions for the User` "
|
|
706
715
|
"sub-section — collapse into the unified `## 1. Clarification Items` "
|
|
707
|
-
"
|
|
716
|
+
"table (`Kind=decision`).",
|
|
708
717
|
),
|
|
709
718
|
)
|
|
710
719
|
|
|
@@ -1046,7 +1055,8 @@ _FINAL_VERDICT_TOKEN_RE = re.compile(
|
|
|
1046
1055
|
# the Verdict Card block, scoped to the body between `## Verdict Card`
|
|
1047
1056
|
# heading and the next `##` heading.
|
|
1048
1057
|
_VERDICT_CARD_BLOCK_RE = re.compile(
|
|
1049
|
-
r"^##[ \t]+Verdict Card
|
|
1058
|
+
r"^##[ \t]+Verdict Card" + _HEADING_TAIL
|
|
1059
|
+
+ r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
|
|
1050
1060
|
re.DOTALL | re.MULTILINE,
|
|
1051
1061
|
)
|
|
1052
1062
|
|
|
@@ -1054,7 +1064,8 @@ _VERDICT_CARD_BLOCK_RE = re.compile(
|
|
|
1054
1064
|
# regex so that we don't accidentally match a Verdict Token row that
|
|
1055
1065
|
# lives in the Verdict Card or anywhere else.
|
|
1056
1066
|
_FINAL_VERDICT_BLOCK_RE = re.compile(
|
|
1057
|
-
r"^##[ \t]+7\.[ \t]+Final Verdict
|
|
1067
|
+
r"^##[ \t]+7\.[ \t]+Final Verdict" + _HEADING_TAIL
|
|
1068
|
+
+ r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
|
|
1058
1069
|
re.DOTALL | re.MULTILINE,
|
|
1059
1070
|
)
|
|
1060
1071
|
|
|
@@ -1114,13 +1125,22 @@ def _extract_final_verdict_token(content: str) -> str | None:
|
|
|
1114
1125
|
return match.group("value")
|
|
1115
1126
|
|
|
1116
1127
|
|
|
1128
|
+
# 렌더러는 ID 정의 셀(`<a id="r-001"></a>R-001`)에도 스크롤 앵커를 넣는다.
|
|
1129
|
+
# 셀 정규화 때 그 빈 앵커를 벗겨야 ID 컬럼이 bare 토큰으로 읽힌다
|
|
1130
|
+
# (clarification_items._CELL_ANCHOR_RE 와 동형).
|
|
1131
|
+
_CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
|
|
1132
|
+
|
|
1133
|
+
|
|
1117
1134
|
def _split_markdown_row(line: str) -> list[str]:
|
|
1118
1135
|
stripped = line.strip()
|
|
1119
1136
|
if stripped.startswith("|"):
|
|
1120
1137
|
stripped = stripped[1:]
|
|
1121
1138
|
if stripped.endswith("|"):
|
|
1122
1139
|
stripped = stripped[:-1]
|
|
1123
|
-
return [
|
|
1140
|
+
return [
|
|
1141
|
+
_CELL_ANCHOR_RE.sub("", cell).strip().strip("`").strip()
|
|
1142
|
+
for cell in stripped.split("|")
|
|
1143
|
+
]
|
|
1124
1144
|
|
|
1125
1145
|
|
|
1126
1146
|
def _is_markdown_separator(line: str) -> bool:
|