okstra 0.199.4 → 0.200.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 (42) hide show
  1. package/README.md +4 -1
  2. package/dist/lib/runtime-payload.mjs +1 -0
  3. package/dist/lib/runtime-payload.mjs.map +1 -1
  4. package/docs/cli.md +10 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-provider-exec.py +1 -1
  8. package/runtime/bin/okstra-zai-exec.sh +5 -0
  9. package/runtime/prompts/launch.template.md +12 -0
  10. package/runtime/prompts/lead/okstra-lead-contract.md +3 -1
  11. package/runtime/prompts/lead/plan-body-verification.md +16 -4
  12. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  13. package/runtime/prompts/wizard/prompts.ko.json +1 -0
  14. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +18 -4
  15. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +96 -0
  16. package/runtime/python/okstra_ctl/adapters/providers/zai/manifest.json +5 -0
  17. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +6 -3
  18. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +23 -1
  19. package/runtime/python/okstra_ctl/consumers.py +0 -55
  20. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -2
  21. package/runtime/python/okstra_ctl/domain/write_policy.py +7 -1
  22. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -17
  23. package/runtime/python/okstra_ctl/final_report_schema.py +16 -16
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -0
  25. package/runtime/python/okstra_ctl/report_assembly.py +4 -1
  26. package/runtime/python/okstra_ctl/report_corrections.py +24 -15
  27. package/runtime/python/okstra_ctl/report_finalize.py +1 -1
  28. package/runtime/python/okstra_ctl/report_narrative.py +11 -6
  29. package/runtime/python/okstra_ctl/report_projections.py +1 -0
  30. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  31. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -0
  32. package/runtime/python/okstra_ctl/wizard/engine.py +4 -16
  33. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +7 -37
  34. package/runtime/python/okstra_ctl/worker_runner.py +7 -2
  35. package/runtime/python/okstra_ctl/worktree_status_cli.py +2 -3
  36. package/runtime/python/okstra_ctl/write_policy.py +51 -4
  37. package/runtime/python/okstra_token_usage/claude.py +29 -0
  38. package/runtime/python/okstra_token_usage/collect.py +11 -1
  39. package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
  40. package/runtime/schemas/final-report-v2.0.schema.json +3 -2
  41. package/runtime/schemas/final-report-v3.0.schema.json +3 -2
  42. package/runtime/skills/okstra-run/SKILL.md +9 -1
@@ -211,26 +211,17 @@ class _Validator:
211
211
  def _misplaced_property_hint(
212
212
  self, name: str, path: tuple[str | int, ...],
213
213
  ) -> str:
214
- """ 키가 스키마의 다른 자리에서 정의된 필드면 "옮겨라" 를 덧붙인다.
215
-
216
- `additional property 'X' is not allowed` 만 보면 "스키마에 그런 칸이
217
- 없다" 로 읽힌다. 실제로는 X 가 다른 객체의 필드이고 작성자가 자리를
218
- 틀린 경우가 있고, 그때 지우면 다음 라운드에서 `required property 'X'
219
- is missing` 이 나온다 — 라운드 하나를 왕복에 쓴다(2026-09-09 실측,
220
- dev-10642 implementation-planning: 최상위 필수 `summary` 를
221
- `rationale` 안에 써서 지웠다가 되살렸다).
222
- """
214
+ """동명 필수 필드의 위치만 알린다. 이름 일치는 값의 의미를 증명하지 않는다."""
223
215
  found = self._required_property_owners(name) - {_format_path(path)}
224
216
  if not found:
225
217
  return ""
226
- # 최상위를 앞에 둔다. 잘못 놓인 키는 대개 최상위 필드가 한 단계 안으로
227
- # 들어간 것이고, 나머지 소유자는 같은 이름을 쓰는 다른 행 타입이다.
228
218
  owners = (
229
219
  ["<root>"] if "<root>" in found else []
230
220
  ) + sorted(found - {"<root>"})
231
221
  return (
232
- f" — '{name}' is a REQUIRED property of {', '.join(owners[:3])}, not "
233
- f"of {_format_path(path)}; move it there rather than removing it"
222
+ f" — '{name}' is also required at {', '.join(owners[:3])}; "
223
+ "a shared name does not establish where this value belongs. "
224
+ "Check the target field's schema and meaning before moving any value"
234
225
  )
235
226
 
236
227
  def _required_property_owners(self, name: str) -> set[str]:
@@ -495,14 +486,14 @@ def verdict_token_rule(schema: Mapping[str, Any], task_type: str) -> tuple[str,
495
486
 
496
487
 
497
488
  def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ...]:
