okstra 0.121.1 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/docs/architecture.md +4 -2
  2. package/docs/project-structure-overview.md +1 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/workers/claude-worker.md +1 -1
  6. package/runtime/prompts/launch.template.md +4 -4
  7. package/runtime/prompts/lead/adapters/claude-code.md +104 -0
  8. package/runtime/prompts/lead/adapters/codex.md +45 -0
  9. package/runtime/prompts/lead/adapters/external.md +46 -0
  10. package/runtime/prompts/lead/context-loader.md +5 -5
  11. package/runtime/prompts/lead/convergence.md +11 -28
  12. package/runtime/prompts/lead/okstra-lead-contract.md +42 -92
  13. package/runtime/prompts/lead/plan-body-verification.md +21 -5
  14. package/runtime/prompts/lead/report-writer.md +50 -51
  15. package/runtime/prompts/lead/team-contract.md +33 -106
  16. package/runtime/prompts/profiles/_common-contract.md +2 -2
  17. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +3 -3
  20. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  21. package/runtime/python/okstra_ctl/lead_runtime.py +4 -0
  22. package/runtime/python/okstra_ctl/render.py +76 -194
  23. package/runtime/python/okstra_ctl/report_views.py +22 -7
  24. package/runtime/python/okstra_ctl/team_reconcile.py +3 -1
  25. package/runtime/schemas/final-report-v1.0.schema.json +3 -0
  26. package/runtime/skills/okstra-schedule/SKILL.md +60 -18
  27. package/runtime/templates/reports/final-report.template.md +4 -4
  28. package/runtime/templates/reports/i18n/en.json +1 -1
  29. package/runtime/templates/reports/i18n/ko.json +1 -1
  30. package/runtime/validators/validate-run.py +89 -0
  31. package/runtime/validators/validate-schedule.py +7 -3
  32. package/runtime/validators/validate_session_conformance.py +58 -33
  33. package/src/cli-registry.mjs +7 -0
  34. package/src/commands/inspect/stage-map.mjs +100 -0
  35. package/src/commands/lifecycle/install.mjs +3 -0
  36. package/src/commands/lifecycle/uninstall.mjs +8 -23
  37. package/src/lib/skill-catalog.mjs +8 -0
@@ -6,7 +6,7 @@ model: opus
6
6
 
7
7
  # OKSTRA Schedule
8
8
 
9
- Generate a consolidated work schedule for all non-done tasks in a given `task-group`. The skill reads each task's `task-manifest.json` and `latestReport`, classifies tasks into phases by priority and risk, and writes a single Markdown plan file under `.okstra/tasks/<task-group>/schedule/`. It runs as a single Claude lead synthesis (lightweight mode); the frontmatter `model: opus` switches supporting harnesses to Opus-class for the turn — cross-task synthesis needs that reasoning depth.
9
+ Generate a consolidated work schedule for the selected `implementation-planning` stages of every non-done task in a given `task-group` (or a single `task-id`). For each task the skill reads its `implementation-planning` final-report **Stage Map** as the authoritative forward-looking decomposition, asks the user which stages to include, drafts a single Markdown plan, and only writes the final file after an **independent verifier subagent** confirms the draft covers exactly the selected stages. It runs as a **lead + verifier** flow; the frontmatter `model: opus` switches supporting harnesses to Opus-class for the turn — stage-level cross-task synthesis needs that reasoning depth.
10
10
 
11
11
  ## When to Use
12
12
 
@@ -47,20 +47,23 @@ The installed template `~/.okstra/templates/reports/schedule.template.md` is the
47
47
 
48
48
  `~/.okstra/lib/validators/validate-schedule.py` is the enforcement for all of the above (heading order, field labels, controlled vocabulary — e.g. `Med-High` is the canonical risk form — forbidden translations, checkbox bans, Gantt fence rules, unresolved-code detection). The Step 4 ambiguous-classification rationale line is the one rule the validator does not yet enforce — emit it yourself.
49
49
 
50
- Two computation rules the template scaffold cannot carry inline:
50
+ One computation rule the template scaffold cannot carry inline:
51
51
 
52
52
  - **Effort-to-Day mapping**: day ranges per size are defined once in the template's `### Effort Sizing 기준` table. For the At a Glance totals line, sum that table's lower bounds across in-scope tasks for the lower total and upper bounds for the upper total.
53
- - **`[NEEDS-OKSTRA-RUN]` / `[PARSE-ERROR: <section>]` placement**: put the marker immediately under the per-task heading inside its Phase section and fill only the available fields — never relocate it to a sibling section.
54
53
 
55
54
  ## Procedure
56
55
 
57
56
  ### Step 1: Resolve task-group and collect tasks
58
57
 
59
58
  1. Read `.okstra/discovery/task-catalog.json`.
