okstra 0.115.0 → 0.117.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.
@@ -541,6 +541,23 @@ scripts/okstra.sh --resume-clarification --task-key <project-id>:<task-group>:<t
541
541
 
542
542
  `$EDITOR`로 최신 final report가 열리고, 저장 후 같은 phase가 자동으로 `--clarification-response` carry-in 으로 재실행됩니다.
543
543
 
544
+ #### Incremental re-verification (implementation-planning 재실행 한정)
545
+
546
+ `implementation-planning` clarification 재실행은 기본이 **full 재검증**입니다. 다만 답변의 파급이 국소적이고 코드가 그대로라면, lead 가 영향 stage 의 하류 폐포만 재검증하고 나머지 stage 의 plan-item verdict 는 직전 run 에서 carry-forward 합니다. 판정은 결정적 CLI 와 lead 재량으로 나뉩니다.
547
+
548
+ - **C1 (CLI 판정, 결정적)**: 직전 run 의 `state/active-run-context-implementation-planning-<prev-seq>.json` 의 `executorWorktree.baseRef` 와 이번 run 의 base-ref SHA 가 같아야 합니다. 다르면 코드가 바뀐 것이므로 즉시 `full`.
549
+ - **C2 (lead 재량)**: 답변이 건드리는 Stage Map stage 번호 집합. Stage Map 에 없는 번호는 절대 넣지 않고, 매핑에 확신이 없거나 답변이 선택 Option/접근법을 뒤집으면 **빈 집합**을 넘겨 `full` 로 폴백합니다.
550
+ - **폐포 컷오프**: `okstra incremental-scope` 가 `implementationPlanning.stageMap` 의 의존성 그래프에서 영향 stage 의 `downstream_stage_closure` 를 구하고, 그 크기가 전체 stage 의 **과반을 넘으면** `full`. 그 외에는 `{mode, reverify_stages, carry_stages, reason}` JSON 을 출력합니다.
551
+
552
+ ```bash
553
+ okstra incremental-scope --prev-data <prev data.json> --cur-base-sha <sha> --prev-base-sha <sha> --impacted 2,3
554
+ okstra incremental-carry --prev-data <prev data.json> --cur-data <cur data.json> --prev-seq <prev-seq> --out <cur data.json>
555
+ ```
556
+
557
+ `mode == "incremental"` 이면 worker dispatch 는 `reverify_stages` 로만 범위가 좁혀지고(`prompts/profiles/implementation-planning.md` 의 *Cross-verification mode*), 워커는 `carry_stages` 를 재개봉·재판정하지 않습니다. 재실행이 끝나면 `okstra incremental-carry` 가 직전 run 의 plan-item verdict 중 이번 run 에 없는 항목을 `carriedForwardFromSeq` 태그와 함께 병합합니다. 두 run 의 `schemaVersion` 이 다르면 `CarryError` 로 비영 종료하고 full 로 폴백합니다. 재검증 stage 에서 plan item 이 **삭제**돼야 한다는 결론이 나오면 파급이 국소적이지 않다는 신호이므로 역시 full 로 재실행합니다. `verdictCard` / `finalVerdict` 는 절대 carry 되지 않고 매 run 재계산합니다.
558
+
559
+ 판정 결과는 report-writer 가 data.json 의 `implementationPlanning.incrementalDecision` 에 그대로 기록하고, 렌더러가 `### 0.1 Incremental Re-Verification Scope` 감사 블록으로 노출합니다. 절차 원문은 `prompts/launch.template.md` §"Incremental re-verification" 입니다.
560
+
544
561
  ### 6. 필요 시 구현 계획 검토
545
562
 
546
563
  ```bash
@@ -630,8 +647,10 @@ resume 판단 기준:
630
647
  Claude가 작성하는 최종 보고서는 아래 구조를 우선 사용합니다 (brief 의 augmentation 이 더 구체적인 형식을 요구할 때만 그것을 따릅니다).
631
648
 
632
649
  - `## Verdict Card` — **최상단 의무 섹션**. Final Conclusion / Verdict Token / Direction / Approval Required? / Next Step 5 행. Verdict Token / Direction / Next Step 셀은 본문 §2 (실행 현황) 와 §6 (다음 단계) 의 권위 셀과 byte-match 해야 합니다.
650
+ - (선택) `## Reader Summary` — data.json 의 `readerSummary` 가 있을 때만 Verdict Card 바로 아래 렌더되는 5행 표: 결정(`decision`) / 사람이 해야 할 행동(`humanActionRequired`) / 차단 항목(`blockingItems`) / 건너뛰어도 되는 것(`safeToSkip`) / 추천 명령(`recommendedCommand`). 존재하면 5개 필드 전부 필수이며 (schema `required`), raw evidence table 을 반복하지 않고 요약만 담습니다. 없는 과거 data.json 도 그대로 렌더됩니다.
633
651
  - `## 작업 배경과 근거` — **모든 task-type 의무 섹션** (data.json `rationale`). 검토자용 서술로 네 질문에 순서대로 답합니다: 왜 이 작업을 하는가(`motivation`) / 왜 이게 문제인가(`problem`) / 그래서 어떤 작업이 필요한가(`approach`) / 왜 이게 합리적 선택인가(`justification`). 표가 아니라 프로즈이며, 각 필드는 `path:line` · 보고서 ID(`C-001`) · `§5.4` 같은 증거 참조나 명시적 불충분 표시(`근거 불충분`) 중 하나를 반드시 포함해야 합니다 — `validators/validate-run.py` 의 `_validate_rationale_evidence` 가 둘 다 없는 필드를 fail 합니다.
634
652
  - (선택) `## 0. Clarification Response Carried In From Previous Run` — 직전 run 에서 응답이 carry-in 된 경우에만 렌더링. 빈 carry-in 일 때는 헤딩 자체를 출력하지 않습니다.
653
+ - (선택) `### 0.1 Incremental Re-Verification Scope` — data.json 의 `implementationPlanning.incrementalDecision` 이 있을 때만 렌더링. `mode == "incremental"` 이면 `Re-verified stages` / `Carried-forward stages` 두 행이 반드시 포함되며, `validators/validate-run.py` 가 누락을 `contract-violated` 로 차단합니다. carry 된 plan item 은 `carriedForwardFromSeq` 태그로 어느 run 에서 검증됐는지 남깁니다.
635
654
  - `## 1. 문제 또는 검증 대상 요약` — §6.1 Consensus / §6.2 Differences 표 각각 `Source items (worker:item)` 컬럼 보존 (cross-worker traceability).
636
655
  - `## 2. 에이전트별 실행 현황`
637
656
  - `## 3. Cross Verification 결과` — §2.1 Primary Evidence 에 `Source items (worker:item)` + `Source (path:line / log)` 컬럼.
@@ -650,6 +669,8 @@ Claude가 작성하는 최종 보고서는 아래 구조를 우선 사용합니
650
669
  Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 self-contained HTML view 를 결정론적으로 자동 생성합니다.
651
670
 
652
671
  - `reports/final-report-<task-type>-<seq>.html` — 사람 reviewer 용 self-contained HTML. CSS / JS 인라인 임베드 (외부 URL 0), system color 다크모드, sticky header, 인쇄 대응. §1 `C-*` 행의 의사결정 입력 (체크박스 / 셀렉트 / textarea) 을 화면에서 채우고 `Export user response` 버튼으로 사이드카 markdown 을 생성합니다.