498
- """ task type 의 데이터 블록(`properties[block_key]`) 요구하는 모양을 한
489
+ """최상위 필드(`properties[block_key]`) 요구하는 모양을 한
499
490
  객체당 한 줄의 저작 계약 문장으로 편다.
500
491
 
501
492
  리드는 report-writer 지시문을 쓸 때 블록 안쪽의 필수 필드·식별자 패턴·
502
493
  정수 필드·고정 길이 배열을 알 길이 프롬프트 경로에 없어 스키마 JSON 을
503
494
  손으로 파싱했고, 그래도 놓친 만큼 같은 리포트를 다시 썼다(2026-09-03 실측,
504
495
  dev-10626 implementation-option-selection: 네 회차). 여기서 뽑아 합성 묶음의
505
- 저작 계약에 싣는다. 블록이 없거나 객체가 아니면 빈 튜플이다.
496
+ 저작 계약에 싣는다. 필드가 없으면 빈 튜플이다.
506
497
 
507
498
  경로는 스키마 키다(`rankedOptions[]` 는 `- Item N` 목록의 각 항목). 같은
508
499
  정의(`$ref`)가 두 경로에 나오면 두 번째는 첫 경로를 가리킨다 — 랭킹 옵션과
@@ -514,7 +505,16 @@ def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ..
514
505
  return ()
515
506
  lines: list[str] = []
516
507
  described: dict[str, str] = {}
517
- _describe_object(root, block, block_key, lines, described)
508
+ resolved, _ = _resolved(root, block)
509
+ if resolved.get("type") == "object" or "properties" in resolved:
510
+ _describe_object(root, block, block_key, lines, described)
511
+ else:
512
+ nested: list[tuple[str, Any]] = []
513
+ constraint = _describe_property(root, block, block_key, nested)
514
+ if constraint:
515
+ lines.append(f"`{block_key}`: {constraint}")
516
+ for child_path, child in nested:
517
+ _describe_object(root, child, child_path, lines, described)
518
518
  return tuple(lines)
519
519
 
520
520
 
@@ -1935,6 +1935,12 @@ def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
1935
1935
  data, current, _audit, _history = _round_inputs(args)
1936
1936
  run_manifest = getattr(args, "run_manifest", None)
1937
1937
  payloads = _prepared_payloads(run_manifest)