60
- 2. **Normalise the user-supplied `<task-group>` argument:** lowercase it, then strip every character that is not `[a-z0-9]`. Apply the same transform to each entry's `taskGroupPathSegment`. Match on equality this is the single comparison rule; do NOT also fall back to the raw `taskGroup` field.
61
- 3. If no tasks found, output `해당 task-group 찾을 없습니다.` and stop.
62
- 4. For each matched task, read `.okstra/tasks/<task-group-segment>/<task-id-segment>/task-manifest.json` directly. Catalog data may be stale; the manifest is authoritative.
63
- 5. **Derive `<project-id>`** for the header: prefer `task-catalog.json`'s top-level `projectId`, otherwise the first matched manifest's `projectId`. Do not invent a value.
59
+ 2. **Resolve which task-group to schedulenever silently guess.**
60
+ - The user **explicitly named a task-group** (as the `okstra schedule <task-group>` argument or unambiguously in the request) → use that token; skip the picker and go to sub-step 3.
61
+ - The user **named no task-group, or the named token matches 0 or ≥2 groups** → present a 3-option picker via `AskUserQuestion` and do NOT proceed until the user chooses. Build the options from the catalog: walk `tasks[]` in catalog order (already `updatedAt` desc — see `scripts/okstra_ctl/render.py:654`), collect distinct `taskGroupPathSegment` values that have ≥1 entry whose resolved `workStatus` is **non-done** (defer to Step 2's inference table), and offer the newest **1–2** such groups as recommendations. The **last option is always `직접 입력`** (free-text group token, fed into sub-step 3). Label each recommendation with its non-done task count (e.g. `uploadFont (non-done 3)`).
62
+ - If **zero groups have a non-done task** (or the catalog is empty), do NOT open a picker — emit `해당 task-group의 모든 task가 done 상태입니다. 생성할 schedule이 없습니다.` (or `해당 task-group을 찾을 없습니다.` when the catalog has no tasks at all) and stop **without creating a file**.
63
+ 3. **Normalise the resolved `<task-group>`:** lowercase it, then strip every character that is not `[a-z0-9]`. Apply the same transform to each entry's `taskGroupPathSegment`. Match on equality — this is the single comparison rule; do NOT also fall back to the raw `taskGroup` field.
64
+ 4. If no tasks found, output `해당 task-group을 찾을 수 없습니다.` and stop.
65
+ 5. For each matched task, read `.okstra/tasks/<task-group-segment>/<task-id-segment>/task-manifest.json` directly. Catalog data may be stale; the manifest is authoritative.
66
+ 6. **Derive `<project-id>`** for the header: prefer `task-catalog.json`'s top-level `projectId`, otherwise the first matched manifest's `projectId`. Do not invent a value.
64
67
 
65
68
  ### Step 2: Filter by workStatus
66
69
 
@@ -68,18 +71,35 @@ For inference when `workStatus` is missing or empty, defer to the inference tabl
68
71
 
69
72
  If 0 tasks remain, output `해당 task-group의 모든 task가 done 상태입니다. 생성할 schedule이 없습니다.` and stop **without creating a file**.
70
73
 
71
- ### Step 3: Per-task data extraction
74
+ ### Step 3: Per-task stage extraction (Stage Map source)
72
75
 
73
- From `task-manifest.json`: `taskId`, `taskGroup`, `taskKey`, `workCategory`, `workflow.currentPhase`, `workflow.currentPhaseState`, `taskType`, `workStatus`, `latestReportPath`.
76
+ For each in-scope task, the **authoritative source is its `implementation-planning` final-report**, NOT `latestReportPath`. Resolve and parse it as follows:
74
77
 
75
- From the report file (`latestReportPath`): Title / Problem statement, Solution / Architecture, Work Breakdown table, Verification Commands, Rollback strategy, Effort, Risk, Priority, Scope, Repos. Blocking items / approval requests in the report are **not** extracted (see Audience).
78
+ 1. Resolve `<task-root>` from the manifest's `taskRootPath`. The planning report lives under `<task-root>/runs/implementation-planning/**/reports/final-report-*.md`. If several exist, pick the **newest by mtime**.
79
+ 2. Do NOT hand-parse the Stage Map markdown. Call `okstra stage-map <task-key> --json` (Task 0). It returns `{ok, taskRoot, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int]}` by wrapping `_load_stage_map` + `read_stage_consumer_state().done_stages`. `stages: []` means no planning Stage Map exists → treat as **no planning report** below.
80
+ 3. From each stage row keep `stage_number`, `title`, `depends_on`, `step_count`. Compute `remainingStages = stages − doneStages`.
81
+ 4. From `task-manifest.json` still read task-level metadata for the header and phase bucket: `taskId`, `taskGroup`, `taskKey`, `workCategory`, `workStatus`, `taskType`, `workflow.currentPhase`.
82
+ 5. From the planning report also read task-level Priority / Risk / Scope / Repos for phase classification and the At a Glance row. Blocking / approval items are **not** extracted (see Audience & authority).
76
83
 
77
- If the report file does not exist or cannot be parsed, attempt the **manifest-staleness fallback** before tagging (`latestReportPath` can go stale when an interleaved `--render-only` prep advances it to a path that is never written):
84
+ **No planning Stage Map** (`stage-map` returned `stages: []`) → this task cannot be stage-scheduled. Tag it `[NEEDS-PLANNING]`, skip the Step 3.5 stage picker for it, and render it under its phase section as a single banner line with task-level metadata only (no Gantt bars, no day total). Continue with the remaining tasks.
78
85
 
79
- 1. Resolve `<task-root>` from the manifest's `taskRootPath`.
80
- 2. List `<task-root>/runs/<taskType>/reports/final-report-*.md` and pick the **newest existing file by mtime** (NOT by filename timestamp). If none, try `<task-root>/runs/*/reports/final-report-*.md` across all task-types.
81
- 3. If found, parse it and add one line at the top of that task's block: `> _Report fallback: manifest pointed at non-existent <pointed-name>, used <actual-name> (mtime <ts>) instead._`
82
- 4. Only if no candidate exists, tag the entry `[NEEDS-OKSTRA-RUN]`, use manifest metadata only, and continue with the remaining tasks. If parsing fails for one section, tag `[PARSE-ERROR: <section>]` and continue.
86
+ **`remainingStages` is empty** (every stage done but `workStatus` not `done`) → not a scheduling target. Render the task as `_완료 남은 stage 없음_` under its phase section; contribute no forward day total.
87
+
88
+ ### Step 3.5: Stage selection (per task, user input)
89
+
90
+ Run this **once per in-scope task that has a non-empty `remainingStages`**, sequentially. A single `task-id` invocation runs it once; a `task-group` runs it per task-id in At-a-Glance order.
91
+
92
+ **Build dependency-closed cumulative bundles over `remainingStages`:**
93
+
94
+ 1. Order `remainingStages` topologically by `depends_on` (a stage may only appear after all its `depends_on` stages).
95
+ 2. Pick natural cut points to form up to **3 cumulative bundles**: `B1` = through the first milestone, `B2` = through the core, `B3` = all remaining. Each bundle is a **dependency-closed prefix** — selecting it never omits a prerequisite stage. Label each bundle with its stage list and summed `step_count` (e.g. `B2: stage 1–4 (steps 12)`).
96
+ 3. If `remainingStages` has ≤2 stages, emit only the bundles that are distinct (1–2), never pad to 3.
97
+
98
+ **Render the picker** with `AskUserQuestion` (one question for this task):
99
+ - Options = the distinct bundles + a final `"남은 stage 전부"` option. `AskUserQuestion`'s built-in Other slot serves the `직접 입력` (arbitrary stage subset) case; when the user supplies a custom subset, close it under `depends_on` before accepting.
100
+ - **Degenerate skip:** if only one distinct bundle exists AND it already equals all remaining stages, skip the picker for this task and set `selectedStages = remainingStages` (log `> _Stage picker 생략: 남은 stage가 단일 의존성 체인._`).
101
+
102
+ Record the chosen `selectedStages` for this task. Any custom selection that breaks `depends_on` closure is rejected — re-prompt the same task.
83
103
 
84
104
  ### Step 4: Phase classification
85
105
 
@@ -94,16 +114,35 @@ If the report file does not exist or cannot be parsed, attempt the **manifest-st
94
114
 
95
115
  Priority overrides category: `P0` → Phase 1; `P1`/`P2` → Phase 2; `P3` or multi-repo + infrastructure scope → Phase 3. When still ambiguous, place the task in the closest phase and add a one-line rationale at the top of that phase section (not validator-enforced — emit it yourself).
96
116
 
117
+ Phase bucketing stays **task-level** (a task lands in one Phase by its `workCategory`/Priority). Within a task's per-task section, the **selected stages become the Work Breakdown rows**: one row per `selectedStages` entry with its `title`, `step_count`-derived effort, and `depends_on`. Stages excluded from `selectedStages` because they are already done are listed once as `> _완료 stage: stage <n>, …_` and carry no forward effort. This keeps the mandatory heading skeleton unchanged while moving the unit of work to the stage.
118
+
97
119
  ### Step 5: Gantt decision (render by default)
98
120
 
99
121
  `## Gantt Chart` is **rendered by default** — skip ONLY when literally no day signal exists (every task is effort=XXL with no visible decomposition, or all tasks lack both effort sizing and decomposition). Render whenever any of these hold: 2+ tasks with effort sizing; 1 task whose effort yields a range (mid-point bar, or `lo`/`hi` two-bar form); 1 task with Part/Phase/Step decomposition in the source (bars at decomposition-unit level); total estimated effort ≥ 3 days. When per-unit day allocations aren't itemized, split the parent range across the visible units yourself and append the `est` annotation (or add `> 일별 배분은 추정치이며 차단 항목 해소 후 갱신 권장.`). "Range is wide", "single task", "user decisions pending" are NOT skip reasons — render an estimate-tagged chart instead.
100
122
 
123
+ When the source is a Stage Map, the Gantt **bars are the selected stages** (one bar per `selectedStages` entry), day length split from `step_count` (or the effort range across the stage's steps), and cross-stage `(after stage <n>)` / `(after <TASK-ID>)` edges follow `depends_on`. Already-done stages never get a bar. A task tagged `[NEEDS-PLANNING]` contributes no bars.
124
+
101
125
  When you do skip, insert in the section's position exactly: `> _Gantt Chart 생략: <concrete reason referencing the actual data>._`
102
126
 
103
127
  **Directive override (highest priority).** Before applying the heuristic, check for a `## Directive` section, first hit wins: (1) the `--directive-file <abs-path>` argument; (2) `<PROJECT_ROOT>/.okstra/tasks/<task-group-segment>/schedule/instruction-set/analysis-material.md`; (3) none → apply the default heuristic silently. A found directive overrides the render/skip heuristic for the affected section — note it inline as `> _Per Directive directive: <verbatim short excerpt>._` — and its pre-supplied day allocations / phase weights are used verbatim as bar lengths. A directive file without a `## Directive` heading counts as "no directive".
104
128
 
129
+ ### Step 5.5: Draft, verify, gate
130
+
131
+ 1. **Draft to staging (never the final path yet).** Write the assembled schedule to `.okstra/tasks/<task-group-segment>/schedule/.draft/<YYYY-MM-DD_HH-MM-SS>.md`. Auto-create the parent.
132
+ 2. **Dispatch an independent verifier subagent** with the `Agent` tool. It receives, per task: the `selectedStages` list (stage numbers + titles) and `depends_on` edges, plus the draft file path. It must NOT see the lead's reasoning — only these inputs and the draft. It returns a structured verdict:
133
+ - `coverage`: every `selectedStages` entry appears as a forward Work Breakdown / Gantt unit in the draft (nothing missing).
134
+ - `noExcess`: no non-selected stage and no done stage appears as forward work.
135
+ - `depOrder`: stage ordering and `(after …)` edges honor `depends_on`.
136
+ - `pass = coverage && noExcess && depOrder`; on fail it lists the offending stages as `missing[]` / `extra[]` / `orderViolations[]`.
137
+ 3. **Gate:**
138
+ - `pass` → promote the draft to the final path (Step 6) and continue.
139
+ - `fail` → hand the verifier's `missing`/`extra`/`orderViolations` back to the lead, revise the draft in place, and re-dispatch the verifier. **Max 2 revise cycles.**
140
+ - Still failing after the 2nd cycle → do **NOT** write the final file. Remove the staging draft, and report the residual `missing`/`extra`/`orderViolations` to the user in Korean. Stop.
141
+
105
142
  ### Step 6: Write the schedule file
106
143
 
144
+ Reached only after Step 5.5 returns `pass`. **Promote** the verified staging draft to the final path (do not re-render from scratch):
145
+
107
146
  ```
108
147
  .okstra/tasks/<task-group-segment>/schedule/<task-group-segment>-plan-<YYYY-MM-DD_HH-MM-SS>.md
109
148
  ```
@@ -124,7 +163,7 @@ When you do skip, insert in the section's position exactly: `> _Gantt Chart 생
124
163
  - 포함 task: N개
125
164
  - 제외(done) task: M개
126
165
  - 예상 소요: X.X ~ Y.Y days (Effort 합산)
127
- - 모드: lightweight
166
+ - 모드: lead + verifier
128
167
  ```
129
168
 
130
169
  ## Edge Cases
@@ -133,8 +172,10 @@ When you do skip, insert in the section's position exactly: `> _Gantt Chart 생
133
172
  |------|----------|
134
173
  | `workStatus` absent or empty | Resolve via the `okstra-inspect status.4` inference table; include unless resolved `done` |
135
174
  | Filtered task count is 0 | Emit "모든 task가 done" message; do NOT create a file |
136
- | `latestReportPath` missing or unreadable | Staleness fallback, then `[NEEDS-OKSTRA-RUN]` with manifest metadata only |
137
- | Report parsing fails for one section | `[PARSE-ERROR: <section>]`; continue with the rest |
175
+ | implementation-planning 리포트 없음 | `[NEEDS-PLANNING]` 배너로만 나열, stage picker·Gantt·day total 없음 |
176
+ | 남은 stage 0개(전부 done, workStatus 미표기) | `_완료 남은 stage 없음_`, forward 산출 없음 |
177
+ | 남은 stage ≤2개 | 누적 묶음을 나오는 만큼만(1~2), 단일 체인이면 picker 생략·전부 진행 |
178
+ | verifier 2회 미통과 | 최종 파일 미작성, 잔여 missing/extra/order 사용자 보고 |
138
179
  | `task-group` matches no tasks | "해당 task-group을 찾을 수 없습니다." and stop |
139
180
  | Catalog and manifest disagree on `workStatus` | Manifest wins (catalog may be stale) |
140
181
  | task-group casing / punctuation variants | Normalise both sides (lowercase + strip non-`[a-z0-9]`), compare against `taskGroupPathSegment` only; use the manifest's segment verbatim for path output |
@@ -144,3 +185,4 @@ When you do skip, insert in the section's position exactly: `> _Gantt Chart 생
144
185
  - All user-facing messages in Korean; schedule body prose Korean, identifiers/headings/field labels English (template literals).
145
186
  - Use project-relative paths in completion messages.
146
187
  - Use `taskType` (not `workStatus`) for the At a Glance status column — it carries lifecycle phase information.
188
+ - Per-task section Work Breakdown rows are **selected stages**, not free-form items; done stages are summarized, never scheduled forward.
@@ -368,12 +368,12 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
368
368
  {{ t("implementationPlanning.planBodyBreakageLegend") }}
369
369
 
370
370
  {% for item in implementationPlanning.planBodyVerification.planItems %}
371
- **{{ item.id }} — {{ item.subject }}**{% if item.sourceSection %} ({{ t("implementationPlanning.planBodySourceLabel") }} {{ item.sourceSection }}){% endif %}{% if item.clarificationId %} — {{ t("implementationPlanning.planBodyBlockerLabel") }} {{ item.clarificationId }}{% endif %}
371
+ **{{ item.id }} — {{ item.subject }}**{% if item.sourceSection %} ({{ t("implementationPlanning.planBodySourceLabel") }} {{ item.sourceSection }}){% endif %}{% if item.clarificationId %} — {{ t("implementationPlanning.planBodyBlockerLabel") }} {{ item.clarificationId }}{% endif %}{% if item.selfFixNote %} — {{ item.selfFixNote }}{% endif %}
372
372
 
373
373
 
374
- | Worker | Verdict | Breakage kind | Note |
375
- |--------|---------|---------------|------|
376
- {% for v in item.verdicts %}| {{ v.worker | mdcell }} | {{ v.verdict | mdcell }} | {{ (v.breakageKind or '--') | mdcell }} | {{ (v.note or '') | mdcell }} |
374
+ | Worker | Verdict | Breakage kind | Fixability | Note |
375
+ |--------|---------|---------------|------------|------|
376
+ {% for v in item.verdicts %}| {{ v.worker | mdcell }} | {{ v.verdict | mdcell }} | {{ (v.breakageKind or '--') | mdcell }} | {{ (v.fixability or '--') | mdcell }} | {{ (v.note or '') | mdcell }} |
377
377
  {% endfor %}
378
378
 
379
379
  {% endfor %}
@@ -104,7 +104,7 @@
104
104
  "implementationPlanning": {
105
105
  "planBodyGateLegend": "Gate values — `passed`: agreed, no dissent · `passed-with-dissent`: a minority dissent remains but the gate passes (a majority dissent would block approval) · `blocked-by-disagreement`: majority dissent blocks approval · `aborted-non-result`: verification itself produced no result.",
106
106
  "planBodyVerdictLegend": "Verdict — **AGREE**: executable as written and internally consistent with other items · **SUPPLEMENT**: item is sound but a dependency / edge case / precondition is missing · **DISAGREE**: has a defect (see Breakage kind) · **verification-error**: the worker produced no result.",
107
- "planBodyBreakageLegend": "Breakage kind — a: cited file path/symbol mismatches another step or option · b: command is not executable or is ambiguous · c: validation signal is not observable · d: rollback violates commit/dependency order · e: contradicts the trade-off matrix · f: requirement-coverage row does not map to an option/stage/step that actually satisfies the requirement. (`--` = not applicable)",
107
+ "planBodyBreakageLegend": "Breakage kind — a: cited file path/symbol mismatches another step or option · b: command is not executable or is ambiguous · c: validation signal is not observable · d: rollback violates commit/dependency order · e: contradicts the trade-off matrix · f: requirement-coverage row does not map to an option/stage/step that actually satisfies the requirement. (`--` = not applicable) Fixability: planner-fixable = correctable from code + plan + brief; needs-user-input = requires an external decision.",
108
108
  "planBodySourceLabel": "source §",
109
109
  "planBodyBlockerLabel": "blocks approval →",
110
110
  "optionInterfacesLabel": "Affected interfaces / public contracts / downstream consumers",
@@ -104,7 +104,7 @@
104
104
  "implementationPlanning": {
105
105
  "planBodyGateLegend": "게이트 결과 값 뜻 — `passed`: 이견 없이 통과 · `passed-with-dissent`: 소수 워커의 반대가 남았으나 통과(반대가 다수였다면 승인 차단) · `blocked-by-disagreement`: 다수 반대로 승인 차단 · `aborted-non-result`: 검증 자체가 결과를 내지 못함.",
106
106
  "planBodyVerdictLegend": "판정(Verdict) — **AGREE**: 적힌 대로 실행 가능하고 다른 항목과 내부적으로 일관됨 · **SUPPLEMENT**: 항목 자체는 타당하나 의존성·엣지케이스·전제조건이 누락됨 · **DISAGREE**: 결함이 있음(결함 유형 참조) · **verification-error**: 워커 검증이 결과를 내지 못함.",
107
- "planBodyBreakageLegend": "결함 유형(Breakage kind) — a: 인용한 파일 경로/심볼이 다른 스텝·옵션과 불일치 · b: 명령이 실행 불가하거나 모호함 · c: 검증 신호가 관측 불가 · d: 롤백이 커밋/의존성 순서를 위반 · e: 트레이드오프 매트릭스와 모순 · f: 요구사항 커버리지 행이 요구사항을 실제로 충족하는 옵션/스테이지/스텝에 매핑되지 않음. (`--` = 해당 없음)",
107
+ "planBodyBreakageLegend": "결함 유형(Breakage kind) — a: 인용한 파일 경로/심볼이 다른 스텝·옵션과 불일치 · b: 명령이 실행 불가하거나 모호함 · c: 검증 신호가 관측 불가 · d: 롤백이 커밋/의존성 순서를 위반 · e: 트레이드오프 매트릭스와 모순 · f: 요구사항 커버리지 행이 요구사항을 실제로 충족하는 옵션/스테이지/스텝에 매핑되지 않음. (`--` = 해당 없음) · 자가수정 가능 여부(Fixability) — planner-fixable: 코드·계획·브리프만으로 수정 가능 · needs-user-input: 외부 결정 필요",
108
108
  "planBodySourceLabel": "출처 §",
109
109
  "planBodyBlockerLabel": "승인 차단 →",
110
110
  "optionInterfacesLabel": "영향 인터페이스 / 공개 계약 / 다운스트림 소비자",
@@ -1590,6 +1590,8 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1590
1590
  _validate_plan_item_extraction_completeness(data, failures)
1591
1591
  _validate_plan_item_subject_substance(data, failures)
1592
1592
  _validate_plan_body_clarification_matching(data, failures)
1593
+ _validate_disagree_has_fixability(data, failures)
1594
+ _validate_self_fix_before_clarification(data, failures)
1593
1595
  _validate_requirement_coverage_covered_by(data, failures)
1594
1596
 
1595
1597
 
@@ -1986,6 +1988,93 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
1986
1988
  )
1987
1989
 
1988
1990
 
1991
+ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
1992
+ """A planner-fixable defect MUST pass through a self-fix round before it is
1993
+ promoted to a `## 1. Clarification Items` row. Closes the hole where the
1994
+ lead dumps a fixable plan defect (abbreviated path, prose command,
1995
+ placeholder, coverage remap) onto the user instead of having report-writer
1996
+ correct it (plan-body-verification.md "Self-fix round").
1997
+ """
1998
+ ip = data.get("implementationPlanning")
1999
+ if not isinstance(ip, dict):
2000
+ return
2001
+ pbv = ip.get("planBodyVerification")
2002
+ if not isinstance(pbv, dict):
2003
+ return
2004
+ round_count = pbv.get("roundCount")
2005
+ if not isinstance(round_count, int) or round_count < 1:
2006
+ return
2007
+ if pbv.get("selfFixRoundApplied") is True:
2008
+ return
2009
+ for item in pbv.get("planItems") or []:
2010
+ if not isinstance(item, dict):
2011
+ continue
2012
+ if _classify_plan_item_gate(item) != "majority-disagree":
2013
+ continue
2014
+ disagrees = [
2015
+ v for v in (item.get("verdicts") or [])
2016
+ if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
2017
+ ]
2018
+ fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
2019
+ if disagrees and len(fixable) * 2 > len(disagrees):
2020
+ failures.append(
2021
+ "final-report data.json: plan item "
2022
+ f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
2023
+ "planner-fixable majority but `selfFixRoundApplied` is not true. A "
2024
+ "planner-fixable defect MUST be corrected by a report-writer self-fix "
2025
+ "round before it becomes a clarification row "
2026
+ "(plan-body-verification.md Self-fix round)."
2027
+ )
2028
+
2029
+
2030
+ # Allowed `fixability` values, mirroring the schema enum
2031
+ # (schemas/final-report-v1.0.schema.json planItems[].verdicts[].fixability).
2032
+ _FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
2033
+
2034
+
2035
+ def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
2036
+ """Every `DISAGREE` verdict MUST carry a valid `fixability`
2037
+ (`planner-fixable` / `needs-user-input`). The schema enum only constrains a
2038
+ *present* value; it does not require the field, so a DISAGREE with a missing
2039
+ or mislabelled fixability is schema-valid. That silently degrades
2040
+ `_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
2041
+ DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
2042
+ skip the self-fix round and land on the user. This is the enforcement point
2043
+ for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
2044
+ """
2045
+ ip = data.get("implementationPlanning")
2046
+ if not isinstance(ip, dict):
2047
+ return
2048
+ pbv = ip.get("planBodyVerification")
2049
+ if not isinstance(pbv, dict):
2050
+ return
2051
+ round_count = pbv.get("roundCount")
2052
+ if not isinstance(round_count, int) or round_count < 1:
2053
+ return
2054
+ for item in pbv.get("planItems") or []:
2055
+ if not isinstance(item, dict):
2056
+ continue
2057
+ item_id = item.get("id") or "<unknown>"
2058
+ for verdict in item.get("verdicts") or []:
2059
+ if not isinstance(verdict, dict):
2060
+ continue
2061
+ if str(verdict.get("verdict") or "").upper() != "DISAGREE":
2062
+ continue
2063
+ fixability = verdict.get("fixability")
2064
+ if fixability not in _FIXABILITY_VALUES:
2065
+ worker = verdict.get("worker") or "<worker>"
2066
+ allowed = " / ".join(sorted(_FIXABILITY_VALUES))
2067
+ failures.append(
2068
+ f"final-report data.json: plan item `{item_id}` has a "
2069
+ f"`DISAGREE` verdict from `{worker}` with "
2070
+ f"fixability `{fixability}` — a DISAGREE MUST declare a "
2071
+ f"fixability of {allowed}. A missing/invalid value is "
2072
+ "counted as non-fixable and lets a planner-fixable defect "
2073
+ "skip the self-fix round (plan-body-verification.md "
2074
+ "\"fixability (DISAGREE 전용, 필수)\")."
2075
+ )
2076
+
2077
+
1989
2078
  _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
1990
2079
  _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
1991
2080
  _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
@@ -442,9 +442,13 @@ def validate(path: Path) -> list[str]:
442
442
  block = text[block_start:block_end]
443
443
  heading_line = text[block_start : text.find("\n", block_start)].strip()
444
444
 
445
- # Skip blocks marked as NEEDS-OKSTRA-RUN or PARSE-ERROR these are
446
- # intentionally partial.
447
- if "[NEEDS-OKSTRA-RUN]" in block or "[PARSE-ERROR" in block:
445
+ # Skip blocks marked as NEEDS-OKSTRA-RUN, PARSE-ERROR, or NEEDS-PLANNING
446
+ # — these are intentionally partial.
447
+ if (
448
+ "[NEEDS-OKSTRA-RUN]" in block
449
+ or "[PARSE-ERROR" in block
450
+ or "[NEEDS-PLANNING]" in block
451
+ ):
448
452
  continue
449
453
 
450
454
  last_pos = -1
@@ -1,22 +1,20 @@
1
- """prompts/lead/okstra-lead-contract.md BLOCKING 계약 3종의 post-hoc conformance 검사.
2
-
3
- 설계: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
4
-
5
- | 검사 | 선언 위치 | 증거 |
6
- |------|----------|------|
7
- | 1. lead PROGRESS 체크포인트 라인 | prompts/lead/okstra-lead-contract.md "Progress reporting (BLOCKING)" | lead 세션 jsonl assistant text 블록 |
8
- | 2. claude-worker 5분 heartbeat | agents/workers/claude-worker.md "Heartbeat" | audit 사이드카의 `- PROGRESS: <stage> <ISO>` 라인 |
9
- | 3. implementation sidecar entry guard | prompts/lead/okstra-lead-contract.md "Entry guard (BLOCKING)" | lead 세션 jsonl Read tool_use |
10
-
11
- 스캔 규칙 (false-pass 방지가 핵심):
12
- - `type == "assistant"` 레코드만 본다 Skill 호출 시 SKILL.md 본문(체크포인트
13
- 라인 예시 포함)이 tool_result(user 레코드)로 transcript 에 주입되므로,
14
- assistant 레코드를 보면 즉시 false pass 난다. `isSidechain` 레코드도 제외.
15
- - run 윈도우(`resolve_run_window`)로 스코핑한다 — in-session lead 세션 전체
16
- jsonl 기록되므로, 같은 세션의 직전 run 이 남긴 라인이 증거로 오인된다.
17
- (실측: dev-9692 lead 세션 1개에 implementation run 001–003 이 모두 들어 있음.)
18
- - validator 실행 시점(Phase 7) **이후** 에 출력되는 체크포인트
19
- (`phase-7-teardown`, `complete`)는 구조적으로 검사 불가 — 요구하지 않는다.
1
+ """Post-hoc checks for three BLOCKING neutral lead lifecycle contracts.
2
+
3
+ Design: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
4
+
5
+ | Check | Contract | Evidence |
6
+ |-------|----------|----------|
7
+ | 1. lead PROGRESS checkpoints | prompts/lead/okstra-lead-contract.md "Progress reporting (BLOCKING)" | selected adapter evidence source |
8
+ | 2. worker-provider heartbeat | worker provider contract | audit sidecar `- PROGRESS: <stage> <ISO>` lines |
9
+ | 3. implementation sidecar entry guard | prompts/lead/okstra-lead-contract.md "Entry guard (BLOCKING)" | selected adapter evidence source |
10
+
11
+ Evidence rules prevent false passes:
12
+ - `claude-jsonl` accepts only assistant text and Read tool-use records, excluding
13
+ injected skill text and sidechain records.
14
+ - Session evidence is scoped to the current run window so a previous run cannot
15
+ satisfy the neutral lead lifecycle contract.
16
+ - Checkpoints emitted after validation begins (`phase-7-teardown`, `complete`)
17
+ cannot be observed and are not required.
20
18
  """
21
19
  from __future__ import annotations
22
20
 
@@ -202,7 +200,8 @@ def _collect_lead_evidence(
202
200
  if not evidence.scanned_files:
203
201
  return None, (
204
202
  f"lead session jsonl not found under {proj_dir} "
205
- f"(lead.sessionId={lead_sid or '<empty>'}) — PROGRESS checkpoint / "
203
+ f"(lead.sessionId={lead_sid or '<empty>'}) — selected adapter evidence "
204
+ "source `claude-jsonl` cannot verify the PROGRESS checkpoint / "
206
205
  "implementation entry-guard conformance cannot be verified, which "
207
206
  "fails the run (same principle as the token-usage accuracy contract)."
208
207
  )
@@ -223,15 +222,16 @@ def _resolve_lead_events_path(
223
222
  if not raw:
224
223
  return None, (
225
224
  "artifact lead event log path missing from team-state "
226
- "(`leadEventsPath`) — artifact-only conformance cannot be verified."
225
+ "(`leadEventsPath`) — selected adapter evidence source `artifact-only` "
226
+ "cannot verify conformance."
227
227
  )
228
228
  path = Path(str(raw))
229
229
  if not path.is_absolute():
230
230
  path = project_root / path
231
231
  if not path.is_file():
232
232
  return None, (
233
- f"artifact lead event log not found: {path} — artifact-only "
234
- "conformance cannot be verified."
233
+ f"artifact lead event log not found: {path} — selected adapter evidence "
234
+ "source `artifact-only` cannot verify conformance."
235
235
  )
236
236
  return path, None
237
237
 
@@ -301,7 +301,10 @@ def _collect_artifact_lead_evidence(
301
301
  try:
302
302
  events = read_lead_events(events_path)
303
303
  except LeadEventParseError as exc:
304
- return None, f"artifact lead event log is malformed — {exc}"
304
+ return None, (
305
+ "artifact lead event log is malformed — selected adapter evidence "
306
+ f"source `artifact-only` cannot verify conformance: {exc}"
307
+ )
305
308
 
306
309
  run_seq = suffix.rsplit("-", 1)[1]
307
310
  evidence = _LeadEvidence(scanned_files=[events_path])
@@ -324,6 +327,27 @@ def _collect_artifact_lead_evidence(
324
327
  return evidence, None
325
328
 
326
329
 
330
+ def _conformance_evidence_source(
331
+ team_state: dict,
332
+ ) -> tuple[str | None, str | None]:
333
+ dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
334
+ if dispatch_mode in _WORKER_DISPATCH_MODES:
335
+ return "artifact-only", None
336
+
337
+ adapter = team_state.get("leadAdapter")
338
+ if isinstance(adapter, dict):
339
+ accounting = str(adapter.get("sessionAccounting", "")).strip()
340
+ if accounting in {"claude-jsonl", "artifact-only"}:
341
+ return accounting, None
342
+ if accounting:
343
+ return None, f"unsupported sessionAccounting: `{accounting}`"
344
+
345
+ legacy_runtime = str(team_state.get("leadRuntime", "") or "claude-code")
346
+ if legacy_runtime in {"codex", "external"}:
347
+ return "artifact-only", None
348
+ return "claude-jsonl", None
349
+
350
+
327
351
  def _convergence_rounds_ran(run_dir: Path, suffix: str | None) -> bool:
328
352
  """이 run 의 convergence state artifact 가 실제 round 를 1회 이상 돌았는지.
329
353
  auto-disable(`totalRounds: 0`)·artifact 부재는 phase-5.5 라인을 요구하지 않는다."""
@@ -569,7 +593,8 @@ def _check_implementation_sidecar_reads(evidence: _LeadEvidence, errors: list[st
569
593
  if not ts_list:
570
594
  errors.append(
571
595
  f"implementation entry guard: no `Read` of `{basename}` found in "
572
- f"the lead session jsonl within this run's window — the sidecar "
596
+ "the selected adapter evidence source within this run's window — "
597
+ "the sidecar "
573
598
  f"MUST be read fresh at {read_at} every implementation run "
574
599
  "(prompts/lead/okstra-lead-contract.md 'Entry guard (BLOCKING)')."
575
600
  )
@@ -593,10 +618,10 @@ def validate_session_conformance(
593
618
  task_type: str,
594
619
  claude_projects_dir: Path | None = None,
595
620
  ) -> SessionConformanceResult:
596
- """post-hoc conformance 검사 3종을 수행하고 실패 목록을 돌려준다.
621
+ """Run three post-hoc checks for the neutral lead lifecycle contract.
597
622
 
598
- `claude_projects_dir` 테스트/진단용 주입 시드 (기본: 실제 ~/.claude/projects).
599
- 검사 2(heartbeat)는 디스크 사이드카만 보므로 lead jsonl 미발견 시에도 수행된다.
623
+ `claude_projects_dir` injects the Claude project root for tests and diagnostics.
624
+ Heartbeat validation runs before selecting the adapter evidence source.
600
625
  """
601
626
  result = SessionConformanceResult()
602
627
  _ensure_token_usage_importable()
@@ -610,11 +635,11 @@ def validate_session_conformance(
610
635
  suffix = run_artifact_suffix(team_state_path)
611
636
  _check_heartbeat_sidecars(run_dir, task_type, suffix, result.errors)
612
637
 
613
- lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
614
- dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
615
- if lead_runtime in {"codex", "external"} or (
616
- lead_runtime == "claude-code" and dispatch_mode in _WORKER_DISPATCH_MODES
617
- ):
638
+ evidence_source, source_error = _conformance_evidence_source(team_state)
639
+ if source_error:
640
+ result.errors.append(source_error)
641
+ return result
642
+ if evidence_source == "artifact-only":
618
643
  evidence, error = _collect_artifact_lead_evidence(
619
644
  team_state, project_root, task_type, suffix
620
645
  )
@@ -110,6 +110,13 @@ export const COMMAND_REGISTRY = [
110
110
  category: "introspection",
111
111
  summary: ["Summarize a task's manifest + workflow phase state"],
112
112
  },
113
+ {
114
+ name: "stage-map",
115
+ module: "./commands/inspect/stage-map.mjs",
116
+ export: "run",
117
+ category: "introspection",
118
+ summary: ["Dump a task's implementation-planning Stage Map + done stages"],
119
+ },
113
120
  {
114
121
  name: "context-cost",
115
122
  module: "./commands/inspect/context-cost.mjs",
@@ -0,0 +1,100 @@
1
+ import { runPythonSnippet, emitJsonError } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra stage-map — dump a task's implementation-planning Stage Map + done stages
4
+
5
+ Usage:
6
+ okstra stage-map <task-key> task-key is project-id:task-group:task-id
7
+ okstra stage-map <task-key> --cwd <dir> Resolve PROJECT_ROOT from <dir>
8
+ okstra stage-map <task-key> --project <dir> Use <dir> directly as PROJECT_ROOT
9
+
10
+ Output: JSON { ok, taskKey, taskRoot, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int] }.
11
+ stages is [] when no implementation-planning Stage Map exists.
12
+ `;
13
+
14
+ function parseArgs(args) {
15
+ const opts = { cwd: process.cwd(), projectRoot: "", taskKey: "" };
16
+ const positional = [];
17
+ for (let i = 0; i < args.length; i++) {
18
+ const a = args[i];
19
+ if (a === "--cwd") {
20
+ opts.cwd = args[++i];
21
+ if (!opts.cwd) throw new Error("--cwd requires a path");
22
+ } else if (a === "--project") {
23
+ opts.projectRoot = args[++i];
24
+ if (!opts.projectRoot) throw new Error("--project requires a path");
25
+ } else if (a === "--json") {
26
+ // JSON is always emitted; accept the flag for parity with sibling commands.
27
+ } else if (a.startsWith("--")) {
28
+ throw new Error(`unknown argument '${a}'`);
29
+ } else {
30
+ positional.push(a);
31
+ }
32
+ }
33
+ if (positional.length !== 1) {
34
+ throw new Error("expected exactly one positional argument: <task-key>");
35
+ }
36
+ opts.taskKey = positional[0];
37
+ return opts;
38
+ }
39
+
40
+ const SCRIPT = `
41
+ import json, sys
42
+ from pathlib import Path
43
+ from okstra_project import resolve_project_root, find_task_root, read_task_manifest, ResolverError
44
+ from okstra_ctl.implementation_outcome import _load_stage_map
45
+ from okstra_ctl.consumers import read_stage_consumer_state
46
+
47
+ explicit, cwd, task_key = sys.argv[1], sys.argv[2], sys.argv[3]
48
+
49
+ try:
50
+ pr = resolve_project_root(explicit_root=explicit, cwd=cwd)
51
+ except ResolverError as e:
52
+ print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
53
+ sys.exit(2)
54
+
55
+ task_root = find_task_root(Path(pr), task_key)
56
+ if task_root is None:
57
+ print(json.dumps({"ok": False, "stage": "task_root", "reason": f"task root not found for {task_key}"}))
58
+ sys.exit(1)
59
+
60
+ manifest = read_task_manifest(task_root) or {}
61
+ stages = _load_stage_map(task_root, manifest)
62
+
63
+ plan_run_root = task_root / "runs" / "implementation-planning"
64
+ done = sorted(read_stage_consumer_state(plan_run_root, recover_from_carry=True).done_stages) if plan_run_root.is_dir() else []
65
+
66
+ print(json.dumps({
67
+ "ok": True,
68
+ "taskKey": task_key,
69
+ "taskRoot": str(task_root),
70
+ "stages": stages,
71
+ "doneStages": done,
72
+ }, ensure_ascii=False, indent=2))
73
+ `;
74
+
75
+ export async function run(args) {
76
+ if (args.includes("--help") || args.includes("-h")) {
77
+ process.stdout.write(USAGE);
78
+ return 0;
79
+ }
80
+ let opts;
81
+ try {
82
+ opts = parseArgs(args);
83
+ } catch (err) {
84
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
85
+ return 2;
86
+ }
87
+ const result = await runPythonSnippet({
88
+ script: SCRIPT,
89
+ args: [opts.projectRoot, opts.cwd, opts.taskKey],
90
+ });
91
+ if (result.code !== 0 && !result.stdout.trim()) {
92
+ emitJsonError({
93
+ stage: "python",
94
+ reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
95
+ });
96
+ return 1;
97
+ }
98
+ process.stdout.write(result.stdout);
99
+ return result.code === 0 ? 0 : result.code;
100
+ }
@@ -45,6 +45,9 @@ const BIN_ENTRYPOINTS = [
45
45
  // depend on. Intentionally an exact allowlist, not a directory wildcard.
46
46
  const REQUIRED_PROMPT_RESOURCE_FILES = Object.freeze([
47
47
  ["prompts", "lead", "okstra-lead-contract.md"],
48
+ ["prompts", "lead", "adapters", "claude-code.md"],
49
+ ["prompts", "lead", "adapters", "codex.md"],
50
+ ["prompts", "lead", "adapters", "external.md"],
48
51
  ["prompts", "lead", "context-loader.md"],
49
52
  ["prompts", "lead", "team-contract.md"],
50
53
  ["prompts", "lead", "convergence.md"],