672
+ - **Reader Summary dashboard + reader mode**: 최상단에 `readerSummary`(없으면 `verdictCard` fallback) 기반 dashboard 를 렌더하고, `Action` / `Audit` / `Full` 세 reader mode 토글을 제공합니다. 기본은 `Action` — Reader Summary / Verdict Card / Clarification Items / Recommended Next Steps / Follow-up Tasks 만 보이고, Evidence · Cross Verification · Execution Status · Token Usage · Plan Body Verification · Round History 같은 감사용 섹션은 `Audit` / `Full` 에서 펼칩니다.
673
+ - **`C-*` 셀렉트 옵션 순서**: `Expected form` 의 `Recommended:` 답이 항상 **첫 옵션**으로 렌더되고, 이어지는 `Alternatives:` 항목이 `(a)`, `(b)`, ... 순으로 연속 재부여됩니다 (원본 문자 라벨을 그대로 쓰지 않음).
653
674
 
654
675
  진입점:
655
676
 
@@ -711,7 +732,7 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 self-contained HTML
711
732
  phase 산출물의 출고 가능 여부를 강제하는 진입점:
712
733
 
713
734
  - `validators/validate-workflow.sh` — phase contract 통합 검증.
714
- - `validators/validate-run.py` — run-level final-report 본문 contract (Verdict Card 존재, `rationale` 4개 필드의 증거-앵커 강제(`_validate_rationale_evidence`), deprecated §6.1/§6.2/§5.5.8/§5.5.9 Open Questions 부재, Plan Body Verification gate × Approval 마커 cross-check, Token Usage sentinel/zero 차단, 워커-결과 audit 사이드카 존재).
735
+ - `validators/validate-run.py` — run-level final-report 본문 contract (Verdict Card 존재, `rationale` 4개 필드의 증거-앵커 강제(`_validate_rationale_evidence`), deprecated §6.1/§6.2/§5.5.8/§5.5.9 Open Questions 부재, Plan Body Verification gate × Approval 마커 cross-check, Token Usage sentinel/zero 차단, 워커-결과 audit 사이드카 존재, 증분 재검증 감사 블록 존재(`_check_incremental_audit_block` — data.json 이 `incrementalDecision.mode == "incremental"` 이면 `### 0.1 Incremental Re-Verification Scope` + `Re-verified stages` / `Carried-forward stages` 행 필수)).
715
736
  - `validators/validate-report-views.py` — self-contained HTML view 의 form-control 위치, 외부 URL 부재, stale source digest, Response ID parity(`C-*` ↔ HTML) 검사.
716
737
  - `validators/validate-brief.py` — brief schema (front-matter, `Reporter Confirmations` 섹션 존재, root parent-id self 규칙, slug 컨벤션 등) 강제. `bash validators/validate-brief.sh <brief.md>` 가 thin wrapper.
717
738
 
@@ -211,6 +211,8 @@ Important modules:
211
211
  | `implementation_stage.py` | `implementation` 단일-stage run 오케스트레이션 — Stage Lifecycle Snapshot 읽기 → 가용 Stage Map entry 선택 → 격리 stage worktree provision → 선택 stage 를 run context 로 발행 (`run.py` 에서 추출) |
212
212
  | `stage_targets.py` | Stage readiness/verification 정책 SSOT — Stage Lifecycle Snapshot(`consumers.jsonl` ledger + carry sidecar backfill + active registry reservation)으로 어떤 stage 가 실행 가능한지, 어느 commit 에서 분기하는지, final-verification 이 무엇을 검사하는지 결정. `order_stage_closure` 는 wizard 멀티선택 stage 집합의 의존성 closure 를 Kahn 위상정렬해 `chain-stages` 무인 연쇄 순서를 산출 |
213
213
  | `stage_reconcile.py` | stage prepare 흐름 공용 best-effort git reconciliation (`git_reconcile.auto_reconcile` 위임; advisory — 실패는 stderr 보고만, dependency gate 가 권위 판정 유지) |
214
+ | `incremental_scope.py` | `implementation-planning` clarification 재실행의 증분 재검증 판정 (결정적 순수 함수) — 직전 run data.json 의 `implementationPlanning.stageMap` 에서 의존성 그래프를 읽고, base-ref SHA 불변 + 영향 stage 의 `downstream_stage_closure` 가 전체 stage 의 과반 이하일 때만 `mode="incremental"` 을 반환. CLI: `okstra incremental-scope` |
215
+ | `incremental_carry.py` | 증분 재실행의 carry 병합 — 직전 run 의 plan-item verdict 중 이번 run 이 재검증하지 않은 항목을 `carriedForwardFromSeq` 태그와 함께 현재 data.json 에 병합. `schemaVersion` drift 시 `CarryError` 로 비영 종료해 full 폴백을 유도. CLI: `okstra incremental-carry` |
214
216
  | `run_context.py` | Per-task mutex, run context and run-input persistence; `consumers_mutex` helper for atomic `consumers.jsonl` writes |
215
217
  | `path_hints.py` | Compact path-hint persistence + legacy context hydration — `run-context` / `active-run-context` 를 schemaVersion `2.0` 의 `identity` + `pathHints` compact schema 로 저장하고, host-side reader 가 읽는 순간 기존 flat path key(`RUN_MANIFEST_RELATIVE_PATH`, `TEAM_STATE_PATH` 등)를 메모리에서 hydrate |
216
218
  | `consumers.py` | Append-only `consumers.jsonl` writer + reader — records which `implementation` runs consumed which `implementation-planning` stage |
@@ -291,6 +293,11 @@ Token/cost accounting:
291
293
 
292
294
  `schemas/final-report-v1.0.schema.json` is the final-report data.json contract. The report-writer worker writes `final-report-<task-type>-<seq>.data.json`; the renderer produces Markdown from that JSON.
293
295
 
296
+ Optional (v1.0 하위호환) 최상위 키:
297
+
298
+ - `readerSummary` — 사람이 먼저 읽는 요약 블록. 존재하면 5개 필드(`decision`, `humanActionRequired`, `blockingItems`, `safeToSkip`, `recommendedCommand`)가 모두 필수. 없는 기존 data.json 도 그대로 렌더됩니다(HTML dashboard 는 `verdictCard` 로 폴백).
299
+ - `implementationPlanning.incrementalDecision` — 증분 재검증 판정 (`mode`, `reverifyStages`, `carryStages`, `reason`). `mode == "incremental"` 이면 렌더러가 `### 0.1 Incremental Re-Verification Scope` 감사 블록을 emit 하고 `validators/validate-run.py` 가 그 부재를 차단합니다.
300
+
294
301
  ### 4.9 `validators/`
295
302
 
296
303
  | File | Role |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.115.0",
3
- "builtAt": "2026-07-09T15:25:58.950Z",
2
+ "package": "0.117.0",
3
+ "builtAt": "2026-07-14T11:54:12.876Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -85,3 +85,37 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
85
85
  - Source path: `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}`
86
86
  - If the source path above is empty, no prior clarification response was attached to this run.
87
87
  - If the source path is set, a copy is staged at `{{INSTRUCTION_SET_RELATIVE_PATH}}/clarification-response.md`. Read it before running workers; reconcile each `C-*` row in section 1 (`## 1. Clarification Items`) of the prior report against new evidence and record the outcome in the conditional `## 0. Clarification Response Carried In From Previous Run` section of this run's final report (render that heading only when carry-in is non-empty — the validator fails empty Section 0 stubs).