1938
+ # 실제 목록에서 제외할 항목을 다음 비평 검증으로 요구하면 빈 큐에서 멈춘다.
1939
+ allowed = set(dispatch_item_ids(
1940
+ [payloads.get(item["id"], item) for item in current],
1941
+ data["planBodyVerification"].get("stageLedger"),
1942
+ ))
1943
+ current = [item for item in current if item["id"] in allowed]
1938
1944
  return next_dispatch(
1939
1945
  current, payloads, critic_rostered=_rostered_critic(run_manifest),
1940
1946
  decision_items=(
@@ -594,7 +594,10 @@ def validate_plan_draft(
594
594
  *task_narrative_errors(
595
595
  draft, load_schema_version("3.0"), str(manifest.get("taskType", "")),
596
596
  ),
597
- *selected_direction_plan_errors(draft, project_root, manifest),
597
+ *[
598
+ f"implementationPlanning: {error}"
599
+ for error in selected_direction_plan_errors(draft, project_root, manifest)
600
+ ],
598
601
  ]
599
602
 
600
603
 
@@ -20,7 +20,7 @@ from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
  from typing import Any, Callable, Mapping, Sequence
22
22
 
23
- from .final_report_schema import validate
23
+ from .final_report_schema import task_block_rules, validate
24
24
  from .json_boundary import JsonBoundaryError, load_owned_object
25
25
  from .report_markdown import humanise
26
26
  from .report_narrative import (
@@ -29,6 +29,7 @@ from .report_narrative import (
29
29
  render_narrative,
30
30
  validate_writer_owned,
31
31
  writer_owned_path_defect,
32
+ writer_owned_schema,
32
33
  )
33
34
 
34
35
 
@@ -204,7 +205,7 @@ def check_corrections(
204
205
  3. 경로 해소와 `current` 일치. `rewrite` 는 배열의 `[len]` 추가 위치를 허용한다.
205
206
  4. 작성자 소유 경로인가.
206
207
  5. replace·remove 를 적용한 사본을 작성자 소유 값 스키마와 task 의미 검증기로
207
- 대조한다. rewrite 경로 아래의 스키마 결함은 뺀다(작성자가 다시 쓸 자리다).
208
+ 대조한다. rewrite 경로 아래로 특정된 결함은 뺀다(작성자가 다시 쓸 자리다).
208
209
  6. rewrite 마다 그 경로의 스키마 제약 문장을 붙인다.
209
210
  """
210
211
  ledger_dict = dict(ledger)
@@ -279,7 +280,7 @@ def check_corrections(
279
280
  continue
280
281
  if kind == "rewrite":
281
282
  rewrite_paths.append(path)
282
- constraints[correction_id] = _constraints_for(path, block_rules)
283
+ constraints[correction_id] = _constraints_for(path, block_rules, schema)
283
284
 
284
285
  replacements = [c for c in corrections if c.get("kind") == "replace" and str(c.get("id")) in parsed]
285
286
  removals = [c for c in corrections if c.get("kind") == "remove" and str(c.get("id")) in parsed]
@@ -305,15 +306,16 @@ def check_corrections(
305
306
  # 회차에 전건을 보게 하려는 것이 이 모듈의 이유다. 결함인 교정은 적용하지
306
307
  # 않았으므로 그 자리는 기준 서사의 값 그대로다.
307
308
  if scratch is not None:
308
- for error in validate_writer_owned(scratch, schema):
309
- location = error.split(": ", 1)[0]
309
+ errors = validate_writer_owned(scratch, schema)
310
+ if semantic_validator is not None:
311
+ errors.extend(semantic_validator(scratch))
312
+ for error in errors:
313
+ location, separator, _ = error.partition(": ")
314
+ location = location if separator else "-"
310
315
  if any(location == rp or location.startswith(f"{rp}.") or location.startswith(f"{rp}[")
311
316
  for rp in rewrite_paths):
312
317
  continue
313
318
  defects.append(_defect("applied", location, error))
314
- if semantic_validator is not None:
315
- for error in semantic_validator(scratch):
316
- defects.append(_defect("applied", "-", str(error)))
317
319
 
318
320
  mechanical = not defects and not rewrite_paths
319
321
  return CorrectionsCheck(
@@ -321,16 +323,22 @@ def check_corrections(
321
323
  )
322
324
 
323
325
 
324
- def _constraints_for(path: str, block_rules: Sequence[str]) -> tuple[str, ...]:
325
- """이 경로(또는 가장 가까운 상위 객체)의 `task_block_rules` 줄."""
326
+ def _constraints_for(
327
+ path: str, block_rules: Sequence[str], schema: Mapping[str, Any],
328
+ ) -> tuple[str, ...]:
329
+ """가장 가까운 필드의 제약. 공통 필드도 검증에 쓰는 스키마에서 추출한다."""
326
330
  normalised = normalise_field_path(path)
331
+ root_key = str(parse_field_path(path)[0])
332
+ rules = block_rules
333
+ if not any(rule.startswith(f"`{root_key}`: ") for rule in rules):
334
+ rules = task_block_rules(writer_owned_schema(schema), root_key)
327
335
  candidates = [normalised]
328
336
  while "." in candidates[-1]:
329
337
  candidates.append(candidates[-1].rsplit(".", 1)[0])
330
338
  for candidate in candidates:
331
339
  lines = tuple(
332
- rule for rule in block_rules
333
- if rule.startswith(f"`{candidate}`: ")
340
+ rule for rule in rules
341
+ if rule.startswith(f"`{candidate}`: ") or rule.startswith(f"`{candidate}[]")
334
342
  )
335
343
  if lines:
336
344
  return lines
@@ -423,9 +431,10 @@ def render_corrections_section(
423
431
  lines.extend(["", "### Context", "", context.strip()])
424
432
  lines.extend([
425
433
  "",
426
- "Change nothing else: not a sentence, not a score, not an evidence path, not a "
427
- "verdict, not the `Human Summary`, and not the blockquote indentation where each "
428
- "value line sits two spaces deeper than its own `- **Label**` line.",
434
+ "Outside the correction paths, preserve every sentence, score, evidence path, "
435
+ "and verdict. Follow the schema constraints when a correction describes an "
436
+ "incompatible shape. Preserve the blockquote indentation: each value line "
437
+ "sits two spaces deeper than its own `- **Label**` line.",
429
438
  ])
430
439
  return lines
431
440
 
@@ -1088,7 +1088,7 @@ def _parser() -> argparse.ArgumentParser:
1088
1088
  "--only",
1089
1089
  action="append",
1090
1090
  default=[],
1091
- choices=list(STEP_ORDER),
1091
+ choices=list(dict.fromkeys((*STEP_ORDER, *V3_STEP_ORDER))),
1092
1092
  help=(
1093
1093
  "run only these steps (repeatable, contractual order preserved). "
1094
1094
  "Use `--only validate-run` to retry the step that usually fails "
@@ -118,8 +118,8 @@ _MACHINE_OWNED_NESTED = frozenset(
118
118
  )
119
119
 
120
120
 
121
- def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
122
- """`required` 목록에서 기계 소유 필드 이름만 뺀 스키마 사본.
121
+ def _without_machine_owned_fields(node: Any, *, negated: bool = False) -> Any:
122
+ """기계 소유 필드의 정의와 필수 조건을 작성자용 스키마 사본.
123
123
 
124
124
  `not` 아래는 건드리지 않는다. 거기서 `required: [designPreparation]` 은
125
125
  "이 필드가 있으면 안 된다" 는 뜻이고 서사에는 원래 없으니 이미 만족한다.
@@ -128,7 +128,7 @@ def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
128
128
  뒤집는다. 실제로 `direction-invalidated` 계획 분기가 그렇게 죽었다.
129
129
  """
130
130
  if isinstance(node, list):
131
- return [_without_machine_owned_required(item, negated=negated) for item in node]
131
+ return [_without_machine_owned_fields(item, negated=negated) for item in node]
132
132
  if not isinstance(node, dict):
133
133
  return node
134
134
  result: dict[str, Any] = {}
@@ -137,8 +137,13 @@ def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
137
137
  result[key] = [
138
138
  name for name in value if name not in _MACHINE_OWNED_NESTED
139
139
  ]
140
+ elif key == "properties" and isinstance(value, dict) and not negated:
141
+ result[key] = {
142
+ name: _without_machine_owned_fields(field)
143
+ for name, field in value.items() if name not in _MACHINE_OWNED_NESTED
144
+ }
140
145
  else:
141
- result[key] = _without_machine_owned_required(
146
+ result[key] = _without_machine_owned_fields(
142
147
  value, negated=negated or key == "not"
143
148
  )
144
149
  return result
@@ -169,8 +174,8 @@ def writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
169
174
  return {
170
175
  "type": "object",
171
176
  "additionalProperties": False,
172
- "properties": _without_machine_owned_required(properties),
173
- "$defs": _without_machine_owned_required(schema.get("$defs", {})),
177
+ "properties": _without_machine_owned_fields(properties),
178
+ "$defs": _without_machine_owned_fields(schema.get("$defs", {})),
174
179
  }
175
180
 
176
181
 
@@ -21,6 +21,7 @@ _AGENT_LABELS = {
21
21
  "antigravity": "Antigravity",
22
22
  "grok": "Grok",
23
23
  "kimi": "Kimi",
24
+ "zai": "Z.ai GLM",
24
25
  }
25
26
  _STATUS = {
26
27
  "done": "completed",
@@ -811,7 +811,7 @@ def _schema_authoring_rules(
811
811
  if isinstance(key, str) and key in allowed
812
812
  )
813
813
  block_key = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
814
- block_rules = task_block_rules(schema, block_key) if block_key else ()
814
+ block_rules = task_block_rules(writer_owned_schema(schema), block_key) if block_key else ()
815
815
  return (
816
816
  required_top_level,
817
817
  verdict_token_rule(schema, task_type),
@@ -340,4 +340,8 @@ def confirmation_block(state: WizardState) -> str:
340
340
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
341
341
  if state.fix_cycle:
342
342
  lines.append(f" fix-cycle : {state.fix_cycle}")
343
+ lines.append(_msg(
344
+ state.workspace_root, "confirmation", "provider_data_scope",
345
+ project_root=state.project_root,
346
+ ))
343
347
  return "\n".join(lines)
@@ -35,7 +35,6 @@ from .ids import (
35
35
  from .state import Prompt, WizardError, WizardState, _is_role_selection_step
36
36
  from .prompts import _domain_prompt
37
37
  from .picker_navigation import (
38
- accept_picker_answer,
39
38
  is_split_picker,
40
39
  merge_split_picker_answer,
41
40
  present_picker,
@@ -150,11 +149,8 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
150
149
  때마다 턴이 하나씩 들었다(실측 2026-09-09, 후보 13개인 critic 화면).
151
150
 
152
151
  묶음에 못 실리면(옵션 16개 초과, 라벨 중복, 질문 묶음이 없는 세션)
153
- 단일 선택은 종전대로 쪽으로 나누고(`present_picker`), 체크박스는
154
- `CapabilityInteractionPort.plan` `numbered-multi` 로 내려 전체 목록을
155
- 한 번에 보인다. 체크박스는 쪽으로 나누지 않는다 — 쪽 사본이 추천 표시를
156
- 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히
157
- 하나)에 걸려 화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면).
152
+ `CapabilityInteractionPort.plan` 이 단일 선택은 `numbered-single`,
153
+ 체크박스는 `numbered-multi` 로 내려 전체 목록을 한 번에 보인다.
158
154
  """
159
155
  if "native_single_select" not in state.available_functions:
160
156
  return prompt
@@ -172,7 +168,7 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
172
168
  return split
173
169
  if prompt.multi:
174
170
  return prompt
175
- return present_picker(state, prompt, limit=port.native_option_limit)
171
+ return present_picker(prompt)
176
172
 
177
173
 
178
174
  def _next_prompt_screen(state: WizardState) -> Prompt:
@@ -404,13 +400,6 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
404
400
  return {"echo": "", "next": prompt_payload(state, prompt)}
405
401
  plan = _interaction_plan(state, prompt)
406
402
  value = _normalize_interaction_answer(state, prompt, plan, value)
407
- if plan.kind == "native-single":
408
- original = _next_prompt_screen(state)
409
- if original.kind == "pick_group":
410
- original = next(q for q in original.questions if q.step == prompt.step)
411
- value = accept_picker_answer(state, original, value)
412
- if value is None:
413
- return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
414
403
  if is_split_picker(prompt):
415
404
  # 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
416
405
  # 경로로 보낸다. 원본의 선택지로 검증한다.
@@ -419,8 +408,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
419
408
  elif prompt.kind == "pick_group":
420
409
  return _submit_group(state, prompt, value)
421
410
  if _is_role_selection_step(prompt.step):
422
- # 고정 단일 역할의 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수
423
- # 있다(`present_picker`). 답은 잘리지 않은 원본의 선택지로 검증한다.
411
+ # 화면의 중복 라벨을 구분했어도 답은 원본 선택지의 값으로 검증한다.
424
412
  original = next_role_prompt(state)
425
413
  if original is not None and original.step == prompt.step:
426
414
  prompt = original
@@ -6,20 +6,16 @@
6
6
  원래 step 의 제출 경로로 간다. 단일 선택(예: critic 처럼 최대 1개인 역할)도
7
7
  같은 방식으로 자르되 통틀어 하나만 고른 답이어야 한다.
8
8
 
9
- 그 크기도 넘거나 질문 묶음이 없는 세션이면, 단일 선택은 쪽으로 나누고
10
- (`present_picker`) 체크박스는 `numbered-multi` 전체 목록을 번에 보인다
11
- (`engine._native_picker_screen`). 체크박스를 쪽으로 나누던 경로는 쪽 사본이
12
- 추천 표시를 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식에 걸리므로
13
- 2026-09-09 에 뺐다.
9
+ 그 크기도 넘거나 질문 묶음이 없는 세션이면 전체 번호 목록을 한 번에 보인다.
10
+ `present_picker` 중복 라벨만 구분하고 선택지 순서와 값을 보존한다.
14
11
  """
15
12
  import json
16
13
  import math
17
14
  from dataclasses import replace
18
15
 
19
16
  from .ids import PICK_TYPE_CUSTOM
20
- from .state import Option, Prompt, WizardError, WizardState
17
+ from .state import Prompt, WizardError
21
18
 
22
- _PAGE_PREFIX = "__okstra_picker_page__:"
23
19
  _SPLIT_SEPARATOR = "#"
24
20
 
25
21
 
@@ -113,7 +109,7 @@ def merge_split_picker_answer(prompt: Prompt, value: str) -> str:
113
109
  return ",".join(chosen)
114
110
 
115
111
 
116
- def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
112
+ def present_picker(prompt: Prompt) -> Prompt:
117
113
  if prompt.kind != "pick" or not prompt.options:
118
114
  return prompt
119
115
  labels = [option.label for option in prompt.options]
@@ -121,33 +117,7 @@ def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
121
117
  option,
122
118
  label=option.label + (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
123
119
  ) for option in prompt.options]
124
- paged = len(options) > limit
125
- size = max(1, limit - 1) if paged else len(options)
126
- offset = state.picker_offsets.get(prompt.step, 0)
127
- offset = offset if 0 <= offset < len(options) else 0
128
- shown = options[offset:offset + size]
129
- label = prompt.label
130
- if paged:
131
- next_offset = offset + size if offset + size < len(options) else 0
132
- shown.append(Option(
133
- f"{_PAGE_PREFIX}{next_offset}",
134
- "다음 선택지" if next_offset else "처음 선택지로",
135
- ))
136
- label += f" ({offset + 1}–{min(offset + size, len(options))}/{len(options)})"
137
- if len(shown) == 1:
138
- shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
139
- shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
140
- o for o in shown if o.value == PICK_TYPE_CUSTOM
120
+ options = [o for o in options if o.value != PICK_TYPE_CUSTOM] + [
121
+ o for o in options if o.value == PICK_TYPE_CUSTOM
141
122
  ]
142
- return replace(prompt, label=label, options=shown)
143
-
144
-
145
- def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
146
- if value.startswith(_PAGE_PREFIX):
147
- raw_offset = value.removeprefix(_PAGE_PREFIX)
148
- if not raw_offset.isdecimal() or not 0 <= int(raw_offset) < len(prompt.options):
149
- raise WizardError("invalid picker page; select a returned navigation option")
150
- state.picker_offsets[prompt.step] = int(raw_offset)
151
- return None
152
- state.picker_offsets.pop(prompt.step, None)
153
- return value
123
+ return replace(prompt, options=options)
@@ -202,7 +202,7 @@ def _launch(
202
202
  stdout=subprocess.PIPE,
203
203
  stderr=_stderr_target(strategy),
204
204
  start_new_session=True,
205
- env=_child_env(),
205
+ env=_child_env(command.environment),
206
206
  )
207
207
  on_spawn(process)
208
208
  exit_code, timed_out, idle_seconds = _pump(
@@ -604,7 +604,7 @@ def _terminate(process: subprocess.Popen[bytes]) -> None:
604
604
  process.wait()
605
605
 
606
606
 
607
- def _child_env() -> dict[str, str]:
607
+ def _child_env(overrides: Mapping[str, str | None]) -> dict[str, str]:
608
608
  """The worker's environment with git's fsmonitor disabled.
609
609
 
610
610
  The main worktree's fsmonitor daemon leaks its IPC socket into task
@@ -614,6 +614,11 @@ def _child_env() -> dict[str, str]:
614
614
  GIT_CONFIG_* the caller already set.
615
615
  """
616
616
  env = dict(os.environ)
617
+ for key, value in overrides.items():
618
+ if value is None:
619
+ env.pop(key, None)
620
+ else:
621
+ env[key] = value
617
622
  index = int(env.get("GIT_CONFIG_COUNT", "0") or "0")
618
623
  env[f"GIT_CONFIG_KEY_{index}"] = "core.fsmonitor"
619
624
  env[f"GIT_CONFIG_VALUE_{index}"] = "false"
@@ -19,9 +19,8 @@ and stage-integration steps use.
19
19
  Use it as the assertion in a plan step:
20
20
  okstra worktree-status --check-clean
21
21
 
22
- Do not chain a `git tag stage-<N>-exit` onto it. okstra writes that tag when it
23
- settles the stage, at the commit the carry evidence records; a step that tags
24
- while the stage is still running puts it on an earlier commit.
22
+ Do not chain a `git tag stage-<N>-exit` onto it. Stage completion records the
23
+ commit in the consumer ledger without creating or moving git tags.
25
24
 
26
25
  Output: JSON { ok, path, clean, entries, excluded }. `entries` holds the
27
26
  `git status --short` rows that made it dirty; `excluded` lists the paths left
@@ -51,6 +51,8 @@ def build_write_policy(
51
51
  _relative_paths(invocation.get("protectedPaths", (".okstra", ".git")))
52
52
  ),
53
53
  }
54
+ if "plannedPathsDeclared" in invocation:
55
+ source_policy["plannedPathsDeclared"] = invocation["plannedPathsDeclared"]
54
56
  git_policy = _git_policy(invocation, source_root, source_mode)
55
57
  external_roots = _validated_auxiliary_roots(invocation, assignment)
56
58
  auxiliary_policy = {
@@ -144,18 +146,21 @@ def build_invocation_write_contract(
144
146
  )
145
147
  )
146
148
  )
149
+ source_root = worktree.resolve() if worktree is not None else root
150
+ source_paths = _planned_source_paths(
151
+ planned_paths, root, source_root, relative_artifacts
152
+ )
147
153
  invocation: dict[str, Any] = {
148
154
  "role": role,
149
155
  "projectRoot": str(root),
150
156
  "artifactPaths": list(relative_artifacts),
151
- "plannedPaths": list(planned_paths),
157
+ "plannedPaths": list(source_paths),
152
158
  "plannedPathsDeclared": planned_paths_declared,
153
159
  "protectedPaths": [".okstra", ".git"],
154
160
  "generatedPaths": list(generated_paths),
155
161
  "scratchRoots": [str(path) for path in scratch_roots],
156
162
  "auxiliaryRoots": [str(path) for path in auxiliary_roots],
157
163
  }
158
- source_root = worktree.resolve() if worktree is not None else root
159
164
  if role == "implementer":
160
165
  invocation.update({
161
166
  "expectedBaseCommit": _git_output(source_root, "rev-parse", "HEAD"),
@@ -173,6 +178,47 @@ def build_invocation_write_contract(
173
178
  return policy, derive_write_enforcement(policy, maximum_precision, role=role)
174
179
 
175
180
 
181
+ def _planned_source_paths(
182
+ paths: Sequence[str],
183
+ project_root: Path,
184
+ source_root: Path,
185
+ artifact_paths: Sequence[str],
186
+ ) -> tuple[str, ...]:
187
+ """산출물 권한은 프로젝트에, 소스 변경 목록은 배정 작업 디렉터리에 결속한다."""
188
+ sources: set[str] = set()
189
+ for value in paths:
190
+ candidate = Path(value)
191
+ if candidate.is_absolute():
192
+ root = (
193
+ source_root if candidate.is_relative_to(source_root) else project_root
194
+ )
195
+ relative = _relative_to_root(candidate, root, "planned")
196
+ else:
197
+ relative = _relative_paths((value,))[0]
198
+ root = (
199
+ project_root
200
+ if PurePosixPath(relative).parts[0] == ".okstra"
201
+ else source_root
202
+ )
203
+ _relative_to_root(root / relative, root, "planned")
204
+ relative = _relative_paths((relative,))[0]
205
+ path = PurePosixPath(relative)
206
+ if path.parts[0] == ".okstra":
207
+ if root != project_root or not any(
208
+ path.is_relative_to(allowed) for allowed in artifact_paths
209
+ ):
210
+ raise WritePolicyError(
211
+ f"planned artifact path exceeds this worker's artifact permissions: {value}"
212
+ )
213
+ else:
214
+ if path.parts[0] == ".git":
215
+ raise WritePolicyError(f"planned source path is protected: {value}")
216
+ # 원래 체크아웃의 절대 소스 경로도 실제 쓰기는 배정 작업 디렉터리에 한정된다.
217
+ _relative_to_root(source_root / relative, source_root, "planned source")
218
+ sources.add(relative)
219
+ return tuple(sorted(sources))
220
+
221
+
176
222
  def task_root_from_run_manifest(
177
223
  project_root: Path, manifest: Mapping[str, Any]
178
224
  ) -> Path | None:
@@ -191,7 +237,7 @@ def task_root_from_run_manifest(
191
237
 
192
238
  def planned_paths_from_run_manifest(
193
239
  project_root: Path, manifest: Mapping[str, Any]
194
- ) -> tuple[str, ...]:
240
+ ) -> tuple[tuple[str, ...], bool]:
195
241
  active_value = manifest.get("activeRunContextPath")
196
242
  if not isinstance(active_value, str) or not active_value:
197
243
  raise WritePolicyError("implementer write policy has no active run context")
@@ -421,7 +467,8 @@ def _planned_paths_from_report(
421
467
  if isinstance(path, str) and path.strip()
422
468
  }
423
469
  if paths:
424
- return _relative_paths(tuple(sorted(paths))), True
470
+ # 경로 분류는 산출물 권한과 배정 작업 디렉터리를 함께 아는 정책 생성기가 맡는다.
471
+ return tuple(sorted(paths)), True
425
472
  # A plan approved before `plannedPaths` existed carries its paths only in
426
473
  # the prose `files` cell. That prose does not survive being split into
427
474
  # paths — it mis-splits globs and parentheticals — so the ledger stays
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
11
11
  from .paths import claude_project_dir, ts_in_window
12
+ from okstra_ctl.wrapper_status import read_wrapper_status
12
13
 
13
14
  # lead 의 단계 체크포인트 라인(prompts/lead/okstra-lead-contract.md "Progress reporting") — phase id 가
14
15
  # `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
@@ -16,6 +17,34 @@ _PROGRESS_RE = re.compile(r"^PROGRESS:\s+(phase-\d\S*(?:[ \t].*)?)$", re.MULTILI
16
17
  _PROGRESS_MARKER_MAX_CHARS = 160
17
18
 
18
19
 
20
+ def claude_code_status_total(status_path: Path) -> dict:
21
+ """Claude Code의 최종 누적 사용량을 제공자 가격과 분리해서 읽는다."""
22
+ status = read_wrapper_status(status_path)
23
+ usage = status.raw.get("usage") if status is not None else None
24
+ if not isinstance(usage, dict) or not usage:
25
+ return {"available": False, "totalTokens": 0}
26
+ attestation = status.raw.get("servedModelAttestation")
27
+ model = attestation.get("observedModel") if isinstance(attestation, dict) else None
28
+ input_tokens = usage.get("input_tokens")
29
+ output_tokens = usage.get("output_tokens")
30
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
31
+ cache_read = usage.get("cache_read_input_tokens", 0)
32
+ if any(
33
+ not isinstance(value, int) or isinstance(value, bool) or value < 0
34
+ for value in (input_tokens, output_tokens, cache_creation, cache_read)
35
+ ):
36
+ return {"available": False, "totalTokens": 0}
37
+ return {
38
+ "available": True,
39
+ "totalTokens": input_tokens + output_tokens + cache_creation,
40
+ "inputTokens": input_tokens,
41
+ "outputTokens": output_tokens,
42
+ "cacheCreationTokens": cache_creation,
43
+ "cacheReadTokens": cache_read,
44
+ "model": model,
45
+ }
46
+
47
+
19
48
  def _event_from_record(rec: dict) -> dict | None:
20
49
  """jsonl 레코드 1개 → 압축 이벤트. 집계에 기여하지 않으면 None.
21
50
 
@@ -11,6 +11,7 @@ from okstra_project.dirs import OKSTRA_RELATIVE
11
11
 
12
12
  from .blocks import na_block, usage_block
13
13
  from .claude import (
14
+ claude_code_status_total,
14
15
  claude_session_totals,
15
16
  find_claude_agent_sessions,
16
17
  find_claude_team_sessions,
@@ -662,6 +663,8 @@ def _cli_session_totals(
662
663
  )
663
664
  elif provider == "grok":
664
665
  total = grok_session_total(session_path)
666
+ elif provider == "zai":
667
+ total = claude_code_status_total(session_path)
665
668
  else:
666
669
  continue
667
670
  if total.get("available"):
@@ -705,12 +708,15 @@ def _cli_usage_block(provider: str, totals: dict, session_paths: list[Path]) ->
705
708
  billable = provider_billable_equivalent(provider, *rates)
706
709
  if billable is not None:
707
710
  block["billableEquivalentTokens"] = billable
708
- else:
711
+ elif provider == "antigravity":
709
712
  cost = antigravity_cost_usd(
710
713
  totals.get("model"),
711
714
  totals.get("inputTokens", 0) or 0,
712
715
  totals.get("outputTokens", 0) or 0,
713
716
  )
717
+ else:
718
+ cost = None
719
+ block["cliNote"] = "Token usage recorded; provider billing cost is unavailable."
714
720
  if cost is not None:
715
721
  block["cliEstimatedCostUsd"] = cost
716
722
  return block
@@ -919,6 +925,10 @@ def _worker_cli_usage_block(
919
925
  if path not in session_paths:
920
926
  session_paths.append(path)
921
927
  status_path = status_paths[-1] if status_paths else None
928
+ if provider == "zai":
929
+ session_paths = list(dict.fromkeys(
930
+ path for path in status_paths if path is not None and path.is_file()
931
+ ))
922
932
  if provider == "antigravity":
923
933
  for raw, source_status in zip(prompt_paths, status_paths):
924
934
  for source in _antigravity_usage_sources(
@@ -148,6 +148,7 @@
148
148
  "mode": {"enum": ["source-readonly", "project-mutation"]},
149
149
  "allowedRoot": {"type": "string", "pattern": "^/"},
150
150
  "plannedPaths": {"type": "array", "items": {"type": "string", "pattern": "\\S"}, "uniqueItems": true},
151
+ "plannedPathsDeclared": {"type": "boolean"},
151
152
  "protectedPaths": {"type": "array", "items": {"type": "string", "pattern": "\\S"}, "uniqueItems": true}
152
153
  },
153
154
  "additionalProperties": false