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.
- package/README.md +4 -1
- package/dist/lib/runtime-payload.mjs +1 -0
- package/dist/lib/runtime-payload.mjs.map +1 -1
- package/docs/cli.md +10 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-provider-exec.py +1 -1
- package/runtime/bin/okstra-zai-exec.sh +5 -0
- package/runtime/prompts/launch.template.md +12 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +16 -4
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +18 -4
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +96 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/manifest.json +5 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +6 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +23 -1
- package/runtime/python/okstra_ctl/consumers.py +0 -55
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -2
- package/runtime/python/okstra_ctl/domain/write_policy.py +7 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -17
- package/runtime/python/okstra_ctl/final_report_schema.py +16 -16
- package/runtime/python/okstra_ctl/plan_items_cli.py +6 -0
- package/runtime/python/okstra_ctl/report_assembly.py +4 -1
- package/runtime/python/okstra_ctl/report_corrections.py +24 -15
- package/runtime/python/okstra_ctl/report_finalize.py +1 -1
- package/runtime/python/okstra_ctl/report_narrative.py +11 -6
- package/runtime/python/okstra_ctl/report_projections.py +1 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +4 -16
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +7 -37
- package/runtime/python/okstra_ctl/worker_runner.py +7 -2
- package/runtime/python/okstra_ctl/worktree_status_cli.py +2 -3
- package/runtime/python/okstra_ctl/write_policy.py +51 -4
- package/runtime/python/okstra_token_usage/claude.py +29 -0
- package/runtime/python/okstra_token_usage/collect.py +11 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
- package/runtime/schemas/final-report-v2.0.schema.json +3 -2
- package/runtime/schemas/final-report-v3.0.schema.json +3 -2
- 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
|
|
233
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
309
|
-
|
|
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(
|
|
325
|
-
|
|
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
|
|
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
|
-
"
|
|
427
|
-
"verdict
|
|
428
|
-
"
|
|
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
|
|
122
|
-
"""
|
|
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 [
|
|
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] =
|
|
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":
|
|
173
|
-
"$defs":
|
|
177
|
+
"properties": _without_machine_owned_fields(properties),
|
|
178
|
+
"$defs": _without_machine_owned_fields(schema.get("$defs", {})),
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
|
|
@@ -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
|
-
단일 선택은
|
|
154
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
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,
|
|
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.
|
|
23
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|