okstra 0.66.0 → 0.68.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 (32) hide show
  1. package/bin/okstra +7 -0
  2. package/docs/kr/architecture.md +17 -1
  3. package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
  4. package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
  5. package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
  6. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
  7. package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
  8. package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
  9. package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/SKILL.md +5 -4
  13. package/runtime/prompts/profiles/_common-contract.md +6 -6
  14. package/runtime/prompts/profiles/final-verification.md +3 -2
  15. package/runtime/prompts/profiles/release-handoff.md +12 -5
  16. package/runtime/prompts/wizard/prompts.ko.json +14 -4
  17. package/runtime/python/okstra_ctl/consumers.py +72 -5
  18. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  19. package/runtime/python/okstra_ctl/handoff.py +348 -0
  20. package/runtime/python/okstra_ctl/render.py +44 -2
  21. package/runtime/python/okstra_ctl/run.py +88 -27
  22. package/runtime/python/okstra_ctl/wizard.py +141 -36
  23. package/runtime/python/okstra_ctl/worktree.py +10 -0
  24. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  25. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  26. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
  28. package/runtime/skills/okstra-run/SKILL.md +45 -5
  29. package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
  30. package/runtime/validators/validate-run.py +49 -9
  31. package/src/git-reconcile.mjs +31 -0
  32. package/src/handoff.mjs +30 -0
@@ -46,13 +46,17 @@ def _okstra_worktrees_dir() -> Path:
46
46
  def task_key(
47
47
  project_id: str, task_group: str, task_id: str,
48
48
  stage_number: Optional[int] = None,
49
+ group_id: Optional[str] = None,
49
50
  ) -> str:
50
- """Canonical task-key. With stage_number, returns the stage-scoped
51
- key `<proj>/<group>/<task>#stage-<N>` used to reserve a per-stage
52
- worktree independently of the task-key entry."""
51
+ """Canonical task-key. stage_number `#stage-<N>` (per-stage worktree),
52
+ group_id → `#group-<id>` (stage-group collector worktree). 둘은 상호배타."""
53
+ if stage_number is not None and group_id is not None:
54
+ raise ValueError("stage_number and group_id are mutually exclusive")
53
55
  base = f"{project_id}/{task_group}/{task_id}"
54
56
  if stage_number is not None:
55
57
  return f"{base}#stage-{stage_number}"
58
+ if group_id is not None:
59
+ return f"{base}#group-{group_id}"
56
60
  return base
57
61
 
58
62
 
@@ -70,6 +74,7 @@ class WorktreeEntry:
70
74
  status: str = "active" # "active" | "released"
71
75
  stage: Optional[int] = None
72
76
  implementation_base_commit: str = ""
77
+ stages: Optional[list] = None
73
78
 
74
79
 
75
80
  @contextlib.contextmanager
@@ -119,8 +124,9 @@ def _save(data: dict) -> None:
119
124
  def lookup(
120
125
  project_id: str, task_group: str, task_id: str,
121
126
  stage_number: Optional[int] = None,
127
+ group_id: Optional[str] = None,
122
128
  ) -> Optional[WorktreeEntry]:
123
- key = task_key(project_id, task_group, task_id, stage_number)
129
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
124
130
  with _registry_lock():
125
131
  data = _load()
126
132
  row = data["tasks"].get(key)
@@ -139,18 +145,20 @@ def reserve(
139
145
  base_ref: str,
140
146
  phase: str = "",
141
147
  stage_number: Optional[int] = None,
148
+ group_id: Optional[str] = None,
149
+ stages: Optional[list] = None,
142
150
  ) -> WorktreeEntry:
143
151
  """Atomically insert a new entry. Raises RuntimeError if the
144
152
  task-key already exists or the branch is already owned by a
145
153
  different task-key. Callers should `lookup()` first when re-entry
146
154
  is expected.
147
155
  """
148
- key = task_key(project_id, task_group, task_id, stage_number)
156
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
149
157
  now = time.strftime("%Y-%m-%dT%H:%M:%S%z") or time.strftime("%Y-%m-%dT%H:%M:%S")
150
158
  with _registry_lock():
151
159
  data = _load()