88
+
89
+ ### Incremental re-verification (implementation-planning clarification re-runs only)
90
+
91
+ The **default is full re-verification**. Only narrow this re-run to the impacted stages when the deterministic `okstra incremental-scope` CLI returns `mode == "incremental"`; on any doubt, stay full. This procedure fires ONLY when this run's task-type is `implementation-planning` AND a prior final report exists for this task-key (its data.json at `runs/implementation-planning/reports/final-report-implementation-planning-<prev-seq>.data.json`, where `<prev-seq>` is the most recent prior implementation-planning run's seq). For every other task-type, ignore this block and re-verify normally. This branches on the CLI's `mode` output only — it does NOT re-implement the safety logic in the prompt.
92
+
93
+ 1. **Resolve the impacted stage set (safety condition C2 — your discretionary judgement).** For each answered `C-*` row you reconciled in §1, decide which **Stage Map stage numbers** (from the prior plan's `## 5.5 Stage Map`) the answer touches. Rules:
94
+ - Pass ONLY stage numbers that appear in that Stage Map. Never invent or guess a stage number — a number absent from the graph must never enter `--impacted` (it would leak into `reverify_stages`).
95
+ - If you cannot map a touched item to a real stage with confidence, leave the impacted set **EMPTY** — an empty set forces `mode == "full"`. Widening to full is always the safe choice; guessing is not.
96
+ - If any answer overturns the selected Option, restructures the stages, or changes the recommended approach (rather than a localized detail), also leave the impacted set **EMPTY**.
97
+ 2. **Resolve the two base SHAs (safety condition C1 — code-unchanged, decided by the CLI, not by you).**
98
+ - Current base SHA: `{{EXECUTOR_WORKTREE_BASE_REF}}` (this run's resolved worktree base commit).
99
+ - Prior base SHA: read the prior run's active-run-context at `runs/implementation-planning/state/active-run-context-implementation-planning-<prev-seq>.json`, field `executorWorktree.baseRef` (a resolved commit SHA). The run-manifest does NOT carry this field — use the active-run-context. If that file or field cannot be located, treat the run as **full** and skip the rest of this procedure.
100
+ 3. **Call the CLI** (it is pure — same inputs always yield the same decision):
101
+ ```
102
+ okstra incremental-scope \
103
+ --prev-data runs/implementation-planning/reports/final-report-implementation-planning-<prev-seq>.data.json \
104
+ --cur-base-sha {{EXECUTOR_WORKTREE_BASE_REF}} \
105
+ --prev-base-sha <prior baseRef from step 2> \
106
+ --impacted <csv of impacted stage numbers, empty for full>
107
+ ```
108
+ The CLI reads the plan's dependency graph from the top-level `## 5.5 Stage Map` (`implementationPlanning.stageMap`), which is authoritative for the impacted stage numbers — there is no per-option stage graph. The CLI prints JSON `{mode, reverify_stages, carry_stages, reason}`. Instruct the report-writer to record this JSON verbatim into this run's data.json as `implementationPlanning.incrementalDecision` (keys `mode`, `reverifyStages`, `carryStages`, `reason`) — the renderer turns it into the `### 0.1 Incremental Re-Verification Scope` audit block, and the validator fails an `incremental`-mode run whose Section 0 omits that block.
109
+ 4. **`mode == "full"`** → run the existing full re-verification path unchanged; ignore `reverify_stages` / `carry_stages`.
110
+ 5. **`mode == "incremental"`** → scope every worker dispatch prompt to `reverify_stages` only (the downstream closure of the impacted stages). Do NOT re-analyze `carry_stages` — their prior plan-item verdicts are carried forward verbatim (see `prompts/profiles/implementation-planning.md` "Cross-verification mode" and `prompts/lead/convergence.md` "Convergence scope").
111
+ 6. **Merge carried-forward verdicts.** In `incremental` mode, after this run's report-writer authors its data.json, instruct it to merge the prior plan-item verdicts for `carry_stages` into it:
112
+ ```
113
+ okstra incremental-carry \
114
+ --prev-data runs/implementation-planning/reports/final-report-implementation-planning-<prev-seq>.data.json \
115
+ --cur-data <this run's data.json> \
116
+ --prev-seq <prev-seq> \
117
+ --out <this run's data.json>
118
+ ```
119
+ A non-zero exit (`CarryError` — schema drift between the two runs) means the carry is unsafe: fall back to **full** — discard the incremental result and re-verify every stage. Note: `verdictCard` / `finalVerdict` are NEVER carried — this run re-computes them from the re-verified plus carried plan items.
120
+
121
+ **Carry completeness (BLOCKING).** In incremental mode, this run's `planItems` MUST contain every plan-item id from the re-verified stages, each carried forward with its updated verdict. If re-verification concludes a plan item should be REMOVED, that is a signal the answer's blast radius is NOT local — abandon incremental and re-route to a FULL re-verification. The carry merge only ever ADDS prior items whose id is absent from this run; it cannot distinguish a legitimate deletion from an untouched carry, so it would resurrect a stale verdict.
@@ -66,6 +66,8 @@ Read the worker result files generated in Phase 4/5 and extract individual findi
66
66
 
67
67
  **Convergence scope.** Convergence operates on sections 1–5 of the worker output (the common core, see `team-contract` "Worker Output Contract"). Section 6 ("Specialization Lens") is additive worker-specific depth and MUST NOT be fed into the consensus grouping, the verification queue, or the round-N reverify prompts. Carry Section 6 forward into the final report verbatim through the report-writer worker — do not let it inflate `unique` counts or trigger spurious `verification-error` statuses.
68
68
 
69
+ **Incremental re-verification scope (implementation-planning clarification re-runs).** When the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), only findings the lead attributes to a stage in `reverify_stages` enter the verification queue. Findings and plan-item verdicts carried forward for `carry_stages` are NOT re-queued — they skip the re-verification rounds entirely and are merged verbatim into this run's data.json via `okstra incremental-carry`. When the decision is `mode == "full"` (the default), every finding enters the queue as usual.
70
+
69
71
  1. In the "Findings" section of each worker's results, identify individual items by number (F-001, F-002, ...) and parse the ticket identifier attached to each item:
70
72
  - For table-form findings, read the `Ticket ID` column.
71
73
  - For bullet/numbered findings, parse `[TICKETID: <id>]` from the item title.
@@ -281,7 +281,7 @@ Every field MUST anchor its claim with at least one evidence reference — a `pa
281
281
 
282
282
  **Reader-facing prose MUST NOT cite a bare brief/worker-internal ID that this report never surfaces** — `RC-*` (reporter confirmations, defined in the brief), `RF-*` / `F-*` (findings, defined in worker-results) have no anchor in the final report, so a reader hits an opaque token with nothing to click. Either expand it inline (`the confirmed version target 1.27.47→1.27.48`) or, for an audit trail, namespace it (`claude:F-005`). **Enforced:** `_validate_no_opaque_id_references` fails a bare `RC-*` / `RF-*` / non-namespaced `F-*` appearing in `verdictCard` / `finalVerdict` / `rationale` / `clarificationItems[].statement`. (The renderer anchors + links in-report IDs of any digit width, so a surfaced `RC-4`-style id does resolve.) Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
283
283
 
284
- 0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub.
284
+ 0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub. When the lead ran `okstra incremental-scope` for this re-run, record its JSON verbatim into `implementationPlanning.incrementalDecision` (`mode`, `reverifyStages`, `carryStages`, `reason`); the renderer emits the `### 0.1 Incremental Re-Verification Scope` audit block from it, and the validator fails an `incremental`-mode run whose Section 0 omits that block. In `incremental` mode this run's `planItems` MUST carry every plan-item id from the re-verified stages forward with its updated verdict; if re-verification concludes a plan item should be REMOVED, that is a signal the answer's blast radius is not local — do not drop it here, tell the lead to abandon incremental and re-route to a FULL re-verification, because the carry merge only adds prior items and would resurrect the removed item's stale verdict.
285
285
  1. **Cross Verification Results** — 4 categories (Full / Partial / Contested / Worker-Unique) when convergence is enabled, per `convergence`. Prepend the Round History sub-table (columns: `Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches | skippedWorkers`) plus a `round2SkippedReason: <value>` note, pulled verbatim from `convergence-<task-type>-<seq>.json`. Empty contested list renders as `- 합의 미달 항목 없음.`. Convergence-disabled runs use the legacy Consensus/Differences format and omit the round table.
286
286
  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.
287
287
  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.
@@ -43,6 +43,7 @@
43
43
  - Cross-verification mode:
44
44
  - Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / option) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode".
