okstra 0.133.0 → 0.135.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 (36) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/for-ai/skills/okstra-pr-gen.md +2 -1
  4. package/docs/for-ai/skills/okstra-user-response.md +9 -4
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/agents/workers/report-writer-worker.md +3 -1
  10. package/runtime/bin/okstra-antigravity-exec.sh +12 -2
  11. package/runtime/bin/okstra-claude-exec.sh +10 -1
  12. package/runtime/bin/okstra-codex-exec.sh +12 -2
  13. package/runtime/prompts/lead/convergence.md +1 -1
  14. package/runtime/prompts/lead/team-contract.md +2 -2
  15. package/runtime/prompts/profiles/_common-contract.md +0 -1
  16. package/runtime/prompts/profiles/_implementation-executor.md +20 -1
  17. package/runtime/prompts/profiles/final-verification.md +1 -1
  18. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +34 -12
  20. package/runtime/python/okstra_ctl/convergence_engine.py +11 -2
  21. package/runtime/python/okstra_ctl/models.py +2 -0
  22. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  23. package/runtime/python/okstra_ctl/run.py +30 -11
  24. package/runtime/python/okstra_ctl/session.py +36 -7
  25. package/runtime/python/okstra_ctl/user_response.py +14 -5
  26. package/runtime/python/okstra_ctl/worker_artifact_paths.py +5 -1
  27. package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
  28. package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
  29. package/runtime/python/okstra_ctl/worktree.py +39 -9
  30. package/runtime/python/okstra_token_usage/collect.py +75 -9
  31. package/runtime/python/okstra_token_usage/pricing.py +4 -1
  32. package/runtime/skills/okstra-pr-gen/SKILL.md +24 -0
  33. package/runtime/skills/okstra-user-response/SKILL.md +67 -19
  34. package/runtime/validators/forbidden_actions.py +1 -1
  35. package/runtime/validators/validate-run.py +26 -8
  36. package/runtime/validators/validate_session_conformance.py +17 -7
@@ -96,19 +96,28 @@ def utc_now() -> str:
96
96
  _CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})
97
97
 
98
98
 
99
- def _cli_wrapper_worker_labels(team_state: dict) -> frozenset[str]:
99
+ def _unavailable_usage_worker_labels(team_state: dict) -> frozenset[str]:
100
100
  """Token-table row labels whose `--` cells are honest.
101
101
 
102
- Mirrors `scripts/okstra_token_usage/report.py` `_worker_detail_label`
103
- (`"{role} ({agent}, {status})"`) so the match is on the rendered label the
104
- scanner actually sees.
102
+ Two ways a row legitimately renders `--`: a CLI-wrapper provider (tokens
103
+ live inside its own CLI) and any worker whose collected
104
+ `usage.source` is `unavailable`. The latter is not a value a worker typed —
105
+ the renderer (`okstra_token_usage/report.py` `_worker_detail_row`) emits
106
+ null cells for it, and the collector records *why* in the same block. Gating
107
+ it made an in-process worker the collector could not attribute fail the whole
108
+ run (observed dev-10172: no subagent jsonl carried the report-writer's
109
+ agentName, so a completed run was reported as contract-violated).
110
+
111
+ Labels mirror `_worker_detail_label` (`"{role} ({agent}, {status})"`) so the
112
+ match is on the rendered label the scanner actually sees.
105
113
  """
106
114
  labels = set()
107
115
  for worker in team_state.get("workers") or []:
108
116
  if not isinstance(worker, dict):
109
117
  continue
110
118
  agent = str(worker.get("agent") or "").strip()
111
- if agent not in _CLI_WRAPPER_AGENTS:
119
+ usage = worker.get("usage") if isinstance(worker.get("usage"), dict) else {}
120
+ if agent not in _CLI_WRAPPER_AGENTS and usage.get("source") != "unavailable":
112
121
  continue
113
122
  role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
114
123
  status = str(worker.get("status") or "").strip()
