okstra 0.200.1 → 0.201.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 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +7 -3
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/technical-verification-worker.md +44 -0
- package/runtime/prompts/launch.template.md +7 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
- package/runtime/prompts/lead/plan-body-verification.md +3 -1
- package/runtime/prompts/lead/report-writer.md +8 -2
- package/runtime/prompts/lead/team-contract.md +6 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/final-verification.md +5 -0
- package/runtime/prompts/profiles/forbidden-actions.json +6 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/technical-verification.md +53 -0
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
- package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
- package/runtime/python/okstra_ctl/conformance.py +2 -20
- package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
- package/runtime/python/okstra_ctl/domain/provider.py +0 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
- package/runtime/python/okstra_ctl/implementation_options.py +58 -45
- package/runtime/python/okstra_ctl/model_pool.py +2 -5
- package/runtime/python/okstra_ctl/next_phase.py +3 -0
- package/runtime/python/okstra_ctl/plan_items.py +15 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
- package/runtime/python/okstra_ctl/qa_commands.py +30 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_assembly.py +8 -2
- package/runtime/python/okstra_ctl/report_contract.py +3 -0
- package/runtime/python/okstra_ctl/report_corrections.py +209 -93
- package/runtime/python/okstra_ctl/report_finalize.py +25 -8
- package/runtime/python/okstra_ctl/report_html/router.py +2 -0
- package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
- package/runtime/python/okstra_ctl/report_projections.py +4 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +178 -26
- package/runtime/python/okstra_ctl/run.py +82 -0
- package/runtime/python/okstra_ctl/team.py +4 -1
- package/runtime/python/okstra_ctl/technical_verification.py +195 -0
- package/runtime/python/okstra_ctl/usage_identity.py +54 -0
- package/runtime/python/okstra_ctl/usage_report.py +22 -8
- package/runtime/python/okstra_ctl/verification_target.py +74 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/render.py +8 -55
- package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
- package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
- package/runtime/python/okstra_ctl/wizard/state.py +13 -6
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -0
- package/runtime/python/okstra_ctl/write_policy.py +23 -0
- package/runtime/python/okstra_token_usage/blocks.py +50 -1
- package/runtime/python/okstra_token_usage/claude.py +42 -21
- package/runtime/python/okstra_token_usage/codex.py +17 -0
- package/runtime/python/okstra_token_usage/collect.py +299 -162
- package/runtime/python/okstra_token_usage/cursor.py +2 -3
- package/runtime/python/okstra_token_usage/report.py +35 -30
- package/runtime/python/okstra_token_usage/task_totals.py +3 -12
- package/runtime/schemas/final-report-v2.0.schema.json +298 -7
- package/runtime/schemas/final-report-v3.0.schema.json +298 -7
- package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
- package/runtime/skills/okstra-run/SKILL.md +10 -2
- package/runtime/skills/okstra-setup/SKILL.md +42 -7
- package/runtime/templates/report-writer-prompt-preamble.md +7 -3
- package/runtime/templates/reports/html/i18n/en.json +11 -0
- package/runtime/templates/reports/html/i18n/ko.json +11 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
- package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
- package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
- package/runtime/validators/validate-run.py +9 -4
|
@@ -147,6 +147,10 @@ class WizardState:
|
|
|
147
147
|
# "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
|
|
148
148
|
fix_cycle: str = ""
|
|
149
149
|
confirmed: Optional[bool] = None
|
|
150
|
+
confirmation_stages: str = ""
|
|
151
|
+
confirmation_prompt: str = ""
|
|
152
|
+
confirmation_scope: dict[str, Any] = field(default_factory=dict)
|
|
153
|
+
user_authorization: dict[str, Any] = field(default_factory=dict)
|
|
150
154
|
edit_target: str = ""
|
|
151
155
|
# terminal: user picked 중단 — no further prompt ever applies
|
|
152
156
|
aborted: bool = False
|
|
@@ -225,26 +229,28 @@ class Prompt:
|
|
|
225
229
|
self._check_recommendations()
|
|
226
230
|
|
|
227
231
|
def _check_recommendations(self) -> None:
|
|
228
|
-
"""단일
|
|
232
|
+
"""단일 추천은 하나다. 모델 선택은 제공자 순서, 그 외에는 추천이 앞이다.
|
|
229
233
|
|
|
230
234
|
실측(2026-09-09, task 선택 화면): 남은 task 세 줄이 전부 `(추천)` 을
|
|
231
235
|
달고 나왔고, 리드는 산문에서 2번을 권했다. 추천이 여럿이면 라벨은
|
|
232
236
|
아무것도 고르지 않은 것이고, 추천이 1번이 아니면 사용자는 목록을
|
|
233
|
-
끝까지 읽어야 추천을 찾는다.
|
|
234
|
-
|
|
237
|
+
끝까지 읽어야 추천을 찾는다. 모델 선택은 제공자별 묶음을 우선한다.
|
|
238
|
+
그 외 체크박스(`multi`)는 추천이 여럿일 수 있되 앞머리에 모인다.
|
|
239
|
+
그리고 `직접 입력` /
|
|
235
240
|
`중단` 은 앞의 선택지가 전부 맞지 않을 때의 탈출구이므로 추천 대상이
|
|
236
241
|
될 수 없다.
|
|
237
242
|
"""
|
|
238
243
|
flags = [option.recommended for option in self.options]
|
|
244
|
+
grouped_models = self.step.startswith(("role-models:", "role-model:"))
|
|
239
245
|
if any(flags):
|
|
240
246
|
if self.multi:
|
|
241
|
-
if any(flags[index] for index in range(1, len(flags))
|
|
247
|
+
if not grouped_models and any(flags[index] for index in range(1, len(flags))
|
|
242
248
|
if not flags[index - 1]):
|
|
243
249
|
raise WizardError(
|
|
244
250
|
f"wizard step {self.step!r}: recommended options must "
|
|
245
251
|
"be the leading run of the list"
|
|
246
252
|
)
|
|
247
|
-
elif sum(flags) != 1 or not flags[0]:
|
|
253
|
+
elif sum(flags) != 1 or (not grouped_models and not flags[0]):
|
|
248
254
|
raise WizardError(
|
|
249
255
|
f"wizard step {self.step!r}: a single-select step carries "
|
|
250
256
|
"exactly one recommendation and it is the first option"
|
|
@@ -684,7 +690,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
684
690
|
"pr_template_path": "", "pr_template_pending_text": False,
|
|
685
691
|
"pr_template_scope": "",
|
|
686
692
|
"fix_cycle": "",
|
|
687
|
-
"confirmed": None, "edit_target": "",
|
|
693
|
+
"confirmed": None, "edit_target": "", "confirmation_prompt": "", "confirmation_stages": "",
|
|
694
|
+
"confirmation_scope": {}, "user_authorization": {},
|
|
688
695
|
}
|
|
689
696
|
|
|
690
697
|
|
|
@@ -12,6 +12,7 @@ from okstra_ctl.incremental_scope import CARRY_ALL_SCOPE
|
|
|
12
12
|
from okstra_ctl.implementation_direction import (
|
|
13
13
|
DirectionSelectionError,
|
|
14
14
|
lexical_absolute_path,
|
|
15
|
+
resolve_selected_direction,
|
|
15
16
|
validate_task_artifact_path,
|
|
16
17
|
)
|
|
17
18
|
from okstra_ctl.final_report_paths import final_report_data_path
|
|
@@ -191,6 +192,13 @@ def _submit_selected_direction_pick(
|
|
|
191
192
|
candidates = _selected_direction_candidates(state)
|
|
192
193
|
if value not in candidates:
|
|
193
194
|
raise WizardError(t["errors"]["unknown"].format(value=value))
|
|
195
|
+
try:
|
|
196
|
+
resolve_selected_direction(
|
|
197
|
+
Path(state.project_root) / value,
|
|
198
|
+
expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
|
|
199
|
+
)
|
|
200
|
+
except DirectionSelectionError as exc:
|
|
201
|
+
raise WizardError(str(exc)) from exc
|
|
194
202
|
state.selected_direction_path = value
|
|
195
203
|
# 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
|
|
196
204
|
# clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
|
|
@@ -162,7 +162,8 @@ def probe_launch(
|
|
|
162
162
|
"""Whether the wrapper behind *prompt* ever started."""
|
|
163
163
|
log, status = _log_path(prompt), Path(f"{prompt}.status.json")
|
|
164
164
|
probe = {"kind": "launch", "path": str(prompt)}
|
|
165
|
-
if
|
|
165
|
+
if any(path.is_file() and path.stat().st_mtime >= dispatched_at.timestamp()
|
|
166
|
+
for path in (log, status)):
|
|
166
167
|
return {**probe, "state": "live", "reason": ""}
|
|
167
168
|
waited = (now - dispatched_at).total_seconds()
|
|
168
169
|
if waited <= grace:
|
|
@@ -273,7 +274,8 @@ def probe_all(
|
|
|
273
274
|
def result_ready(target: ProbeTarget) -> bool:
|
|
274
275
|
"""Whether this worker's result file has landed with content in it."""
|
|
275
276
|
path = target.result_path
|
|
276
|
-
return bool(path and path.is_file() and path.stat().st_size > 0
|
|
277
|
+
return bool(path and path.is_file() and path.stat().st_size > 0
|
|
278
|
+
and path.stat().st_mtime >= target.dispatched_at.timestamp())
|
|
277
279
|
|
|
278
280
|
|
|
279
281
|
def wait_for_results(
|
|
@@ -370,53 +372,58 @@ def _dispatch_worker_id(record: Mapping[str, Any]) -> str:
|
|
|
370
372
|
return ""
|
|
371
373
|
|
|
372
374
|
|
|
373
|
-
def _dispatch_fallback(state: Mapping[str, Any], worker_id: str) -> dict:
|
|
374
|
-
"""
|
|
375
|
-
|
|
376
|
-
로스터 행에 없는 키를 여기서 보충한다. 재디스패치는 같은 워커의 행을 뒤에
|
|
377
|
-
덧붙이므로 마지막 행이 이번 시도다.
|
|
378
|
-
"""
|
|
379
|
-
records = state.get("workerDispatches")
|
|
380
|
-
if not isinstance(records, list):
|
|
381
|
-
return {}
|
|
375
|
+
def _dispatch_fallback(state: Mapping[str, Any], worker_id: str, dispatch_id: str = "") -> dict:
|
|
376
|
+
"""명시한 배정 또는 하나로 확정되는 구형 배정만 선택한다."""
|
|
377
|
+
records = state.get("workerDispatches") or []
|
|
382
378
|
matches = [
|
|
383
|
-
row for row in records
|
|
384
|
-
|
|
379
|
+
row for row in records if isinstance(row, Mapping)
|
|
380
|
+
and (row.get("dispatchId") == dispatch_id if dispatch_id
|
|
381
|
+
else _dispatch_worker_id(row) == worker_id)
|
|
385
382
|
]
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
if len(matches) > 1:
|
|
384
|
+
raise DispatchError(f"multiple dispatches for {worker_id or dispatch_id}; pass --dispatch-id")
|
|
385
|
+
if dispatch_id and not matches:
|
|
386
|
+
raise DispatchError(f"team-state has no dispatchId={dispatch_id}")
|
|
387
|
+
return dict(matches[0]) if matches else {}
|
|
388
388
|
|
|
389
|
-
def _worker_row(team_state_path: Path, worker_id: str) -> dict:
|
|
390
|
-
"""이 워커의 프로브 입력 — 로스터 행에 디스패치 행을 덧댄 것.
|
|
391
389
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
프로브가 전면 거부되고, 리드는 계약이 금지한 자체 폴링으로 밀려난다.
|
|
395
|
-
보충은 로스터에 없거나 빈 키에만 적용한다 — `startedAt` 처럼 로스터가 정본인
|
|
396
|
-
값을 디스패치 행이 덮어쓰면 grace 앵커가 이번 시도에서 어긋난다.
|
|
397
|
-
"""
|
|
390
|
+
def _worker_row(team_state_path: Path, worker_id: str, dispatch_id: str = "") -> dict:
|
|
391
|
+
"""새 배정의 필드는 원자적으로 읽고 구형 단일 기록만 보완한다."""
|
|
398
392
|
state = load_json_object(team_state_path, "team-state")
|
|
393
|
+
dispatch = _dispatch_fallback(state, worker_id, dispatch_id)
|
|
394
|
+
if dispatch.get("dispatchId") and dispatch.get("startedAt"):
|
|
395
|
+
result = dispatch.get("resultPath")
|
|
396
|
+
root = _project_root_for_team_state(team_state_path)
|
|
397
|
+
if result and any(
|
|
398
|
+
isinstance(row, Mapping) and row.get("dispatchId") != dispatch["dispatchId"]
|
|
399
|
+
and row.get("status") not in {"completed", "error", "timeout", "not-run"}
|
|
400
|
+
and row.get("resultPath")
|
|
401
|
+
and (root / row["resultPath"]).resolve() == (root / result).resolve()
|
|
402
|
+
for row in state.get("workerDispatches", [])
|
|
403
|
+
):
|
|
404
|
+
raise DispatchError("resultPath is shared by dispatches; use an attempt-specific result path")
|
|
405
|
+
return dispatch
|
|
399
406
|
workers = state.get("workers")
|
|
400
407
|
if not isinstance(workers, list):
|
|
401
408
|
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if isinstance(row, dict) and row.get("workerId") == worker_id
|
|
406
|
-
),
|
|
407
|
-
None,
|
|
408
|
-
)
|
|
409
|
+
selected_worker = _dispatch_worker_id(dispatch) if dispatch_id else worker_id
|
|
410
|
+
worker = next((row for row in workers if isinstance(row, dict)
|
|
411
|
+
and row.get("workerId") == selected_worker), None)
|
|
409
412
|
if worker is None:
|
|
410
|
-
raise DispatchError(f"team-state has no workerId={
|
|
413
|
+
raise DispatchError(f"team-state has no workerId={selected_worker}: {team_state_path}")
|
|
414
|
+
if dispatch and worker.get("promptPath") and dispatch.get("promptPath"):
|
|
415
|
+
root = _project_root_for_team_state(team_state_path)
|
|
416
|
+
if (root / worker["promptPath"]).resolve() != (root / dispatch["promptPath"]).resolve():
|
|
417
|
+
raise DispatchError("dispatch has no startedAt and roster promptPath differs; repair the recorded attempt")
|
|
411
418
|
merged = dict(worker)
|
|
412
|
-
for key, value in
|
|
419
|
+
for key, value in dispatch.items():
|
|
413
420
|
current = merged.get(key)
|
|
414
421
|
if key not in merged or (isinstance(current, str) and not current.strip()):
|
|
415
422
|
merged[key] = value
|
|
416
423
|
return merged
|
|
417
424
|
|
|
418
425
|
|
|
419
|
-
def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
426
|
+
def probe_target(team_state_value: str, worker_id: str, *, dispatch_id: str = "") -> ProbeTarget:
|
|
420
427
|
"""Resolve one worker's probe target from team-state.
|
|
421
428
|
|
|
422
429
|
``livenessMode`` is authoritative — never infer the transport from the
|
|
@@ -424,7 +431,7 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
|
424
431
|
worker for a wrapper log that will never exist.
|
|
425
432
|
"""
|
|
426
433
|
team_state_path = Path(team_state_value).resolve()
|
|
427
|
-
worker = _worker_row(team_state_path, worker_id)
|
|
434
|
+
worker = _worker_row(team_state_path, worker_id, dispatch_id)
|
|
428
435
|
mode = worker.get("livenessMode")
|
|
429
436
|
field = _ARTIFACT_FIELD_BY_MODE.get(mode) if isinstance(mode, str) else None
|
|
430
437
|
if field is None:
|
|
@@ -492,6 +499,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
492
499
|
help="team-state path for a pending worker (repeatable)")
|
|
493
500
|
parser.add_argument("--worker", action="append", default=[],
|
|
494
501
|
help="worker id paired with --team-state (repeatable)")
|
|
502
|
+
parser.add_argument("--dispatch-id", action="append", default=[],
|
|
503
|
+
help="exact dispatch id paired with --team-state; do not mix with --worker")
|
|
495
504
|
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
496
505
|
help="heartbeat staleness budget in seconds")
|
|
497
506
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
@@ -520,15 +529,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
520
529
|
)
|
|
521
530
|
args = parser.parse_args(argv)
|
|
522
531
|
|
|
523
|
-
if
|
|
524
|
-
parser.error("
|
|
532
|
+
if args.worker and args.dispatch_id:
|
|
533
|
+
parser.error("use --worker or --dispatch-id, not both")
|
|
534
|
+
selectors = args.dispatch_id or args.worker
|
|
535
|
+
if len(args.team_state) != len(selectors):
|
|
536
|
+
parser.error("each --team-state must have one paired --worker or --dispatch-id")
|
|
525
537
|
if not args.team_state:
|
|
526
538
|
parser.error("pass at least one --team-state/--worker pair")
|
|
527
539
|
|
|
528
540
|
try:
|
|
529
541
|
targets = [
|
|
530
|
-
probe_target(team_state, worker
|
|
531
|
-
|
|
542
|
+
probe_target(team_state, "" if args.dispatch_id else worker,
|
|
543
|
+
dispatch_id=worker if args.dispatch_id else "")
|
|
544
|
+
for team_state, worker in zip(args.team_state, selectors, strict=True)
|
|
532
545
|
]
|
|
533
546
|
except DispatchError as exc:
|
|
534
547
|
parser.error(str(exc))
|
|
@@ -547,7 +560,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
547
560
|
return 0 if result["ok"] else 1
|
|
548
561
|
|
|
549
562
|
unwaitable = [
|
|
550
|
-
worker for target, worker in zip(targets,
|
|
563
|
+
worker for target, worker in zip(targets, selectors, strict=True)
|
|
551
564
|
if target.result_path is None
|
|
552
565
|
]
|
|
553
566
|
if unwaitable:
|
|
@@ -49,6 +49,7 @@ FINAL_VERIFICATION_HEADERS = (
|
|
|
49
49
|
"**Verification target digest:**",
|
|
50
50
|
)
|
|
51
51
|
SUPPORTED_TASK_TYPES = frozenset({
|
|
52
|
+
"technical-verification",
|
|
52
53
|
"requirements-discovery",
|
|
53
54
|
"error-analysis",
|
|
54
55
|
"implementation-option-selection",
|
|
@@ -65,6 +66,7 @@ SUPPORTED_TASK_TYPES = frozenset({
|
|
|
65
66
|
# something else gets its own. A task type absent from this map takes the
|
|
66
67
|
# observational default below: describe the area, do not design for it.
|
|
67
68
|
ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
|
|
69
|
+
"technical-verification": "technical-verification-worker",
|
|
68
70
|
"requirements-discovery": "discovery-worker",
|
|
69
71
|
"improvement-discovery": "discovery-worker",
|
|
70
72
|
"error-analysis": "diagnosis-worker",
|
|
@@ -39,6 +39,14 @@ ERROR_ANALYSIS_ROUTING_DIRECTIONS = {
|
|
|
39
39
|
# prompt template 에 그대로 박혀 lead 가 읽는다. forbidden actions 는 이 dict 가
|
|
40
40
|
# 아니라 prompts/profiles/forbidden-actions.json (load_phase_forbidden) 이 SSOT.
|
|
41
41
|
PHASE_RULES: dict[str, dict[str, str]] = {
|
|
42
|
+
"technical-verification": {
|
|
43
|
+
"allowed": (
|
|
44
|
+
" - falsifiable experiment plans for the frozen unresolved facts\n"
|
|
45
|
+
" - dependency installation, source experiments, tests and builds only in this run's experiment copies\n"
|
|
46
|
+
" - command logs, observed signals and per-fact supported/refuted/inconclusive/not-run results\n"
|
|
47
|
+
" - return to implementation-option-selection with evidence, without adoption or plan approval"
|
|
48
|
+
),
|
|
49
|
+
},
|
|
42
50
|
"requirements-discovery": {
|
|
43
51
|
"allowed": (
|
|
44
52
|
" - work-category classification (bugfix / feature / refactor / ops / improvement)\n"
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
from collections.abc import Mapping, Sequence
|
|
8
9
|
from dataclasses import dataclass
|
|
@@ -120,6 +121,27 @@ def _role_qa_artifact_paths(role: str, task_root: Path | None) -> tuple[Path, ..
|
|
|
120
121
|
return ()
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
|
|
125
|
+
def _technical_experiment_paths(
|
|
126
|
+
artifact_paths: Sequence[Path], task_root: Path | None,
|
|
127
|
+
) -> tuple[Path, ...]:
|
|
128
|
+
"""정규 작업 결과 경로에서 해당 작업자의 시험 디렉터리만 파생한다."""
|
|
129
|
+
if task_root is None:
|
|
130
|
+
return ()
|
|
131
|
+
run_root = task_root / "runs" / "technical-verification"
|
|
132
|
+
paths = set()
|
|
133
|
+
for artifact in artifact_paths:
|
|
134
|
+
if artifact.parent != run_root / "worker-results":
|
|
135
|
+
continue
|
|
136
|
+
match = re.fullmatch(
|
|
137
|
+
r"([a-z0-9][a-z0-9-]*)-worker-technical-verification-(\d{3,})\.md",
|
|
138
|
+
artifact.name,
|
|
139
|
+
)
|
|
140
|
+
if match:
|
|
141
|
+
paths.add(run_root / "experiments" / match[2] / match[1])
|
|
142
|
+
return tuple(sorted(paths))
|
|
143
|
+
|
|
144
|
+
|
|
123
145
|
def build_invocation_write_contract(
|
|
124
146
|
*,
|
|
125
147
|
role: str,
|
|
@@ -142,6 +164,7 @@ def build_invocation_write_contract(
|
|
|
142
164
|
_relative_to_root(path, root, "artifact")
|
|
143
165
|
for path in (
|
|
144
166
|
*artifact_paths,
|
|
167
|
+
*_technical_experiment_paths(artifact_paths, task_root),
|
|
145
168
|
*_role_qa_artifact_paths(role, task_root),
|
|
146
169
|
)
|
|
147
170
|
)
|
|
@@ -1,12 +1,62 @@
|
|
|
1
1
|
"""Per-source usage block constructors (consumed by collect())."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
4
7
|
from .paths import utc_now
|
|
5
8
|
from .pricing import (
|
|
6
9
|
claude_billable_equivalent,
|
|
7
10
|
claude_cost_usd,
|
|
8
11
|
)
|
|
9
12
|
|
|
13
|
+
_CACHE_INCLUDED_SOURCES = frozenset({"codex-cli", "grok-cli", "kimi-cli"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def accounting_workers(state: Mapping[str, Any]) -> list[dict]:
|
|
17
|
+
"""초기 명부와 명부 밖 실행의 사용량 행을 같은 소비 경로로 전달한다."""
|
|
18
|
+
rows = []
|
|
19
|
+
for key in ("workers", "additionalWorkerUsage"):
|
|
20
|
+
collection = state.get(key)
|
|
21
|
+
if isinstance(collection, list):
|
|
22
|
+
rows.extend(row for row in collection if isinstance(row, dict))
|
|
23
|
+
return rows
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def usage_blocks(state: dict) -> list[dict]:
|
|
27
|
+
blocks = [state.get("leadUsage") or {}]
|
|
28
|
+
blocks.extend(worker.get("usage") or {} for worker in accounting_workers(state))
|
|
29
|
+
unattributed = (state.get("usageSummary") or {}).get("unattributedWorkerUsage")
|
|
30
|
+
if isinstance(unattributed, dict):
|
|
31
|
+
blocks.append(unattributed)
|
|
32
|
+
return [block for block in blocks if isinstance(block, dict)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_usage_block(block: dict) -> dict:
|
|
36
|
+
"""과거 캐시 포함 합계를 정규화하고 게시 당시 합계를 별도로 보존한다."""
|
|
37
|
+
normalized = dict(block)
|
|
38
|
+
cached = block.get("cachedInputTokens") or 0
|
|
39
|
+
if (
|
|
40
|
+
block.get("source") not in _CACHE_INCLUDED_SOURCES
|
|
41
|
+
or "cacheReadTokens" in block
|
|
42
|
+
or block.get("accountingBasis") == "cache-read-excluded"
|
|
43
|
+
or not isinstance(cached, int)
|
|
44
|
+
or isinstance(cached, bool)
|
|
45
|
+
or cached <= 0
|
|
46
|
+
):
|
|
47
|
+
return normalized
|
|
48
|
+
total = block.get("totalTokens")
|
|
49
|
+
if not isinstance(total, int) or total < cached:
|
|
50
|
+
return normalized
|
|
51
|
+
normalized["reportedTotalTokens"] = total
|
|
52
|
+
normalized["totalTokens"] = total - cached
|
|
53
|
+
normalized["cacheReadTokens"] = cached
|
|
54
|
+
normalized["accountingBasis"] = "cache-read-excluded"
|
|
55
|
+
if isinstance(block.get("cliTotalTokens"), int):
|
|
56
|
+
normalized["reportedCliTotalTokens"] = block["cliTotalTokens"]
|
|
57
|
+
normalized["cliTotalTokens"] = max(0, block["cliTotalTokens"] - cached)
|
|
58
|
+
return normalized
|
|
59
|
+
|
|
10
60
|
|
|
11
61
|
def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
|
|
12
62
|
block = {
|
|
@@ -65,4 +115,3 @@ def na_block(reason: str) -> dict:
|
|
|
65
115
|
"collectedAt": utc_now(),
|
|
66
116
|
"note": reason,
|
|
67
117
|
}
|
|
68
|
-
|
|
@@ -4,12 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
import sys
|
|
6
6
|
import re
|
|
7
|
-
from datetime import datetime
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
10
9
|
from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
|
|
11
10
|
from .paths import claude_project_dir, ts_in_window
|
|
12
11
|
from okstra_ctl.wrapper_status import read_wrapper_status
|
|
12
|
+
from okstra_ctl.usage_cells import duration_ms_from_bounds
|
|
13
13
|
|
|
14
14
|
# lead 의 단계 체크포인트 라인(prompts/lead/okstra-lead-contract.md "Progress reporting") — phase id 가
|
|
15
15
|
# `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
|
|
@@ -45,19 +45,13 @@ def claude_code_status_total(status_path: Path) -> dict:
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
키: t=timestamp, i/o=input/output, c=cache_creation 합, c5/c1=ephemeral
|
|
52
|
-
5m/1h, r=cache_read, u=tool_use 수. 0/부재 필드는 생략(캐시 크기 절약).
|
|
53
|
-
ts-only 레코드도 보존한다 — 임의 윈도우의 first/last ts 산출에 필요.
|
|
54
|
-
"""
|
|
55
|
-
msg = rec.get("message")
|
|
56
|
-
if not isinstance(msg, dict):
|
|
57
|
-
msg = {}
|
|
48
|
+
def _usage_from_message(msg: dict) -> dict:
|
|
49
|
+
"""응답 식별자를 남겨 여러 출력 조각의 사용량을 한 번만 집계한다."""
|
|
58
50
|
ev: dict = {}
|
|
59
51
|
usage = msg.get("usage")
|
|
60
|
-
if usage:
|
|
52
|
+
if isinstance(usage, dict):
|
|
53
|
+
if isinstance(msg.get("id"), str) and msg["id"]:
|
|
54
|
+
ev["m"] = msg["id"]
|
|
61
55
|
for src, key in (("input_tokens", "i"), ("output_tokens", "o"),
|
|
62
56
|
("cache_read_input_tokens", "r")):
|
|
63
57
|
v = usage.get(src, 0) or 0
|
|
@@ -79,6 +73,15 @@ def _event_from_record(rec: dict) -> dict | None:
|
|
|
79
73
|
elif cc_total:
|
|
80
74
|
# API 분해가 없으면 전부 5m 티어로(1.25x — 더 싼 가정, 기존 동작).
|
|
81
75
|
ev["c5"] = cc_total
|
|
76
|
+
return ev
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _event_from_record(rec: dict) -> dict | None:
|
|
80
|
+
"""사용량과 별개로 각 조각의 도구·진행·시각 이벤트를 보존한다."""
|
|
81
|
+
msg = rec.get("message")
|
|
82
|
+
if not isinstance(msg, dict):
|
|
83
|
+
msg = {}
|
|
84
|
+
ev = _usage_from_message(msg)
|
|
82
85
|
if rec.get("type") == "assistant":
|
|
83
86
|
tools = sum(1 for b in (msg.get("content") or [])
|
|
84
87
|
if isinstance(b, dict) and b.get("type") == "tool_use")
|
|
@@ -102,6 +105,31 @@ def _event_from_record(rec: dict) -> dict | None:
|
|
|
102
105
|
return ev or None
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
def _response_usage_events(events: list[dict], since: str | None, until: str | None):
|
|
109
|
+
"""응답별 누적 스냅샷의 창 안 증가량만 세며 도구 이벤트는 그대로 둔다."""
|
|
110
|
+
latest: dict[str, int] = {}
|
|
111
|
+
baseline: dict[str, dict] = {}
|
|
112
|
+
for index, event in enumerate(events):
|
|
113
|
+
message_id = event.get("m")
|
|
114
|
+
timestamp = event.get("t")
|
|
115
|
+
if message_id and (not timestamp or ts_in_window(timestamp, None, until)):
|
|
116
|
+
latest[message_id] = index
|
|
117
|
+
if timestamp and since and not ts_in_window(timestamp, since, None):
|
|
118
|
+
baseline[message_id] = event
|
|
119
|
+
usage_keys = {"i", "o", "c", "c5", "c1", "r"}
|
|
120
|
+
for index, event in enumerate(events):
|
|
121
|
+
message_id = event.get("m")
|
|
122
|
+
if not message_id:
|
|
123
|
+
yield event
|
|
124
|
+
continue
|
|
125
|
+
projected = {key: value for key, value in event.items() if key not in usage_keys}
|
|
126
|
+
if latest.get(message_id) == index:
|
|
127
|
+
previous = baseline.get(message_id) or {}
|
|
128
|
+
projected.update({key: max(0, event.get(key, 0) - previous.get(key, 0))
|
|
129
|
+
for key in usage_keys})
|
|
130
|
+
yield projected
|
|
131
|
+
|
|
132
|
+
|
|
105
133
|
def _session_meta_from_record(rec: dict) -> tuple[str | None, str | None]:
|
|
106
134
|
"""레코드에서 (agentName, model) 후보 추출 — 둘 다 first-non-null 정책."""
|
|
107
135
|
agent = rec.get("agentName") or None
|
|
@@ -213,7 +241,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
|
|
|
213
241
|
progress_markers: list[dict] = []
|
|
214
242
|
first_ts: str | None = None
|
|
215
243
|
last_ts: str | None = None
|
|
216
|
-
for ev in events:
|
|
244
|
+
for ev in _response_usage_events(events, since, until):
|
|
217
245
|
ts = ev.get("t")
|
|
218
246
|
if ts and not ts_in_window(ts, since, until):
|
|
219
247
|
continue
|
|
@@ -231,14 +259,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
|
|
|
231
259
|
first_ts = ts
|
|
232
260
|
if last_ts is None or ts > last_ts:
|
|
233
261
|
last_ts = ts
|
|
234
|
-
duration_ms = 0
|
|
235
|
-
if first_ts and last_ts:
|
|
236
|
-
try:
|
|
237
|
-
a = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
|
|
238
|
-
b = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
|
|
239
|
-
duration_ms = max(0, int((b - a).total_seconds() * 1000))
|
|
240
|
-
except ValueError:
|
|
241
|
-
duration_ms = 0
|
|
262
|
+
duration_ms = duration_ms_from_bounds(first_ts, last_ts) or 0
|
|
242
263
|
# '처리 토큰' total 에서 cache_read 는 제외한다. claude 는 매 턴 직전까지의
|
|
243
264
|
# 컨텍스트 전체를 캐시에서 재읽기(cache_read)하므로, 단순 합산하면 같은 토큰을
|
|
244
265
|
# 턴 수만큼 중복 카운트해 처리량이 비현실적으로 부풀려진다(예: in-session
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from .jsonl_io import iter_jsonl
|
|
@@ -12,6 +13,22 @@ from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
|
|
|
12
13
|
_DEFAULT_CODEX_SESSIONS = CODEX_SESSIONS
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def codex_wrapper_session_ids(log_path: Path) -> set[str]:
|
|
17
|
+
"""사용자 프롬프트 앞 실행기 헤더에서만 세션 ID를 읽는다."""
|
|
18
|
+
try:
|
|
19
|
+
with log_path.open(encoding="utf-8", errors="replace") as stream:
|
|
20
|
+
for line in stream:
|
|
21
|
+
clean = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
|
|
22
|
+
if clean == "user" or clean.endswith("[worker]user"):
|
|
23
|
+
break
|
|
24
|
+
match = re.search(r"(?:^|\[worker\])session id:\s*([\w-]+)\s*$", clean)
|
|
25
|
+
if match:
|
|
26
|
+
return {match.group(1)}
|
|
27
|
+
except OSError:
|
|
28
|
+
return set()
|
|
29
|
+
return set()
|
|
30
|
+
|
|
31
|
+
|
|
15
32
|
def codex_session_total(jsonl_path: Path) -> dict:
|
|
16
33
|
"""Return last token_count snapshot from a codex rollout jsonl."""
|
|
17
34
|
last: dict | None = None
|