45
45
  - §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/plan-body-verification.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical kinds `a` (path/symbol mismatch) / `d` (rollback order) — and `f` on `P-Req-*` items. A majority also needs ≥2 participating votes, so a lone dissent whose peer returned a non-result does not block on a majority-gated kind (see that contract's §"Adversarial plan-body posture").
46
+ - **Incremental re-verification scope (clarification re-runs):** when the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), workers re-analyze ONLY the stages listed in `reverify_stages` (the downstream closure of the impacted stages). Workers MUST NOT re-open, re-score, or re-judge any stage in `carry_stages` — those stages' prior plan-item verdicts are carried forward verbatim, and a worker never overwrites a carried verdict with its own judgement. When the decision is `mode == "full"` (the default), every stage is re-analyzed as usual.
46
47
  {{INCLUDE:_coverage-critic.md}}
47
48
  - Non-goals:
48
49
  - code-level micro-optimization unless it changes the implementation approach
@@ -0,0 +1,70 @@
1
+ """Merge carried-forward plan-item verdicts from the prior run into the
2
+ current incremental re-run's data.json. Plan items the current run did not
3
+ re-verify keep their prior verdict, tagged so the report shows they are
4
+ carried forward and unchanged.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ class CarryError(Exception):
15
+ """Carry merge refused — schema drift or structural mismatch."""
16
+
17
+
18
+ def merge_carried_forward(prev: dict, cur: dict, prev_seq: str = "previous") -> dict:
19
+ if prev.get("schemaVersion") != cur.get("schemaVersion"):
20
+ raise CarryError(
21
+ f"schemaVersion drift {prev.get('schemaVersion')!r} != {cur.get('schemaVersion')!r}; "
22
+ "cannot carry forward — caller must fall back to full"
23
+ )
24
+ prev_pbv = prev.get("implementationPlanning", {}).get("planBodyVerification", {})
25
+ cur_ip = cur.setdefault("implementationPlanning", {})
26
+ cur_pbv = cur_ip.setdefault("planBodyVerification", {})
27
+ cur_items = cur_pbv.setdefault("planItems", [])
28
+ reverified_ids = {i["id"] for i in cur_items}
29
+ for item in prev_pbv.get("planItems", []):
30
+ if item["id"] in reverified_ids:
31
+ continue
32
+ carried = dict(item)
33
+ carried["carriedForwardFromSeq"] = str(prev_seq)
34
+ cur_items.append(carried)
35
+ cur_items.sort(key=lambda i: i["id"])
36
+ return cur
37
+
38
+
39
+ def main(argv: list[str]) -> int:
40
+ ap = argparse.ArgumentParser(prog="okstra incremental-carry")
41
+ ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
42
+ ap.add_argument("--cur-data", required=True, help="current incremental re-run data.json")
43
+ ap.add_argument("--prev-seq", required=True, help="prior run seq, tagged onto carried items")
44
+ ap.add_argument("--out", required=True, help="path to write the merged data.json")
45
+ args = ap.parse_args(argv)
46
+
47
+ prev = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
48
+ cur = json.loads(Path(args.cur_data).read_text(encoding="utf-8"))
49
+ try:
50
+ merged = merge_carried_forward(prev, cur, prev_seq=args.prev_seq)
51
+ except CarryError as exc:
52
+ print(f"carry refused: {exc}", file=sys.stderr)
53
+ return 1
54
+
55
+ Path(args.out).write_text(
56
+ json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
57
+ )
58
+ carried = sum(
59
+ 1
60
+ for i in merged.get("implementationPlanning", {})
61
+ .get("planBodyVerification", {})
62
+ .get("planItems", [])
63
+ if i.get("carriedForwardFromSeq") == str(args.prev_seq)
64
+ )
65
+ print(f"merged {carried} carried-forward plan item(s) from seq {args.prev_seq} -> {args.out}")
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,89 @@
1
+ """Decide whether an implementation-planning clarification re-run can be
2
+ incremental, and if so which stages to re-verify vs carry forward.
3
+
4
+ Deterministic half of the incremental-reverification feature: the lead
5
+ prompt supplies the impacted-stage set (its discretionary C2 judgement) and
6
+ the base SHAs; everything here is pure so the same inputs always yield the
7
+ same decision. See docs/superpowers/specs/2026-07-09-incremental-clarification-reverification-design.md.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+
17
+ from okstra_ctl.stage_targets import downstream_stage_closure
18
+
19
+ CUTOFF_RATIO = 0.5
20
+
21
+
22
+ @dataclass
23
+ class IncrementalDecision:
24
+ mode: str # "incremental" | "full"
25
+ reverify_stages: list[int]
26
+ carry_stages: list[int]
27
+ reason: str
28
+
29
+
30
+ def _parse_depends_on(cell: str) -> list[int]:
31
+ text = (cell or "").strip()
32
+ if not text or text == "(none)":
33
+ return []
34
+ return [int(tok.strip()) for tok in text.split(",") if tok.strip()]
35
+
36
+
37
+ def parse_stage_graph(data: dict) -> list[tuple[int, list[int]]]:
38
+ stage_map = data.get("implementationPlanning", {}).get("stageMap", [])
39
+ return [(int(row["stage"]), _parse_depends_on(row.get("dependsOn", ""))) for row in stage_map]
40
+
41
+
42
+ def decide_scope(
43
+ *,
44
+ stages: list[tuple[int, list[int]]],
45
+ impacted_stages: set[int],
46
+ prev_base_sha: str,
47
+ cur_base_sha: str,
48
+ cutoff_ratio: float = CUTOFF_RATIO,
49
+ ) -> IncrementalDecision:
50
+ if not prev_base_sha or prev_base_sha != cur_base_sha:
51
+ return IncrementalDecision(
52
+ "full", [], [],
53
+ f"base-ref changed ({prev_base_sha!r} -> {cur_base_sha!r}); prior analysis invalid",
54
+ )
55
+ if not impacted_stages:
56
+ return IncrementalDecision("full", [], [], "no impacted stages resolved; falling back to full")
57
+ all_stages = {num for num, _ in stages}
58
+ closure = downstream_stage_closure(stages, set(impacted_stages))
59
+ if len(closure) * 2 > len(all_stages):
60
+ return IncrementalDecision(
61
+ "full", [], [],
62
+ f"closure {len(closure)}/{len(all_stages)} exceeds {int(cutoff_ratio * 100)}% cutoff",
63
+ )
64
+ reverify = sorted(closure)
65
+ carry = sorted(all_stages - closure)
66
+ return IncrementalDecision("incremental", reverify, carry, f"closure {reverify} within cutoff")
67
+
68
+
69
+ def main(argv: list[str]) -> int:
70
+ ap = argparse.ArgumentParser(prog="okstra incremental-scope")
71
+ ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
72
+ ap.add_argument("--cur-base-sha", required=True)
73
+ ap.add_argument("--prev-base-sha", required=True)
74
+ ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
75
+ args = ap.parse_args(argv)
76
+
77
+ data = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
78
+ stages = parse_stage_graph(data)
79
+ impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
80
+ decision = decide_scope(
81
+ stages=stages, impacted_stages=impacted,
82
+ prev_base_sha=args.prev_base_sha, cur_base_sha=args.cur_base_sha,
83
+ )
84
+ print(json.dumps(asdict(decision), ensure_ascii=False))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main(sys.argv[1:]))
@@ -599,6 +599,10 @@ _ALTERNATIVES_CUE = "Alternatives:"
599
599
  _PICK_ONE_ANNOTATION = re.compile(r"\s*\([^()]*택\s*\d+\s*\)\s*$")