@@ -3488,8 +3497,17 @@ def _validate_stage_carry_sidecar_exists(
3488
3497
  stage = evidence.get("stageNumber")
3489
3498
  if not isinstance(stage, int):
3490
3499
  return
3491
- # `report_path` is `runs/implementation/reports/final-report-...md`.
3492
- carry_path = report_path.parent.parent / "carry" / f"stage-{stage}.json"
3500
+ # Carry sidecars are stage-SHARED: they live flat at
3501
+ # `runs/implementation/carry/stage-<N>.json`, NOT under the stage-<N>/ run
3502
+ # dir, because the next stage's carry-in and `consumers.backfill_done_from_carry`
3503
+ # glob them without knowing the producing run's layout (storage-model.md
3504
+ # §"representative files"). `report_path` is
3505
+ # `runs/implementation/stage-<N>/reports/final-report-...md`, so drop the
3506
+ # stage-<N>/ segment to reach the flat carry dir that `consumers` reads —
3507
+ # resolving it under the stage run dir forced the lead to write the file twice.
3508
+ run_dir = report_path.parent.parent
3509
+ carry_root = run_dir.parent if _stage_isolated_name(run_dir) else run_dir
3510
+ carry_path = carry_root / "carry" / f"stage-{stage}.json"
3493
3511
  if not carry_path.exists():
3494
3512
  failures.append(
3495
3513
  f"implementation run declares stage-{stage} sidecar evidence but "
@@ -5778,7 +5796,7 @@ def main() -> int:
5778
5796
  contract["required_agent_status_entries"],
5779
5797
  failures,
5780
5798
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
5781
- unavailable_ok_labels=_cli_wrapper_worker_labels(team_state),
5799
+ unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
5782
5800
  )
5783
5801
  validate_team_state_usage(team_state, failures)
5784
5802
 
@@ -36,7 +36,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
36
36
 
37
37
  from okstra_ctl.worker_heartbeat import ( # noqa: E402
38
38
  HEARTBEAT_LINE_RE,
39
- HEARTBEAT_MAX_GAP_SECONDS,
39
+ max_gap_seconds_after,
40
40
  )
41
41
  from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
42
42
 
@@ -66,7 +66,9 @@ _PROGRESS_LINE_RE = re.compile(
66
66
  # heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
67
67
  # `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
68
68
  _HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
69
- _HEARTBEAT_MAX_GAP_SECONDS = HEARTBEAT_MAX_GAP_SECONDS
69
+
70
+ # 장기 단계 중 append 되는 진행 라인 (claude-worker.md "Heartbeat").
71
+ _IN_STAGE_PREFIX = "in-stage:"
70
72
 
71
73
  # Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
72
74
  # 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
@@ -192,7 +194,7 @@ def _collect_lead_evidence(
192
194
  )
193
195
  from okstra_token_usage.paths import claude_project_dir
194
196
 
195
- since, until = resolve_run_window(team_state_path, team_state)
197
+ since, until = resolve_run_window(team_state_path, team_state, relax_start=False)
196
198
  lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
197
199
  team_needles, _needle_source = resolve_team_needles_with_source(
198
200
  team_state, project_root, since, until, projects_root=projects_root
@@ -546,7 +548,10 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
546
548
  )
547
549
  if worker_id == "report-writer":
548
550
  for stage, _raw_ts in entries:
549
- if stage not in _REPORT_WRITER_HEARTBEAT_STAGES:
551
+ # `in-stage:<stage>` 계약이 장기 단계에 요구하는 진행 라인이다 —
552
+ # allowlist 가 이를 배제하면 워커는 cadence 를 지킬 방법이 없다.
553
+ base = stage[len(_IN_STAGE_PREFIX):] if stage.startswith(_IN_STAGE_PREFIX) else stage
554
+ if base not in _REPORT_WRITER_HEARTBEAT_STAGES:
550
555
  errors.append(
551
556
  f"`{rel}`: heartbeat stage `{stage}` is not allowed for report-writer."
552
557
  )
@@ -560,6 +565,7 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
560
565
  "stage must append its own line (agents/workers/claude-worker.md 'Heartbeat')."
561
566
  )
562
567
  prev: datetime | None = None
568
+ prev_stage = ""
563
569
  for stage, raw_ts in entries:
564
570
  ts = _parse_iso(raw_ts)
565
571
  if ts is None:
@@ -569,18 +575,22 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
569
575
  )
570
576
  continue
571
577
  if prev is not None:
578
+ # 예산은 구간을 여는 단계에서 고른다 — 이 공백이 재는 것은 직전에
579
+ # 선언된 단계의 작업 시간이다.
580
+ budget = max_gap_seconds_after(prev_stage)
572
581
  gap = (ts - prev).total_seconds()
573
582
  if gap < 0:
574
583
  errors.append(
575
584
  f"`{rel}`: heartbeat timestamps regress at stage `{stage}` ({raw_ts})."
576
585
  )
577
- elif gap > _HEARTBEAT_MAX_GAP_SECONDS:
586
+ elif gap > budget:
578
587
  errors.append(
579
- f"`{rel}`: heartbeat gap before stage `{stage}` is {int(gap)}s "
580
- "the append cadence MUST NOT exceed 5 minutes (+60s grace); emit "
588
+ f"`{rel}`: heartbeat gap after stage `{prev_stage}` is {int(gap)}s "
589
+ f"(budget {budget}s) emit "
581
590
  "`- PROGRESS: in-stage:<stage> <ISO>` during long stages."
582
591
  )
583
592
  prev = ts
593
+ prev_stage = stage
584
594
 
585
595
 
586
596
  def _check_worker_liveness(