152
- if key in data["tasks"]:
153
- existing = data["tasks"][key]
160
+ existing = data["tasks"].get(key)
161
+ if existing and existing.get("status") != "released":
154
162
  raise RuntimeError(
155
163
  f"task-key already has a worktree registered: {key} → "
156
164
  f"{existing['worktree_path']} (branch {existing['branch']}). "
@@ -174,6 +182,7 @@ def reserve(
174
182
  "last_phase": phase,
175
183
  "status": "active",
176
184
  "stage": stage_number,
185
+ "stages": stages,
177
186
  }
178
187
  data["tasks"][key] = row
179
188
  data["branches"][branch] = key
@@ -218,6 +227,24 @@ def set_implementation_base(
218
227
  return commit
219
228
 
220
229
 
230
+ def reset_implementation_base(
231
+ project_id: str, task_group: str, task_id: str, commit: str,
232
+ ) -> str:
233
+ """anchor 를 의식적으로 재고정한다. 유일한 호출자는 git-reconcile 의
234
+ `--reset-anchor` — prepare 경로는 절대 anchor 를 움직이지 않는다."""
235
+ key = task_key(project_id, task_group, task_id)
236
+ with _registry_lock():
237
+ data = _load()
238
+ row = data["tasks"].get(key)
239
+ if row is None:
240
+ raise RuntimeError(
241
+ f"no task-key entry to reset implementation base: {key}"
242
+ )
243
+ row["implementation_base_commit"] = commit
244
+ _save(data)
245
+ return commit
246
+
247
+
221
248
  def get_implementation_base(
222
249
  project_id: str, task_group: str, task_id: str,
223
250
  ) -> Optional[str]:
@@ -262,13 +289,17 @@ def list_active_stage_numbers(
262
289
  return out
263
290
 
264
291
 
265
- def release(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
292
+ def release(
293
+ project_id: str, task_group: str, task_id: str,
294
+ stage_number: Optional[int] = None,
295
+ group_id: Optional[str] = None,
296
+ ) -> Optional[WorktreeEntry]:
266
297
  """Mark the entry as `released` (worktree dir intact — preservation
267
298
  is the project's policy). The branch index is freed so future
268
299
  reservations of the same branch name are not blocked.
269
300
  Returns the prior entry, or None when not found.
270
301
  """
271
- key = task_key(project_id, task_group, task_id)
302
+ key = task_key(project_id, task_group, task_id, stage_number, group_id)
272
303
  with _registry_lock():
273
304
  data = _load()
274
305
  row = data["tasks"].get(key)
@@ -155,6 +155,6 @@ Information to be obtained after executing this skill:
155
155
  - Reference list of config files/deployment manifests and task-level expected values
156
156
  - Current run status and presence of existing worker results
157
157
  - Current run prompt history contract for attempted workers
158
- - Candidate `teamName` for Phase 3 hand-off: `okstra-<task-key>` (with task-key slugified per Step 1's slug rule)
158
+ - Candidate `teamName` for Phase 3 hand-off: `okstra-<task-key>` (with task-key slugified per Step 1's slug rule); implementation stage runs append `-s<N>` — the launch prompt's Team Creation Gate block carries the final name verbatim
159
159
  - Current Claude `lead.sessionId` (the in-flight Claude Code session) — required by `okstra-team-contract` when registering the lead in `team-state.json`
160
160
  - Resume command path: from `task-manifest.json` → `latestResumeCommandPath` (fallback: latest `runs/<task-type>/sessions/claude-resume-*.sh` by mtime). Never reconstruct the filename — the `<seq>` counter is category-local and may diverge from `manifests/`.
@@ -264,7 +264,7 @@ Agent(
264
264
  prompt: "<re-verification prompt with findings batch>",
265
265
  name: "<role-slug>-reverify-r<N>",
266
266
  subagent_type: "<same as initial execution>",
267
- team_name: "okstra-<task-key>",
267
+ team_name: "<teamName recorded in team-state>",
268
268
  model: "<same as initial execution>",
269
269
  mode: "auto"
270
270
  )
@@ -34,7 +34,7 @@ Agent(
34
34
  prompt: "<report-writer prompt: see this skill + Required reading clause + Available MCP Servers section>",
35
35
  name: "report-writer",
36
36
  subagent_type: "report-writer-worker",
37
- team_name: "okstra-<task-key>", # omit if team is not alive — see Resume-safe dispatch
37
+ team_name: "<teamName recorded in team-state>", # omit if team is not alive — see Resume-safe dispatch
38
38
  model: "<family token of Report writer worker's modelExecutionValue>", # opus/sonnet/haiku — NOT hardcoded; see below
39
39
  mode: "auto"
40
40
  )
@@ -68,7 +68,7 @@ The prompt MUST include, in this order at the top:
68
68
 
69
69
  A resumed lead session can ALWAYS dispatch a fresh Report writer worker. The Agent tool does not require a previously created Team to be alive:
70
70
 
71
- - If `TeamCreate` for `okstra-<task-key>` still succeeds (or the team is still listed), include `team_name` in the dispatch.
71
+ - If `TeamCreate` for the team-state `teamName` still succeeds (or the team is still listed), include `team_name` in the dispatch.
72
72
  - If `TeamCreate` reports the name is taken or the team is gone, omit `team_name` from the dispatch — the worker still runs as a background subagent and its session is still recoverable by `agentName: "report-writer"` in `okstra-token-usage.py`.
73
73
  - Do NOT skip dispatch because of any team-related error. Record the team status in team-state and proceed without `team_name`.
74
74
 
@@ -45,8 +45,9 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
45
45
  - `kind: "pick_group"` → render a SINGLE `AskUserQuestion` whose questions array maps 1:1 to the wizard's `questions[]`. For each entry use `questions[].label`, `questions[].options[].label`, and `multiSelect: questions[].multi`. Collect the user's chosen `options[].value` per tab, build a JSON object keyed by each `questions[].step`, and submit it as a single literal `--answer '{"lead_model":"opus","claude_model":"default",...}'`. A tab the user leaves at its default still gets its `"default"`/`""` value in the JSON. Never split a `pick_group` into multiple `AskUserQuestion` calls — the wizard already capped it at 4 tabs and emits any remainder as the next prompt.
46
46
  - `kind: "text"` → write `label` as a plain text message and consume the user's NEXT message as the answer.
47
47
  - `kind: "done"` → input collection finished; move to Step 5.
48
+ - `kind: "aborted"` → the user picked 중단; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
48
49
 
49
- The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed.
50
+ The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new worktree would be created.
50
51
 
51
52
  Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. Do NOT collapse a multi-option pick into a "recommended + 직접 입력 / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: the `executor` step always emits `claude` / `codex` / `gemini` — show all three, never just `claude`. The run-prompt recommendation rule (1–2 추천 + 직접 입력) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
52
53
 
@@ -92,7 +93,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
92
93
 
93
94
  ## Step 3: Run the prompt loop
94
95
 
95
- Repeat until `next.kind == "done"`:
96
+ Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
96
97
 
97
98
  1. **Render** the prompt according to `kind` (and `multi` for pick):
98
99
  - `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
@@ -118,8 +119,8 @@ Repeat until `next.kind == "done"`:
118
119
 
119
120
  That is the entire interactive flow. The wizard handles:
120
121
 
121
- - new-vs-existing task split, task-group / task-id slug validation,
122
- - task-type pick (with `nextRecommendedPhase` surfaced as recommended for existing tasks),
122
+ - new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (각각 최근 후보 3개 추천 + 직접 입력),
123
+ - task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
123
124
  - brief path after task-group selection (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing tasks),
124
125
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
125
126
  - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
@@ -178,7 +179,7 @@ okstra render-bundle \
178
179
  --pr-template-path "<args.pr-template-path>"
179
180
  ```
180
181
 
181
- `render-bundle` auto-supplies `--workspace-root` and forces `--render-only`. Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full rendered lead prompt. Parse the labelled lines for `TASK_ROOT` and `INSTRUCTION_SET_PATH`.
182
+ `render-bundle` auto-supplies `--workspace-root` and forces `--render-only`. Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full rendered lead prompt. Parse the labelled lines for `TASK_ROOT` and `INSTRUCTION_SET_PATH`. Also watch for an optional `okstra concurrent-run stages:` label line — present only when a concurrent run is detected (see "동시-run 감지 분기" below).
182
183
 
183
184
  The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.lock`), writes `run-context-*.json` + `run-inputs-*.json` + all manifests + discovery files, and registers the run in `~/.okstra/recent.jsonl` with status `prepared`.
184
185
 
@@ -196,6 +197,45 @@ This is **never** a lead/worker self-exemption — only the user may waive. Offe
196
197
 
197
198
  When the user picks a waiver, append `--qa-waiver "<stageKey>:<reason>"` to the `render-bundle` invocation above. Omit the flag entirely otherwise (do **not** pass `--qa-waiver ""`). A malformed value or unknown `<stageKey>` aborts `render-bundle` with a `PrepareError`.
198
199
 
200
+ ### 동시-run 감지 분기 (concurrent-run)
201
+
202
+ `render-bundle` stdout 에 `okstra concurrent-run stages: <stages>` 라벨 라인이
203
+ 있으면(같은 task-key 의 다른 implementation run 이 `<stages>` 를 점유 중), launch
204
+ 프롬프트는 이미 "Concurrent-run: no-team background" 게이트로 렌더돼 있다. 이 라인이
205
+ 없으면 동시-run 이 아니므로 이 분기를 건너뛴다. 라인이 있으면 dispatch 전에
206
+ 사용자에게 3-옵션 recommendation picker 를 제시한다 (run-prompt recommendation 규칙:
207
+ 1–2 추천 + 직접 입력; 이 picker 는 스킬이 author 하는 것이라 wizard `options[]`
208
+ 제약과 무관):
209
+
210
+ 1. (추천) 이대로 no-team background 로 진행 — 이미 렌더된 bundle 을 그대로 사용한다.
211
+ team 을 만들지 않아 `~/.claude/teams/` race 를 회피한다(Teams split-pane 관찰성만 포기).
212
+ 2. 대기 — 지금 dispatch 를 보류한다. stage worktree·run-context 는 보존되므로,
213
+ 점유 중인 다른 run 종료 후 같은 stage 를 resume 으로 재개하면 그때는 정상 team
214
+ 경로다. resume 명령(`okstra-inspect` history → resume)을 사용자에게 출력한다.
215
+ 3. 직접 입력.
216
+
217
+ ### Stale git SHA recovery (git-reconcile gate)
218
+
219
+ `render-bundle` 이 `Recorded stage SHAs no longer match the git history` 를 포함한
220
+ `PrepareError` 로 실패하면, okstra 밖에서 git 히스토리가 바뀐 것이다(rebase /
221
+ squash / 리뷰 반영 amend / branch 삭제). 절대 registry/consumers 를 손으로
222
+ 고치지 말고 다음 순서로 회복한다:
223
+
224
+ 1. 에러 메시지에 인쇄된 `okstra git-reconcile … --check --json` 명령을 그대로 실행해
225
+ stale 리포트를 얻는다. (patch-id 로 내용 동일성이 증명되는 항목은 prepare
226
+ 가 이미 자동 화해했으므로, 여기 남는 것은 confirm 항목뿐이다.)
227
+ 2. confirm 항목별로 사용자에게 3-옵션 picker 를 제시한다:
228
+ - **`stage-<N>` branch 의 현재 tip 으로 재기록 (추천)** — 리뷰 반영 등
229
+ 의도된 수정이 그 branch 에 있을 때.
230
+ - **다른 ref 직접 입력** — 사용자가 commit/branch/tag 를 직접 지정.
231
+ - **중단** — 회복하지 않고 run 을 멈춘다.
232
+ 3. 선택된 ref 로 `okstra git-reconcile … --apply --stage <N> --use-ref <ref>`
233
+ 를 실행한 뒤, 실패했던 `render-bundle` 을 동일 인자로 재시도한다.
234
+
235
+ anchor(`implementation_base_commit`)가 unresolvable 로 보고되면 같은 명령의
236
+ `--reset-anchor <ref>` 를 사용자 확인 후 실행한다. picker 없이 confirm 항목을
237
+ 보정하는 것은 금지 — 런타임도 `--use-ref` 없는 confirm 보정을 거부한다.
238
+
199
239
  ## Step 6: Take over as Claude lead
200
240
 
201
241
  Read `<INSTRUCTION_SET_PATH>/claude-execution-prompt.md` verbatim and enter `Claude lead` mode. The lead prompt now points to compact intake artifacts first (`active-run-context`, `analysis-profile.md`, and `analysis-packet.md`); full source files such as `analysis-material.md`, `reference-expectations.md`, and `final-report-template.md` are lazy/fallback inputs. Follow the rendered prompt order, do not preempt it.
@@ -54,7 +54,7 @@ Only workers selected from `recommendedWorkers` in `task-manifest.json` and `res
54
54
 
55
55
  ## Operating Rules
56
56
 
57
- 0. **TeamCreate ordering (BLOCKING).** Before issuing any `Agent` dispatch that includes `team_name`, Lead MUST have called `TeamCreate(team_name: "okstra-<task-key>", ...)` in this run and recorded the outcome in team-state as `teamCreate: { attempted: true, status: "ok"|"error", error?: <message> }`. If the Agent tool rejects a dispatch with `"team must be created first or call without team_name"` / `"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct response is to go back to Phase 3 and call `TeamCreate` — NOT to strip `team_name` and retry. The no-`team_name` Phase 5 fallback is only legal when `teamCreate.status == "error"` is already recorded; otherwise stripping `team_name` silently degrades the run to in-process background dispatch and loses the Teams split-pane behavior. See [okstra agent SKILL.md Phase 3](../../agents/SKILL.md) for the full team-creation sequence.
57
+ 0. **TeamCreate ordering (BLOCKING).** Before issuing any `Agent` dispatch that includes `team_name`, Lead MUST have called `TeamCreate` with the exact team name from the launch prompt's Team Creation Gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`) in this run and recorded the outcome in team-state as `teamCreate: { attempted: true, status: "ok"|"error", error?: <message> }` plus the name as `teamName`. On a "team already exists" failure, follow the stale-team recovery in [okstra agent SKILL.md Phase 3 step 2-1](../../agents/SKILL.md) — never shell-delete `~/.claude/teams/...` on your own initiative. If the Agent tool rejects a dispatch with `"team must be created first or call without team_name"` / `"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct response is to go back to Phase 3 and call `TeamCreate` — NOT to strip `team_name` and retry. The no-`team_name` Phase 5 fallback is legal when `teamCreate.status == "error"` is already recorded, OR when the launch prompt's concurrent-run gate recorded `status: "skipped", reason: "concurrent-run"`; otherwise stripping `team_name` silently degrades the run to in-process background dispatch and loses the Teams split-pane behavior. See [okstra agent SKILL.md Phase 3](../../agents/SKILL.md) for the full team-creation sequence.
58
58
  1. `Claude lead` is responsible for orchestration, convergence supervision, and final-report review/approval. It never overrides worker analysis results, and it never authors the final-report file when `Report writer worker` is in the roster.
59
59
  2. `Report writer worker` is NOT an analysis worker. It is excluded from Phase 4/5 (initial analysis) and Phase 5.5 (convergence re-verification). It is spawned only in Phase 6 and is the **author** of the final-report file at `runs/<task-type>/reports/final-report-<task-type>-<seq>.md`.
60
60
  3. When `Report writer worker` is in the roster, Lead MUST dispatch it in Phase 6. The only legal lead-authored fallback is when a dispatch was attempted and recorded a terminal status of `error` / `timeout` / `not-run` with a concrete logged reason. Speculative reasons such as "session resume constraint" or "team is no longer alive" are NOT valid — Lead can always dispatch a fresh subagent (omit `team_name` if the team is gone).
@@ -406,7 +406,7 @@ okstra token-usage /abs/path/to/run/state/team-state-<task-type>-<seq>.json --wr
406
406
  `okstra token-usage` is a thin Node-side wrapper around the python helper installed at `~/.okstra/bin/okstra-token-usage.py`. Calling the python script directly with `python3 "$HOME/..."` is forbidden — the `$HOME` expansion breaks the literal-token permission match and forces a confirmation prompt every call.
407
407
 
408
408
  The script reads:
409
- - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
409
+ - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by the recorded team-state `teamName`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
410
410
  - `~/.codex/sessions/Y/M/D/rollout-*.jsonl` for the underlying Codex CLI session (matched by `cwd` and timestamp window of the wrapper subagent). Last `event_msg.token_count.total_token_usage.total_tokens` is the session total.
411
411
  - `~/.gemini/tmp/<project>/chats/session-*.json` for the underlying Gemini CLI session. Sum of per-message `tokens.total`.
412
412
 
@@ -748,6 +748,36 @@ def _parse_diff_summary_files(content: str) -> list[str]:
748
748
  return _DIFF_ROW_PATH_RE.findall(section.group(0))
749
749
 
750
750
 
751
+ _STAGE_RUN_DIR_RE = re.compile(r"^stage-\d+$")
752
+
753
+
754
+ def _implementation_stage_name(run_dir: Path) -> str | None:
755
+ """implementation stage 격리 run(`runs/implementation/stage-<N>`)이면
756
+ `stage-<N>` 을 반환. 그 외(final-verification 등 task-type 레벨 run)는
757
+ None — whole-task 스코프."""
758
+ if run_dir.parent.name == "implementation" and _STAGE_RUN_DIR_RE.match(run_dir.name):
759
+ return run_dir.name
760
+ return None
761
+
762
+
763
+ def _scope_manifest_entries(manifest: dict, stage_name: str | None) -> dict:
764
+ """게이트 평가 대상 entry 를 run 스코프로 좁힌 manifest 를 반환.
765
+
766
+ implementation 은 한 run = 한 stage 이므로 자기 stageKey
767
+ (`<task-id>-stage-<N>`) entry 만 게이트한다 — 다른 stage 는 각자의
768
+ implementation run / final-verification(whole-task) 이 검증한다.
769
+ suffix 매칭인 이유: stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라
770
+ task 디렉터리 segment 와 표기가 다를 수 있다.
771
+ """
772
+ if stage_name is None:
773
+ return manifest
774
+ entries = [
775
+ e for e in manifest.get("entries", [])
776
+ if isinstance(e, dict) and str(e.get("stageKey", "")).endswith(f"-{stage_name}")
777
+ ]
778
+ return {"entries": entries}
779
+
780
+
751
781
  def _task_root_from_run_dir(run_dir: Path) -> Path:
752
782
  """run_dir 에서 `runs` 디렉터리를 앵커로 task_root 를 복원한다.
753
783
 
@@ -770,6 +800,12 @@ def _validate_conformance(report_path: Path, failures: list[str],
770
800
  가 없다는 뜻 — 선언을 강제하는 것은 planning 계약(Phase 4)의 몫). 매니페스트가
771
801
  있으면 결과 사이드카와 함께 evaluate_conformance 로 판정하고 BLOCKING verdict
772
802
  를 run 검증 실패로 승격한다. WAIVED(conditional)/EXEMPT 는 통과시킨다.
803
+
804
+ 게이트 스코프: implementation stage 격리 run 은 자기 stage entry 만(결과
805
+ 게이트·diff-surface 교차검증 모두 — 미래 stage 의 미실행이 현재 run 을
806
+ 막으면 안 된다), final-verification 등 task-type 레벨 run 은 전 entry
807
+ (whole-task). prompts/profiles/_implementation-verifier.md §Tier 3 /
808
+ final-verification.md 의 스코프 계약과 동형.
773
809
  """
774
810
  # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
775
811
  # implementation/final-verification 가 공유한다. report_path 는
@@ -792,8 +828,9 @@ def _validate_conformance(report_path: Path, failures: list[str],
792
828
  if schema_errors:
793
829
  failures.extend(f"conformance manifest: {e}" for e in schema_errors)
794
830
  return
795
- results = _load_conformance_results(qa_dir, manifest)
796
- for verdict in evaluate_conformance(manifest, results):
831
+ scoped = _scope_manifest_entries(manifest, _implementation_stage_name(run_dir))
832
+ results = _load_conformance_results(qa_dir, scoped)
833
+ for verdict in evaluate_conformance(scoped, results):
797
834
  if not verdict.ok:
798
835
  failures.append(
799
836
  f"conformance gate BLOCKING for stage {verdict.stage_key}: "
@@ -803,13 +840,14 @@ def _validate_conformance(report_path: Path, failures: list[str],
803
840
  )
804
841
  changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
805
842
  if changed_files:
806
- uncovered = detect_surfaces(changed_files, surface_patterns) - manifest_required_surfaces(manifest)
843
+ uncovered = detect_surfaces(changed_files, surface_patterns) - manifest_required_surfaces(scoped)
807
844
  if uncovered:
808
845
  failures.append(
809
846
  "conformance gate BLOCKING: implementation diff touches undeclared "
810
- f"surface(s) {sorted(uncovered)} — no stage declares `requires` for "
811
- "them. Declare a conformance entry (requires=[...]) for the touching "
812
- "stage, or an explicit exemption. (silent mock-green 방지 — DEV-9184)"
847
+ f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
848
+ "`requires` for them. Declare a conformance entry (requires=[...]) "
849
+ "for the touching stage, or an explicit exemption. "
850
+ "(silent mock-green 방지 — DEV-9184)"
813
851
  )
814
852
 
815
853
 
@@ -1399,11 +1437,13 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
1399
1437
  f"final-verification: verificationScope must be `whole-task` or "
1400
1438
  f"`single-stage`, got {scope!r}."
1401
1439
  )
1402
- if scope == "single-stage" and "release-handoff" in routing:
1440
+ if (scope == "single-stage" and "release-handoff" in routing
1441
+ and "release-handoff(stage-group)" not in routing):
1403
1442
  failures.append(
1404
1443
  "final-verification: verificationScope `single-stage` cannot recommend "
1405
- "release-handoff routing — single-stage is a partial verification and "
1406
- "release-handoff requires whole-task verification."
1444
+ "plain release-handoff routing — a single-stage accepted verdict may "
1445
+ "only route to `release-handoff(stage-group)` (partial-PR mode); "
1446
+ "whole-task release-handoff requires whole-task verification."
1407
1447
  )
1408
1448
 
1409
1449
 
@@ -0,0 +1,31 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra git-reconcile — okstra 밖 git 히스토리 변경 후 기록 화해
4
+
5
+ A thin shim over \`python3 -m okstra_ctl.git_reconcile\`. 기본은 검사:
6
+ stale 항목을 JSON 으로 출력하고, confirm 항목이 남으면 exit 2.
7
+ patch-id 로 내용 동일성이 증명되는 항목(auto)은 --apply 로 일괄 보정되고,
8
+ 내용이 바뀐 항목(confirm)은 --stage/--use-ref 로만 보정된다.
9
+
10
+ Usage:
11
+ okstra git-reconcile --plan-run-root <dir> --project-id <id> \\
12
+ --task-group <g> --task-id <t> --work-category <c> \\
13
+ [--apply] [--stage <N> --use-ref <ref>] [--reset-anchor <ref>] [--json]
14
+
15
+ Exit codes:
16
+ 0 stale 없음 또는 보정 완료
17
+ 2 confirm 항목 잔존 (check: 확인 필요 / apply: 일부 미보정)
18
+ 1 error (resolve 실패 등)
19
+ `;
20
+
21
+ export async function run(args) {
22
+ if (args.includes("--help") || args.includes("-h")) {
23
+ process.stdout.write(USAGE);
24
+ return 0;
25
+ }
26
+ const { code } = await runPythonModule({
27
+ module: "okstra_ctl.git_reconcile",
28
+ args,
29
+ });
30
+ return code ?? 1;
31
+ }
@@ -0,0 +1,30 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra handoff — release-handoff stage-group 보조 (자격/수집/기록)
4
+
5
+ A thin shim over \`python3 -m okstra_ctl.handoff\`. JSON 출력.
6
+
7
+ Usage:
8
+ okstra handoff eligible --plan-run-root <dir> --approved-plan <md>
9
+ okstra handoff assemble --plan-run-root <dir> --approved-plan <md> \\
10
+ --project-root <dir> --project-id <id> --task-group <g> --task-id <t> \\
11
+ --work-category <c> --stages 2,3 --base <branch>
12
+ okstra handoff record-verified --plan-run-root <dir> --stage <N> \\
13
+ --report-path <md> --data-json <json>
14
+ okstra handoff record-pr --plan-run-root <dir> --stages 2,3 \\
15
+ --branch <b> --url <u>
16
+
17
+ Exit codes: 0 ok / 1 자격·전제 위반 / 2 stage 간 merge 충돌(conflicts 동봉)
18
+ `;
19
+
20
+ export async function run(args) {
21
+ if (args.includes("--help") || args.includes("-h")) {
22
+ process.stdout.write(USAGE);
23
+ return 0;
24
+ }
25
+ const { code } = await runPythonModule({
26
+ module: "okstra_ctl.handoff",
27
+ args,
28
+ });
29
+ return code ?? 1;
30
+ }