600
600
 
601
601
 
602
+ def _lettered_option(letter: str, text: str) -> str:
603
+ return f"({letter}) {text}"
604
+
605
+
602
606
  def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
603
607
  """Parse the ``Expected form`` contract format
604
608
  (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
@@ -620,17 +624,24 @@ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
620
624
  # The rationale follows " — "; the option keeps only the answer part.
621
625
  answer = rec_body.split(" — ", 1)[0].strip(" .,;—-")
622
626
  options: list[tuple[str, str]] = []
627
+ next_letter_idx = 0
623
628
  if answer:
624
- options.append(("recommended", f"추천: {answer}"))
629
+ letter = _ENUM_LETTERS[next_letter_idx]
630
+ options.append(("recommended", _lettered_option(letter, answer)))
631
+ next_letter_idx += 1
625
632
  enum_alts = _parse_enum_options(alt_body)
626
633
  if enum_alts:
627
- options.extend(
628
- (letter, f"({letter}) {text}") for letter, text in enum_alts
629
- )
634
+ for _original_letter, text in enum_alts:
635
+ if next_letter_idx >= len(_ENUM_LETTERS):
636
+ break
637
+ letter = _ENUM_LETTERS[next_letter_idx]
638
+ options.append((letter, _lettered_option(letter, text)))
639
+ next_letter_idx += 1
630
640
  else:
631
641
  alt_text = alt_body.strip(" .,;—-")
632
- if alt_text:
633
- options.append(("alternative", alt_text))
642
+ if alt_text and next_letter_idx < len(_ENUM_LETTERS):
643
+ letter = _ENUM_LETTERS[next_letter_idx]
644
+ options.append((letter, _lettered_option(letter, alt_text)))
634
645
  return options
635
646
 
636
647
 
@@ -651,15 +662,19 @@ def _match_option_value(
651
662
  cv = (current_value or "").strip()
652
663
  if not cv:
653
664
  return None
665
+ normalized_current = _OPTION_LABEL_PREFIX.sub("", cv).strip()
654
666
  bodies = [
655
667
  (value, _OPTION_LABEL_PREFIX.sub("", label).strip())
656
668
  for value, label in opts
657
669
  ]
658
670
  for value, body in bodies:
659
- if body and (cv == body or cv == value):
671
+ if body and (normalized_current == body or cv == body or cv == value):
660
672
  return value
661
673
  for value, body in bodies:
662
- if len(body) >= _MIN_OPTION_PREFIX_LEN and cv.startswith(body):
674
+ if (
675
+ len(body) >= _MIN_OPTION_PREFIX_LEN
676
+ and (normalized_current.startswith(body) or cv.startswith(body))
677
+ ):
663
678
  return value
664
679
  return None
665
680
 
@@ -306,6 +306,32 @@ def order_stage_closure(
306
306
  return out
307
307
 
308
308
 
309
+ def downstream_stage_closure(
310
+ stages: list[tuple[int, list[int]]],
311
+ seed: set[int],
312
+ ) -> set[int]:
313
+ """Stages that (transitively) depend on any stage in ``seed``, plus ``seed``.
314
+
315
+ ``stages`` mirrors ``order_stage_closure``: ``(stage_number, [depends_on_numbers])``.
316
+ ``order_stage_closure`` walks dependencies (upstream); this walks dependents
317
+ (downstream) — the reverse edge — so an answered clarification that changes a
318
+ seed stage re-verifies everything built on top of it.
319
+ """
320
+ dependents: dict[int, list[int]] = {}
321
+ for num, deps in stages:
322
+ for dep in deps:
323
+ dependents.setdefault(dep, []).append(num)
324
+ result = set(seed)
325
+ queue = list(seed)
326
+ while queue:
327
+ cur = queue.pop()
328
+ for child in dependents.get(cur, []):
329
+ if child not in result:
330
+ result.add(child)
331
+ queue.append(child)
332
+ return result
333
+
334
+
309
335
  def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bool:
310
336
  """True iff ``ancestor`` is an ancestor of ``descendant`` in git history."""
311
337
  from .worktree import is_ancestor
@@ -445,6 +445,24 @@
445
445
  "skippedAdrCandidates": {
446
446
  "type": "array",
447
447
  "items": { "$ref": "#/$defs/SkippedAdrCandidate" }
448
+ },
449
+ "incrementalDecision": {
450
+ "type": "object",
451
+ "description": "Verbatim `okstra incremental-scope` output for a clarification re-run. Present only when this implementation-planning run scoped itself against a prior run; drives the Section 0 incremental audit block.",
452
+ "required": ["mode", "reverifyStages", "carryStages", "reason"],
453
+ "additionalProperties": false,
454
+ "properties": {
455
+ "mode": { "type": "string", "enum": ["incremental", "full"] },
456
+ "reverifyStages": {
457
+ "type": "array",
458
+ "items": { "type": "integer" }
459
+ },
460
+ "carryStages": {
461
+ "type": "array",
462
+ "items": { "type": "integer" }
463
+ },
464
+ "reason": { "type": "string", "minLength": 1 }
465
+ }
448
466
  }
449
467
  }
450
468
  },
@@ -1500,6 +1518,7 @@
1500
1518
  "id": { "type": "string", "minLength": 1 },
1501
1519
  "subject": { "type": "string", "minLength": 1 },
1502
1520
  "sourceSection": { "type": "string" },
1521
+ "carriedForwardFromSeq": { "type": "string" },
1503
1522
  "clarificationId": {
1504
1523
  "anyOf": [
1505
1524
  { "type": "null" },
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: okstra-pr-gen
3
+ description: Use when the user wants to register a PR body template or generate a PR description from a branch diff using a stored (or default) template. Manages templates under ~/.okstra/template/pr and drives okstra pr {template,branches,gen}. Trigger words include "okstra pr", "PR 템플릿 등록", "PR 본문 생성", "PR 메시지 만들어", "generate PR", "pr gen", "템플릿 등록".
4
+ ---
5
+
6
+ # okstra-pr-gen
7
+
8
+ Register PR body templates and generate PR descriptions. Templates live in the
9
+ user home at `~/.okstra/template/pr/`. This skill is **global** — it does not
10
+ require `<PROJECT_ROOT>/.okstra/project.json`. PR generation additionally
11
+ requires the current directory to be a git repository.
12
+
13
+ ## Step 0: Check CLI availability
14
+
15
+ Run as a separate Bash tool call with a literal leading token:
16
+
17
+ ```bash
18
+ okstra pr --help
19
+ ```
20
+
21
+ If `okstra` is not on PATH, tell the user:
22
+
23
+ `okstra not installed — run npx okstra@latest install once, then retry this skill.`
24
+
25
+ **Bash invocation rule:** every Bash command begins with the literal token
26
+ `okstra` and passes literal argument values. Do not introduce shell variables,
27
+ `$(...)`, leading `VAR=`, or wrap in `if`/`eval`/`||`/`&&` — that defeats the
28
+ `Bash(okstra:*)` permission match. Paste literal values emitted by a prior call.
29
+
30
+ ## Step 1: Pick the mode (always first)
31
+
32
+ Present a 3-option picker (`AskUserQuestion`):
33
+
34
+ 1. `PR 생성` — generate a PR body from a branch diff.
35
+ 2. `템플릿 등록` — save a new PR body template.
36
+ 3. `직접 입력` — always the last option (okstra picker convention).
37
+
38
+ ## Mode A — PR 생성
39
+
40
+ ### A1. Pick a template
41
+
42
+ ```bash
43
+ okstra pr template list --json
44
+ ```
45
+
46
+ - If `templates` is non-empty, present a picker: 1–2 recommended templates from
47
+ the list, plus `기본 템플릿`(the bundled default), plus `직접 입력` last.
48
+ - If empty, tell the user the bundled default template will be used.
49
+
50
+ Carry the chosen template name as `<template>` (`default` for the bundled one).
51
+
52
+ ### A2. Pick the base branch
53
+
54
+ ```bash
55
+ okstra pr branches --json
56
+ ```
57
+
58
+ Present a 3-option base picker from `recommended` (top entries) plus `직접 입력`
59
+ last. Carry the choice as `<base>`.
60
+
61
+ ### A3. Build the generation bundle and fill the template
62
+
63
+ ```bash
64
+ okstra pr gen --base <base> --template <template> --json
65
+ ```
66
+
67
+ Parse `{base, currentBranch, templateName, template, commits, diffStat}`. Then
68
+ read the full diff honestly — it is the source of truth:
69
+
70
+ ```bash
71
+ git diff <base>...HEAD
72
+ ```
73
+
74
+ For a large diff, read it in sections. Fill the `template` placeholders from the
75
+ diff and commits: describe only what actually changed. Mark checklist boxes
76
+ `[x]` only when the diff supports them (tests touched → tests box, docs touched →
77
+ docs box). If `commits`/`diffStat` are empty, tell the user there is nothing to
78
+ describe and stop. **Never** append AI trailers/footers.
79
+
80
+ ### A4. Output and offer to create the PR
81
+
82
+ Print the filled PR body to chat as a single fenced markdown block. Then ask the
83
+ user whether to open a real PR. Only on an explicit yes:
84
+
85
+ ```bash
86
+ gh pr create --base <base> --title "<title>" --body-file <path-to-body>
87
+ ```
88
+
89
+ Write the body to a temp file first and pass `--body-file`. If `gh` is missing
90
+ or not authenticated (`gh auth status` fails), leave the text in chat and tell
91
+ the user to create the PR manually. Do not push or create the PR without the
92
+ user's explicit confirmation.
93
+
94
+ ## Mode B — 템플릿 등록
95
+
96
+ 1. Ask for a template name (`AskUserQuestion`, free text). It must match
97
+ `^[A-Za-z0-9._-]+$`; re-ask on invalid input.
98
+ 2. Ask for the template body — either pasted text or an absolute file path.
99
+ 3. Save it:
100
+
101
+ ```bash
102
+ okstra pr template add --name <name> --file <abs-path>
103
+ ```
104
+
105
+ or, for pasted text, `--content "<body>"`. Add `--yes` only if the user
106
+ confirmed overwriting an existing template of the same name. Report the
107
+ saved path (`saved: ...`) back to the user.
@@ -43,6 +43,19 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
43
43
  {{ t("sectionIntro.clarificationCarryIn") }}
44
44
 
45
45
  - Source file: `{{ clarificationCarryIn.sourceFile }}`
46
+ {% if implementationPlanning and implementationPlanning.incrementalDecision %}
47
+ {% set incrementalDecision = implementationPlanning.incrementalDecision %}
48
+ ### 0.1 Incremental Re-Verification Scope
49
+
50
+ - Decision mode: `{{ incrementalDecision.mode }}`
51
+ - Reason: {{ incrementalDecision.reason }}
52
+ {% if incrementalDecision.mode == 'incremental' %}
53
+ - Re-verified stages: {% for s in incrementalDecision.reverifyStages %}`stage-{{ s }}`{% if not loop.last %}, {% endif %}{% else %}(none){% endfor +%}
54
+ - Carried-forward stages: {% for s in incrementalDecision.carryStages %}`stage-{{ s }}` carried-forward (unchanged){% if not loop.last %}, {% endif %}{% else %}(none){% endfor +%}
55
+
56
+ Carried-forward plan items retain their prior verdicts verbatim; each such item carries a `carriedForwardFromSeq` tag pointing at the run it was verified in.
57
+ {% endif %}
58
+ {% endif %}
46
59
 
47
60
  {% endif %}
48
61
  ## Summary of the Problem or Verification Target
@@ -744,6 +744,14 @@ _EMPTY_CARRY_IN_SOURCE_RE = re.compile(
744
744
  re.MULTILINE,
745
745
  )
746
746
 
747
+ # Section 0 incremental audit sub-block. When the data.json records an
748
+ # `implementationPlanning.incrementalDecision` with `mode == "incremental"`,
749
+ # the rendered report MUST expose the decision so a reader can audit which
750
+ # stages were re-verified vs carried forward unchanged.
751
+ _INCREMENTAL_AUDIT_HEADING_RE = re.compile(
752
+ r"^###[ \t]+0\.1[ \t]+Incremental Re-Verification Scope\b", re.MULTILINE
753
+ )
754
+
747
755
  # Deprecated section headings removed by the report-format readability
748
756
  # pass. Each entry is (regex, human-readable remedy). The regexes are
749
757
  # line-anchored to avoid false positives from inline references in prose
@@ -971,6 +979,44 @@ def _validate_conformance(report_path: Path, failures: list[str],
971
979
  )
972
980
 
973
981
 
982
+ def _check_incremental_audit_block(
983
+ report_path: Path, content: str, failures: list[str]
984
+ ) -> None:
985
+ """Enforce the Section 0 incremental audit block.
986
+
987
+ When the data.json records an `implementationPlanning.incrementalDecision`
988
+ with `mode == "incremental"`, the rendered markdown MUST carry the
989
+ `### 0.1 Incremental Re-Verification Scope` block naming the re-verified
990
+ and carried-forward stages — otherwise the narrowed re-run is silently
991
+ un-auditable. Non-incremental (`full`) runs and reports without the
992
+ decision are exempt, so this never conflicts with the empty-Section-0
993
+ stub rule (that fires only when NO carry-in was provided at all).
994
+ """
995
+ data = _load_final_report_data(report_path)
996
+ planning = data.get("implementationPlanning")
997
+ if not isinstance(planning, dict):
998
+ return
999
+ decision = planning.get("incrementalDecision")
1000
+ if not isinstance(decision, dict) or decision.get("mode") != "incremental":
1001
+ return
1002
+ if _INCREMENTAL_AUDIT_HEADING_RE.search(content) is None:
1003
+ failures.append(
1004
+ "final report data.json records an `incremental`-mode "
1005
+ "implementationPlanning.incrementalDecision, but the markdown is "
1006
+ "missing the `### 0.1 Incremental Re-Verification Scope` audit "
1007
+ "block under Section 0. Re-render from the data.json so the "
1008
+ "re-verified / carried-forward stages are visible."
1009
+ )
1010
+ return
1011
+ for needle in ("Re-verified stages", "Carried-forward stages"):
1012
+ if needle not in content:
1013
+ failures.append(
1014
+ f"final report's Section 0 incremental audit block is missing "
1015
+ f"the `{needle}` line — an `incremental`-mode run must list "
1016
+ "both the re-verified and the carried-forward stages."
1017
+ )
1018
+
1019
+
974
1020
  def validate_report(
975
1021
  report_path: Path,
976
1022
  required_agent_status_entries: list[str],
@@ -1064,6 +1110,10 @@ def validate_report(
1064
1110
  "and body — do NOT emit a placeholder stub."
1065
1111
  )
1066
1112
 
1113
+ # Incremental audit block — an `incremental`-mode re-run must expose its
1114
+ # scope decision in Section 0 so the narrowed re-verification is auditable.
1115
+ _check_incremental_audit_block(report_path, content, failures)
1116
+
1067
1117
  # Deprecated section headings — pre-1.0 hard removal.
1068
1118
  for pattern, remedy in _DEPRECATED_FINAL_REPORT_PATTERNS:
1069
1119
  if pattern.search(content) is not None:
@@ -305,6 +305,20 @@ export const COMMAND_REGISTRY = [
305
305
  category: "introspection",
306
306
  summary: ["Append run error events to the run error log"],
307
307
  },
308
+ {
309
+ name: "incremental-scope",
310
+ module: "./commands/execute/incremental-scope.mjs",
311
+ export: "run",
312
+ category: "introspection",
313
+ summary: ["Decide re-verify vs carry-forward scope for a clarification re-run"],
314
+ },
315
+ {
316
+ name: "incremental-carry",
317
+ module: "./commands/execute/incremental-carry.mjs",
318
+ export: "run",
319
+ category: "introspection",
320
+ summary: ["Merge carried-forward plan-item verdicts into an incremental re-run"],
321
+ },
308
322
  {
309
323
  name: "memory",
310
324
  module: "./commands/memory/memory.mjs",
@@ -315,6 +329,16 @@ export const COMMAND_REGISTRY = [
315
329
  "~/.okstra/memory-book.",
316
330
  ],
317
331
  },
332
+ {
333
+ name: "pr",
334
+ module: "./commands/pr/pr.mjs",
335
+ export: "run",
336
+ category: "introspection",
337
+ summary: [
338
+ "Manage PR body templates and prepare PR generation",
339
+ "bundles under ~/.okstra/template/pr.",
340
+ ],
341
+ },
318
342
  ];
319
343
 
320
344
  export const COMMANDS = new Map(
@@ -0,0 +1,20 @@
1
+ import { runInstalledScript } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra incremental-carry — merge carried-forward plan-item verdicts into an incremental re-run's data.json
4
+
5
+ Wraps the python helper (\`okstra-incremental-carry.py\`) installed under
6
+ \`~/.okstra/bin/\` so the report-writer worker calls \`okstra incremental-carry\`
7
+ instead of emitting a \`python3 "$HOME/..."\` invocation (which breaks
8
+ \`Bash(okstra:*)\` permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra incremental-carry --prev-data <path> --cur-data <path> \\
12
+ --prev-seq <str> [--carry-stages <csv>] --out <path>
13
+
14
+ Writes the merged data.json to --out. Exits non-zero on schema drift
15
+ (\`CarryError\`) so the caller's shell can fall back to a full re-run.
16
+ `;
17
+
18
+ export async function run(args) {
19
+ return runInstalledScript({ scriptName: "okstra-incremental-carry.py", args, usage: USAGE });
20
+ }
@@ -0,0 +1,20 @@
1
+ import { runInstalledScript } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra incremental-scope — decide the re-verify vs carry-forward scope for a clarification re-run
4
+
5
+ Wraps the python helper (\`okstra-incremental-scope.py\`) installed under
6
+ \`~/.okstra/bin/\` so the lead prompt calls \`okstra incremental-scope\`
7
+ instead of emitting a \`python3 "$HOME/..."\` invocation (which breaks
8
+ \`Bash(okstra:*)\` permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra incremental-scope --prev-data <path> --cur-base-sha <sha> \\
12
+ --prev-base-sha <sha> [--impacted <csv>] [--option <name>]
13
+
14
+ Prints a JSON decision {mode, reverify_stages, carry_stages, reason} to stdout.
15
+ All arguments are forwarded verbatim to the python helper.
16
+ `;
17
+
18
+ export async function run(args) {
19
+ return runInstalledScript({ scriptName: "okstra-incremental-scope.py", args, usage: USAGE });
20
+ }
@@ -0,0 +1,21 @@
1
+ ## **Please check if the PR fulfills these requirements**
2
+ - [ ] Commits have a single intent
3
+ - [ ] Tests for the changes have been added (for bug fixes / features)
4
+ - [ ] I reviewed my own code
5
+ - [ ] I tested the changes (if not, explain why in the "Other information" section)
6
+ - [ ] Docs have been added / updated
7
+
8
+ ## **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...)
9
+ <pr-kind>
10
+
11
+ ## **What is the current behavior?** (You can also link to an open issue here)
12
+ <current-behavior>
13
+
14
+ ## **What is the new behavior (if this is a feature change)?**
15
+ <new-behavior>
16
+
17
+ ## **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?)
18
+ <breaking-change>
19
+
20
+ ## **Other information**:
21
+ <other-information>
@@ -0,0 +1,261 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { stdin as input, stdout as output } from "node:process";
7
+ import { resolveOkstraHome } from "../../lib/paths.mjs";
8
+
9
+ const NAME_RE = /^[A-Za-z0-9._-]+$/;
10
+ const DEFAULT_TEMPLATE_PATH = join(
11
+ dirname(fileURLToPath(import.meta.url)),
12
+ "default.md",
13
+ );
14
+
15
+ const USAGE = `okstra pr — PR body templates and PR generation
16
+
17
+ Usage:
18
+ okstra pr template list [--json]
19
+ okstra pr template show <name|default> [--json]
20
+ okstra pr template add --name <name> (--content <text> | --file <path>) [--yes]
21
+ okstra pr template path
22
+ okstra pr branches [--json]
23
+ okstra pr gen --base <ref> [--template <name|default>] [--json]
24
+ `;
25
+
26
+ export function isValidTemplateName(name) {
27
+ return (
28
+ typeof name === "string" &&
29
+ NAME_RE.test(name) &&
30
+ name !== "." &&
31
+ name !== ".."
32
+ );
33
+ }
34
+
35
+ export function templateDir(home) {
36
+ return join(home, "template", "pr");
37
+ }
38
+
39
+ export async function listTemplates(home) {
40
+ let entries;
41
+ try {
42
+ entries = await fs.readdir(templateDir(home));
43
+ } catch (err) {
44
+ if (err.code === "ENOENT") return [];
45
+ throw err;
46
+ }
47
+ return entries
48
+ .filter((f) => f.endsWith(".md"))
49
+ .map((f) => f.slice(0, -3))
50
+ .sort();
51
+ }
52
+
53
+ async function readBundledDefault() {
54
+ return fs.readFile(DEFAULT_TEMPLATE_PATH, "utf8");
55
+ }
56
+
57
+ export async function readTemplate(home, name) {
58
+ if (!name || name === "default") return readBundledDefault();
59
+ if (!isValidTemplateName(name)) throw new Error(`invalid template name: ${name}`);
60
+ try {
61
+ return await fs.readFile(join(templateDir(home), `${name}.md`), "utf8");
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") return readBundledDefault();
64
+ throw err;
65
+ }
66
+ }
67
+
68
+ export async function addTemplate(home, name, content, opts = {}) {
69
+ if (!isValidTemplateName(name)) throw new Error(`invalid template name: ${name}`);
70
+ const dir = templateDir(home);
71
+ await fs.mkdir(dir, { recursive: true });
72
+ const target = join(dir, `${name}.md`);
73
+ if (!opts.force) {
74
+ try {
75
+ await fs.access(target);
76
+ throw new Error(`template exists: ${name} (use --yes to overwrite)`);
77
+ } catch (err) {
78
+ if (err.code !== "ENOENT") throw err;
79
+ }
80
+ }
81
+ await fs.writeFile(target, content, "utf8");
82
+ return target;
83
+ }
84
+
85
+ function takeValue(args, i, flag) {
86
+ const v = args[i + 1];
87
+ if (!v || v.startsWith("--")) throw new Error(`${flag} requires a value`);
88
+ return v;
89
+ }
90
+
91
+ async function readAddContent(opts) {
92
+ if (opts.content !== null) return opts.content;
93
+ if (opts.file !== null) return fs.readFile(resolve(opts.file), "utf8");
94
+ throw new Error("template add requires --content or --file");
95
+ }
96
+
97
+ async function confirm(question) {
98
+ const rl = createInterface({ input, output });
99
+ try {
100
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
101
+ return answer === "y" || answer === "yes";
102
+ } finally {
103
+ rl.close();
104
+ }
105
+ }
106
+
107
+ async function cmdTemplate(args, home) {
108
+ const sub = args[0];
109
+ const rest = args.slice(1);
110
+ const json = rest.includes("--json");
111
+ if (sub === "list") {
112
+ const names = await listTemplates(home);
113
+ if (json) output.write(JSON.stringify({ templates: names }) + "\n");
114
+ else output.write(names.length ? names.join("\n") + "\n" : "(no templates)\n");
115
+ return 0;
116
+ }
117
+ if (sub === "show") {
118
+ const name = rest.find((a) => !a.startsWith("--"));
119
+ if (!name) throw new Error("template show requires a name");
120
+ const body = await readTemplate(home, name);
121
+ output.write(json ? JSON.stringify({ name, body }) + "\n" : body);
122
+ return 0;
123
+ }
124
+ if (sub === "path") {
125
+ output.write(templateDir(home) + "\n");
126
+ return 0;
127
+ }
128
+ if (sub === "add") {
129
+ const opts = { name: null, content: null, file: null, yes: false };
130
+ for (let i = 0; i < rest.length; i++) {
131
+ const flag = rest[i];
132
+ if (flag === "--name") opts.name = takeValue(rest, i++, flag);
133
+ else if (flag === "--content") opts.content = takeValue(rest, i++, flag);
134
+ else if (flag === "--file") opts.file = takeValue(rest, i++, flag);
135
+ else if (flag === "--yes") opts.yes = true;
136
+ else throw new Error(`unknown flag ${flag}`);
137
+ }
138
+ if (!opts.name) throw new Error("template add requires --name");
139
+ const content = await readAddContent(opts);
140
+ const dir = templateDir(home);
141
+ const target = join(dir, `${opts.name}.md`);
142
+ let force = opts.yes;
143
+ if (!force && isValidTemplateName(opts.name)) {
144
+ let exists = false;
145
+ try {
146
+ await fs.access(target);
147
+ exists = true;
148
+ } catch (err) {
149
+ if (err.code !== "ENOENT") throw err;
150
+ }
151
+ if (exists) {
152
+ if (!process.stdin.isTTY) {
153
+ throw new Error(`template exists: ${opts.name} (use --yes to overwrite)`);
154
+ }
155
+ force = await confirm(`Overwrite existing template ${opts.name}?`);
156
+ if (!force) { output.write("aborted\n"); return 1; }
157
+ }
158
+ }
159
+ const saved = await addTemplate(home, opts.name, content, { force: true });
160
+ output.write(`saved: ${saved}\n`);
161
+ return 0;
162
+ }
163
+ throw new Error(`unknown template subcommand: ${sub ?? "(none)"}`);
164
+ }
165
+
166
+ function tryGit(cwd, args) {
167
+ try {
168
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ export function recommendBranches(cwd) {
175
+ const currentBranch = tryGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
176
+ if (currentBranch === null) return { currentBranch: null, recommended: [] };
177
+ const ordered = [];
178
+ const remoteDefault = tryGit(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
179
+ if (remoteDefault) {
180
+ const bare = remoteDefault.replace(/^origin\//, "");
181
+ if (tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${bare}^{commit}`]) !== null) ordered.push(bare);
182
+ else if (tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${remoteDefault}^{commit}`]) !== null) ordered.push(remoteDefault);
183
+ }
184
+ for (const b of ["main", "master", "develop"]) {
185
+ const local = tryGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${b}`]);
186
+ if (local !== null) ordered.push(b);
187
+ }
188
+ const recent = tryGit(cwd, [
189
+ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads",
190
+ ]);
191
+ if (recent) ordered.push(...recent.split("\n").filter(Boolean));
192
+ const recommended = [];
193
+ for (const b of ordered) {
194
+ if (b && b !== currentBranch && !recommended.includes(b)) recommended.push(b);
195
+ }
196
+ return { currentBranch, recommended };
197
+ }
198
+
199
+ function cmdBranches(args, cwd) {
200
+ const json = args.includes("--json");
201
+ const r = recommendBranches(cwd);
202
+ if (json) output.write(JSON.stringify(r) + "\n");
203
+ else output.write((r.recommended.join("\n") || "(no branches)") + "\n");
204
+ return 0;
205
+ }
206
+
207
+ export async function buildGenBundle({ cwd, home, base, templateName }) {
208
+ if (!base) throw new Error("gen requires --base");
209
+ const verified = tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
210
+ if (verified === null) throw new Error(`base ref not found: ${base}`);
211
+ const currentBranch = tryGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
212
+ const logRaw = tryGit(cwd, [
213
+ "log", "--reverse", "--format=%h%x09%s", `${base}..HEAD`,
214
+ ]) || "";
215
+ const commits = logRaw
216
+ .split("\n")
217
+ .filter(Boolean)
218
+ .map((line) => {
219
+ const tab = line.indexOf("\t");
220
+ return { hash: line.slice(0, tab), subject: line.slice(tab + 1) };
221
+ });
222
+ const diffStat = tryGit(cwd, ["diff", "--stat", `${base}...HEAD`]) || "";
223
+ const template = await readTemplate(home, templateName);
224
+ const resolvedName = templateName && templateName !== "default"
225
+ ? templateName
226
+ : "default";
227
+ return { base, currentBranch, templateName: resolvedName, template, commits, diffStat };
228
+ }
229
+
230
+ async function cmdGen(args, cwd, home) {
231
+ const opts = { base: null, template: "default" };
232
+ for (let i = 0; i < args.length; i++) {
233
+ const flag = args[i];
234
+ if (flag === "--base") opts.base = takeValue(args, i++, flag);
235
+ else if (flag === "--template") opts.template = takeValue(args, i++, flag);
236
+ else if (flag === "--json") continue;
237
+ else throw new Error(`unknown flag ${flag}`);
238
+ }
239
+ const bundle = await buildGenBundle({ cwd, home, base: opts.base, templateName: opts.template });
240
+ output.write(JSON.stringify(bundle) + "\n");
241
+ return 0;
242
+ }
243
+
244
+ export async function run(args) {
245
+ if (args.length === 0 || args.includes("--help")) {
246
+ output.write(USAGE);
247
+ return 0;
248
+ }
249
+ const home = resolveOkstraHome();
250
+ const sub = args[0];
251
+ const rest = args.slice(1);
252
+ try {
253
+ if (sub === "template") return await cmdTemplate(rest, home);
254
+ if (sub === "branches") return cmdBranches(rest, process.cwd());
255
+ if (sub === "gen") return await cmdGen(rest, process.cwd(), home);
256
+ throw new Error(`unknown pr subcommand: ${sub}`);
257
+ } catch (err) {
258
+ process.stderr.write(`error: ${err.message}\n`);
259
+ return 1;
260
+ }
261
+ }
@@ -15,6 +15,7 @@ export const USER_SKILL_NAMES = Object.freeze([
15
15
  "okstra-schedule",
16
16
  "okstra-container-build",
17
17
  "okstra-graphify",
18
+ "okstra-pr-gen",
18
19
  ]);
19
20
 
20
21
  // Names okstra used to install as skills before they were renamed or moved