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
@@ -65,17 +65,46 @@ def _session_has_agent_name(jsonl_path: Path) -> bool:
65
65
  return False
66
66
 
67
67
 
68
+ def _session_mentions(jsonl_path: Path, needle: str) -> bool:
69
+ """세션 transcript 어딘가에 *needle* 문자열이 등장하는가."""
70
+ try:
71
+ with jsonl_path.open(encoding="utf-8", errors="ignore") as fh:
72
+ return any(needle in line for line in fh)
73
+ except OSError:
74
+ return False
75
+
76
+
77
+ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
78
+ """`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
79
+
80
+ 프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
81
+ 같은 프로젝트에서 동시에 도는 **다른 태스크의 세션**이 잡힌다 — 실측
82
+ (dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
83
+ 부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
84
+ 자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
85
+ """
86
+ proj_dir = _claude_projects_dir_for(project_root)
87
+ try:
88
+ candidates = [p for p in proj_dir.iterdir() if p.suffix == ".jsonl"]
89
+ except OSError:
90
+ return ""
91
+ needle = str(run_dir)
92
+ for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
93
+ if _session_has_agent_name(path):
94
+ continue
95
+ if _session_mentions(path, needle):
96
+ return path.stem
97
+ return ""
98
+
99
+
68
100
  def record_observed_lead_session(project_root: Path, team_state_path: Path) -> str:
69
- """현재 live lead 세션을 관측해 team-state 에 append(멱등). 워커 세션이거나
70
- 관측 실패·중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이 여기에 모은다.
101
+ """ run 의 live lead 세션을 관측해 team-state 에 append(멱등). run 을
102
+ 다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
103
+ 여기에 모은다.
71
104
  """
72
- sid = resolve_inproc_lead_session_id(project_root)
105
+ sid = resolve_lead_session_id_for_run(project_root, team_state_path.parent.parent)
73
106
  if not sid:
74
107
  return ""
75
- proj_dir = _claude_projects_dir_for(project_root)
76
- jsonl = proj_dir / f"{sid}.jsonl"
77
- if _session_has_agent_name(jsonl):
78
- return ""
79
108
  try:
80
109
  state = json.loads(team_state_path.read_text(encoding="utf-8"))
81
110
  except (OSError, json.JSONDecodeError):
@@ -24,7 +24,7 @@ from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
24
24
  from okstra_ctl.listing import list_runs, absolute_final_report_path
25
25
  from okstra_ctl.clarification_items import (
26
26
  parse_clarification_rows,
27
- scan_approval_gate,
27
+ scan_open_user_input,
28
28
  section_1_present_but_unparsed,
29
29
  _section_1_slice,
30
30
  )
@@ -118,7 +118,13 @@ def _seq_from_report(report: Path) -> str:
118
118
 
119
119
 
120
120
  def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
121
- """approval-open clarification 이 있는 태스크를 최신 report mtime 순으로."""
121
+ """사용자 답변을 기다리는 clarification 이 있는 태스크를 최신 report mtime 순으로.
122
+
123
+ `Blocks=approval` 만 세면 열린 항목이 전부 `Blocks=next-phase` 인 리포트가
124
+ 빈 목록으로 보이고, 스킬의 picker 자체에 진입할 수 없다(실측: dev-10172 —
125
+ 열린 4건이 모두 next-phase 라 `list` 가 `[]` 를 냈다). 승인 게이트 판정은
126
+ `scan_approval_gate` 쪽 소비자들의 몫이고, 여기서는 답변 대기 전체를 센다.
127
+ """
122
128
  seen: set[str] = set()
123
129
  out: list[dict] = []
124
130
  for row in list_runs(home, project=project_id, limit=0): # startedAt desc
@@ -131,18 +137,21 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
131
137
  if report is None or not report.is_file():
132
138
  continue
133
139
  text = report.read_text(encoding="utf-8")
134
- scan = scan_approval_gate(text)
140
+ scan = scan_open_user_input(text)
135
141
  base = {"taskKey": key, "taskType": row.get("taskType", ""),
136
142
  "seq": _seq_from_report(report), "reportPath": str(report),
137
143
  "reportMtime": report.stat().st_mtime}
138
144
  if scan.unreadable_reason is not None:
139
145
  # §1 heading exists but drifted → real warning; §1 simply absent → skip.
140
146
  if section_1_present_but_unparsed(text):
141
- out.append({**base, "openApprovalCount": 0, "unreadable": True})
147
+ out.append({**base, "openBlockerCount": 0, "openApprovalCount": 0,
148
+ "unreadable": True})
142
149
  continue
143
150
  if not scan.blockers:
144
151
  continue
145
- out.append({**base, "openApprovalCount": len(scan.blockers), "unreadable": False})
152
+ approval_count = sum(1 for it in scan.blockers if it.blocks == "approval")
153
+ out.append({**base, "openBlockerCount": len(scan.blockers),
154
+ "openApprovalCount": approval_count, "unreadable": False})
146
155
  out.sort(key=lambda t: t["reportMtime"], reverse=True)
147
156
  return out[:limit] if limit > 0 else out
148
157
 
@@ -17,7 +17,11 @@ def audit_sidecar_rel(worker_result_rel: str) -> str:
17
17
  prefix, separator, suffix = basename.partition("-worker-")
18
18
  if not separator:
19
19
  raise WorkerArtifactPathError(
20
- f"worker result path has no canonical -worker- token: {worker_result_rel}"
20
+ "worker result path has no canonical -worker- token: "
21
+ f"{worker_result_rel}. The audit sidecar is derived from the "
22
+ "canonical worker-result name `<role>-worker-<task-type>-<seq>.md` "
23
+ "(e.g. `codex-worker-implementation-001.md`); name the result that "
24
+ "way — a role label like `codex-verifier-...` drops the -worker- token."
21
25
  )
22
26
  audit_filename = f"{prefix}-worker-audit-{suffix}"
23
27
  return f"{directory}{slash}{audit_filename}" if slash else audit_filename
@@ -9,6 +9,7 @@ the cadence budget live here rather than in either consumer.
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
+ from dataclasses import dataclass
12
13
  from datetime import datetime, timezone
13
14
  from pathlib import Path
14
15
 
@@ -21,6 +22,36 @@ HEARTBEAT_LINE_RE = re.compile(
21
22
  # 고정 grace 60초를 더한다.
22
23
  HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
23
24
 
25
+ # 단일 도구 호출이 구간 전체를 차지해 워커가 하트비트를 끼워 넣을 수 없는 단계.
26
+ # 한 번의 Write 가 진행되는 동안에는 `in-stage:` 라인도 append 할 수 없으므로
27
+ # 5분 예산으로는 살아 있는 워커가 stalled 로 판정된다 (실측 dev-10172:
28
+ # report-writer 가 74KB data.json 한 개를 쓰는 11분 동안 무하트비트).
29
+ SINGLE_WRITE_STAGES = frozenset({
30
+ "data-json-write-start",
31
+ "render-start",
32
+ "write-result-start",
33
+ })
34
+ SINGLE_WRITE_MAX_GAP_SECONDS = 20 * 60 + 60
35
+
36
+
37
+ def max_gap_seconds_after(stage: str) -> int:
38
+ """*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).
39
+
40
+ 간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
41
+ 구간을 *여는* 단계에서 고른다."""
42
+ return (
43
+ SINGLE_WRITE_MAX_GAP_SECONDS
44
+ if stage in SINGLE_WRITE_STAGES
45
+ else HEARTBEAT_MAX_GAP_SECONDS
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Heartbeat:
51
+ """사이드카에 기록된 하나의 heartbeat."""
52
+ stage: str
53
+ at: datetime
54
+
24
55
 
25
56
  def parse_timestamp(raw: str) -> datetime | None:
26
57
  """A heartbeat's ISO-8601 timestamp as an aware datetime, or None."""
@@ -31,7 +62,7 @@ def parse_timestamp(raw: str) -> datetime | None:
31
62
  return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
32
63
 
33
64
 
34
- def latest_heartbeat(sidecar: Path) -> datetime | None:
65
+ def latest_heartbeat(sidecar: Path) -> Heartbeat | None:
35
66
  """The newest parseable heartbeat in *sidecar*, or None when it has none.
36
67
 
37
68
  Takes the maximum rather than the last line: a regressing timestamp is the
@@ -42,9 +73,9 @@ def latest_heartbeat(sidecar: Path) -> datetime | None:
42
73
  text = sidecar.read_text(encoding="utf-8")
43
74
  except OSError:
44
75
  return None
45
- stamps = [
46
- parsed
76
+ beats = [
77
+ Heartbeat(match.group("stage"), parsed)
47
78
  for match in HEARTBEAT_LINE_RE.finditer(text)
48
79
  if (parsed := parse_timestamp(match.group("ts"))) is not None
49
80
  ]
50
- return max(stamps) if stamps else None
81
+ return max(beats, key=lambda beat: beat.at) if beats else None
@@ -28,7 +28,11 @@ import sys
28
28
  from datetime import datetime, timezone
29
29
  from pathlib import Path
30
30
 
31
- from okstra_ctl.worker_heartbeat import HEARTBEAT_MAX_GAP_SECONDS, latest_heartbeat
31
+ from okstra_ctl.worker_heartbeat import (
32
+ HEARTBEAT_MAX_GAP_SECONDS,
33
+ latest_heartbeat,
34
+ max_gap_seconds_after,
35
+ )
32
36
 
33
37
  DEFAULT_LAUNCH_GRACE_SECONDS = 60
34
38
 
@@ -39,7 +43,12 @@ def _log_path(prompt: Path) -> Path:
39
43
 
40
44
 
41
45
  def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
42
- """Liveness of one in-process worker, read from its audit sidecar."""
46
+ """Liveness of one in-process worker, read from its audit sidecar.
47
+
48
+ ``max_idle`` is the floor. A stage whose work is one uninterruptible tool
49
+ call carries its own, longer budget (``max_gap_seconds_after``) — during a
50
+ single large Write the worker cannot append a heartbeat at all.
51
+ """
43
52
  probe = {"kind": "heartbeat", "path": str(sidecar)}
44
53
  if not sidecar.is_file():
45
54
  # The worker writes the sidecar early but not instantly; absence is the
@@ -52,15 +61,19 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
52
61
  "state": "stalled",
53
62
  "reason": "audit sidecar carries no `- PROGRESS:` heartbeat",
54
63
  }
55
- idle = (now - beat).total_seconds()
56
- state = "stalled" if idle > max_idle else "live"
64
+ budget = max(max_idle, max_gap_seconds_after(beat.stage))
65
+ idle = (now - beat.at).total_seconds()
66
+ state = "stalled" if idle > budget else "live"
57
67
  return {
58
68
  **probe,
59
69
  "state": state,
60
70
  "idleSeconds": int(idle),
61
- "lastHeartbeat": beat.isoformat(),
71
+ "lastHeartbeat": beat.at.isoformat(),
72
+ "lastStage": beat.stage,
73
+ "budgetSeconds": int(budget),
62
74
  "reason": (
63
- f"no heartbeat for {int(idle)}s (budget {int(max_idle)}s)"
75
+ f"no heartbeat for {int(idle)}s after stage `{beat.stage}` "
76
+ f"(budget {int(budget)}s)"
64
77
  if state == "stalled"
65
78
  else ""
66
79
  ),
@@ -284,6 +284,43 @@ def _branch_exists(project_root: Path, branch: str) -> bool:
284
284
  return res.returncode == 0
285
285
 
286
286
 
287
+ def _branch_checkout_path(project_root: Path, branch: str) -> str:
288
+ """The worktree that currently has *branch* checked out, or "" when none does.
289
+
290
+ `git branch -D` refuses to delete a checked-out branch, so an existing-branch
291
+ error that only recommends `-D` is unfollowable whenever the branch is the
292
+ main checkout's HEAD. Callers use this to name the extra step instead.
293
+ """
294
+ res = _git(project_root, "worktree", "list", "--porcelain")
295
+ if res.returncode != 0:
296
+ return ""
297
+ current = ""
298
+ for line in res.stdout.splitlines():
299
+ if line.startswith("worktree "):
300
+ current = line[len("worktree "):].strip()
301
+ elif line.strip() == f"branch refs/heads/{branch}":
302
+ return current
303
+ return ""
304
+
305
+
306
+ def _branch_exists_message(project_root: Path, branch: str, kind: str) -> str:
307
+ """Existing-branch error text whose remedy is actually runnable."""
308
+ checkout = _branch_checkout_path(project_root, branch)
309
+ if checkout:
310
+ return (
311
+ f"{kind} worktree branch already exists: {branch}, and it is checked "
312
+ f"out at {checkout}. `git branch -D {branch}` will refuse while it is "
313
+ f"checked out — switch that checkout to another branch first "
314
+ f"(`git -C {checkout} switch <other-branch>`), then delete it, or "
315
+ "choose a different work-category before retrying."
316
+ )
317
+ return (
318
+ f"{kind} worktree branch already exists: {branch}. "
319
+ f"Delete it (`git branch -D {branch}`) or choose a different "
320
+ "work-category before retrying."
321
+ )
322
+
323
+
287
324
  def _head_sha(cwd: Path) -> str:
288
325
  res = _git(cwd, "rev-parse", "HEAD")
289
326
  if res.returncode != 0:
@@ -768,11 +805,7 @@ def provision_task_worktree(
768
805
  "(or `rm -rf` if it is not a registered worktree) before retrying."
769
806
  )
770
807
  if _branch_exists(project_root, branch):
771
- raise RuntimeError(
772
- f"task worktree branch already exists: {branch}. "
773
- "Delete it (`git branch -D <branch>`) or choose a different "
774
- "work-category before retrying."
775
- )
808
+ raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
776
809
 
777
810
  main_root = main_worktree_path(project_root)
778
811
  requested_base = (base_ref or "").strip()
@@ -921,10 +954,7 @@ def provision_stage_worktree(
921
954
  f"{worktree_path}. Remove it before retrying."
922
955
  )
923
956
  if _branch_exists(project_root, branch):
924
- raise RuntimeError(
925
- f"stage worktree branch already exists: {branch}. "
926
- "Delete it (`git branch -D <branch>`) before retrying."
927
- )
957
+ raise RuntimeError(_branch_exists_message(project_root, branch, "stage"))
928
958
 
929
959
  main_root = main_worktree_path(project_root)
930
960
  resolved_sha = _resolve_commit_sha(main_root, base_commit)
@@ -178,7 +178,58 @@ def _earliest_lead_session_ts(state: dict, team_state_path: Path) -> str | None:
178
178
  return earliest
179
179
 
180
180
 
181
- def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
181
+ def _previous_run_suffix(run_dir: Path, suffix: str) -> str | None:
182
+ """같은 task-type 에서 이 run 직전에 실행된 run 의 접미사, 없으면 None.
183
+
184
+ seq 는 연속이 아닐 수 있으므로(실패한 prep 이 번호를 소모한다) manifest 를
185
+ 실제로 스캔해 현재 seq 미만 중 최대를 고른다."""
186
+ task_type, _, raw_seq = suffix.rpartition("-")
187
+ if not task_type or not raw_seq.isdigit():
188
+ return None
189
+ current = int(raw_seq)
190
+ prefix, width = f"run-manifest-{task_type}-", len(raw_seq)
191
+ earlier = []
192
+ try:
193
+ names = [p.name for p in (run_dir / "manifests").iterdir()]
194
+ except OSError:
195
+ return None
196
+ for name in names:
197
+ if not (name.startswith(prefix) and name.endswith(".json")):
198
+ continue
199
+ candidate = name[len(prefix):-len(".json")]
200
+ if candidate.isdigit() and int(candidate) < current:
201
+ earlier.append(int(candidate))
202
+ if not earlier:
203
+ return None
204
+ return f"{task_type}-{max(earlier):0{width}d}"
205
+
206
+
207
+ def _previous_run_end(run_dir: Path, suffix: str) -> str | None:
208
+ """직전 run 의 종료 시각 — 종료 산출물이 없으면 그 run 의 시작 시각.
209
+
210
+ `since` 완화의 하한이다. 한 세션에서 같은 task-type 을 두 번 돌리면
211
+ `leadSessionIds` 의 세션 jsonl 첫 ts 는 *첫 run* 의 시작을 가리키므로,
212
+ 완화를 그대로 두면 seq002 의 윈도우가 seq001 구간을 통째로 삼킨다 —
213
+ seq001 의 PROGRESS 체크포인트가 seq002 검증에 섞여 순서 오탐을 낸다
214
+ (관측: dev-10172 — seq002 검증이 seq001 의 phase-6 타임스탬프를 인용)."""
215
+ prev = _previous_run_suffix(run_dir, suffix)
216
+ if prev is None:
217
+ return None
218
+ try:
219
+ prev_state = json.loads(
220
+ (run_dir / "state" / f"team-state-{prev}.json").read_text()
221
+ )
222
+ ended_at = prev_state.get("runEndedAt")
223
+ if ended_at:
224
+ return str(ended_at)
225
+ except (OSError, json.JSONDecodeError):
226
+ pass
227
+ return _run_end_estimate(run_dir, prev) or _run_manifest_created_at(run_dir, prev)
228
+
229
+
230
+ def resolve_run_window(
231
+ team_state_path: Path, state: dict, *, relax_start: bool = True
232
+ ) -> tuple[str | None, str | None]:
182
233
  """이 run 의 [시작, 종료] ISO 윈도우.
183
234
 
184
235
  in-session lead 는 자기 run 을 사용자의 *세션 전체* jsonl 에 기록하므로,
@@ -189,19 +240,34 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
189
240
  status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
190
241
  (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백.
191
242
 
192
- 시작 완화: run-manifest createdAt 이 이 run 의 실제 lead/worker 세션보다
193
- 늦게 찍히면(관측: dev-9902 — createdAt 11:43Z 가 05:34Z 시작 세션보다 늦음)
194
- since 가 앞선 세션을 잘라낸다. Task 3 이 기록한 `leadSessionIds[]` 세션의
195
- 첫 ts 최소값이 createdAt 보다 앞서면 그 값으로 since 를 앞당긴다.
196
- `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변."""
243
+ 시작 완화(`relax_start`, 기본 True — 토큰 수집용): run-manifest createdAt 이
244
+ 이 run 의 실제 lead/worker 세션보다 늦게 찍히면(관측: dev-9902 — createdAt
245
+ 11:43Z 가 05:34Z 시작 세션보다 늦음) since 가 앞선 세션을 잘라낸다. Task 3 이
246
+ 기록한 `leadSessionIds[]` 세션의 첫 ts 최소값이 createdAt 보다 앞서면 그 값으로
247
+ since 를 앞당긴다. `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존
248
+ 동작 불변. 완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
249
+ (`_previous_run_end`). 세션 jsonl 은 run 마다 새로 생기지 않으므로 하한이
250
+ 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다.
251
+
252
+ `relax_start=False` (세션 스코프 검증기 전용 — session-conformance /
253
+ forbidden-actions): 완화를 건너뛰고 since 를 createdAt 에 고정한다. createdAt
254
+ 은 prep 시각이라 이 run 의 lead·worker 활동보다 반드시 앞서므로 건전한 하한이고,
255
+ 재사용 세션에 섞인 같은 세션의 이전 run·다른 task 활동을 창에서 배제한다
256
+ (dev-10172 D-3: 이전 planning run 의 phase-6/7 앵커, 다른 task 의 git push 를
257
+ 이 run 의 위반으로 오탐하던 근본 원인). 완화는 토큰을 놓치지 않으려고 세션
258
+ birth 까지 내려가지만, 검증기에는 그 관대함이 곧 오탐이다."""
197
259
  suffix = run_artifact_suffix(team_state_path)
198
260
  if not suffix:
199
261
  return None, None
200
262
  run_dir = team_state_path.parent.parent
201
263
  since = _run_manifest_created_at(run_dir, suffix)
202
- earliest = _earliest_lead_session_ts(state, team_state_path)
203
- if earliest and (since is None or earliest < since):
204
- since = earliest
264
+ if relax_start:
265
+ earliest = _earliest_lead_session_ts(state, team_state_path)
266
+ if earliest and (since is None or earliest < since):
267
+ since = earliest
268
+ floor = _previous_run_end(run_dir, suffix)
269
+ if floor and since and since < floor:
270
+ since = floor
205
271
  until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
206
272
  return since, until
207
273
 
@@ -4,7 +4,7 @@ Pricing is matched by substring against the model id recorded in the session
4
4
  transcript, so keys must reflect the *actual* model id form emitted by each
5
5
  provider:
6
6
 
7
- * Anthropic — `claude-fable-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
7
+ * Anthropic — `claude-fable-5*`, `claude-opus-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
8
8
  `claude-haiku-4-5-*`, `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`,
9
9
  `claude-3-opus-*`, `claude-3-haiku-*`.
10
10
  * OpenAI / Codex — `gpt-5*`, `gpt-4o*`, `gpt-4*`.
@@ -52,6 +52,9 @@ CLAUDE_PRICING = {
52
52
  # Claude Fable 5 (tier above Opus).
53
53
  "fable-5": (10.0, 12.5, 1.0, 50.0), # Fable 5 (cache prices derived from ratios)
54
54
 
55
+ # Claude Opus 5.
56
+ "opus-5": (5.0, 6.25, 0.50, 25.0), # Opus 5 (cache prices derived from ratios)
57
+
55
58
  # Claude 4 point releases (explicit so future divergence is easy to see).
56
59
  "opus-4-8": (5.0, 6.25, 0.50, 25.0), # Opus 4.8 (cache prices derived from ratios)
57
60
  "opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
@@ -96,6 +96,30 @@ diff and commits: describe only what actually changed. Mark checklist boxes
96
96
  docs box). If `commits`/`diffStat` are empty, tell the user there is nothing to
97
97
  describe and stop. **Never** append AI trailers/footers.
98
98
 
99
+ ### A3b. Identifier allowlist for the body
100
+
101
+ Reviewers have never seen okstra, so an okstra-internal id reads as a nonsense
102
+ token. Identifiers in the PR title and body come from this list and nothing else:
103
+
104
+ | allowed | example |
105
+ | --- | --- |
106
+ | repo-relative source path, optionally with a line | `src/commands/pr/pr.mjs:42` |
107
+ | symbol name that exists in the diff | `resolveTemplate()` |
108
+ | branch name, commit subject, commit SHA | `feature/dev-9436`, `a1b2c3d` |
109
+ | issue-tracker ticket id the reviewer can open | `dev-9436` |
110
+
111
+ okstra's own artifact identifiers are out of the allowlist — drop them:
112
+
113
+ - report item ids — `F-001`, `C-001`, `R-001`, `D-0001`, `PREP-001`
114
+ - run artifact names and their `<task-type>-<seq>` suffixes —
115
+ `final-report-implementation-3.md`, `run-manifest-implementation-3.json`
116
+ - phase / stage / worker labels — `final-verification`, `stage-2`, `codex-worker`
117
+ - any path under `.okstra/` — reports, decisions, glossary, user-response sidecars
118
+
119
+ When the only source for a claim is such an artifact, restate its substance in
120
+ the reviewer's terms — what changed in the code, and why — instead of citing the
121
+ id.
122
+
99
123
  ### A4. Output and offer to create the PR
100
124
 
101
125
  Print the filled PR body to chat as a single fenced markdown block. Then ask the
@@ -1,14 +1,16 @@
1
1
  ---
2
2
  name: okstra-user-response
3
3
  description: >-
4
- Use this to answer an okstra task's open clarification questions in-session, without hand-editing any file. The tell is a request to respond to an okstra run's clarification items or its approval gate — "answer okstra", "I'll answer the questions", "clarification response", "user response", "approve and move on". This skill lists tasks whose latest report still has open clarification blockers, presents each open C-id (statement + recommended answer + alternatives), collects the user's own verbatim answers (or a reframe/hold), echoes the full sidecar back for an explicit "confirmed" acknowledgement, optionally records approval, and writes the `user-responses/` sidecar via `okstra user-response write`. NOT for starting a run (okstra-run), inspecting a finished task (okstra-inspect), or generating a brief (okstra-brief-gen). The skill never picks an answer for the user — it only relays recommendations and transcribes what the user decides.
4
+ Use this to answer an okstra task's open clarification questions in-session, without hand-editing any file. The tell is a request to respond to an okstra run's clarification items or its approval gate — "answer okstra", "I'll answer the questions", "clarification response", "user response", "approve and move on". This skill lists tasks whose latest report still has open clarification blockers, then walks the open C-ids one at a time each rewritten as a self-contained question with background, three concrete answers (recommendation first) plus "Enter directly" — echoes the collected answers back for an explicit "confirmed" acknowledgement, optionally records approval, and writes the `user-responses/` sidecar via `okstra user-response write`. NOT for starting a run (okstra-run), inspecting a finished task (okstra-inspect), or generating a brief (okstra-brief-gen). The skill never picks an answer — it builds the option board and transcribes what the user decides.
5
5
  ---
6
6
 
7
7
  # OKSTRA User Response
8
8
 
9
9
  Single entry point for answering the clarification questions an okstra run left behind (the open `C-*` rows under the final report's `## 1. Clarification Items`) **in-session**, and recording those answers as a `runs/<type>/user-responses/` sidecar. The next `/okstra-run` auto-attaches this sidecar via `--clarification-response`.
10
10
 
11
- **Core principle — the skill never picks an answer for the user.** This skill only *presents* recommendations and alternatives; every `value` is the user's own verbatim input. It never calls `write` until the user has explicitly confirmed.
11
+ **Core principle — the skill never picks an answer for the user.** It builds the option board — background, a self-contained question, three concrete answers, `Enter directly` — and the user alone picks from it; every `value` is what the user chose or typed. It never calls `write` until the user has explicitly confirmed.
12
+
13
+ **Second principle — one question at a time.** Never batch two clarification items into one question, and never dump the whole open list at the user. Ask item 1, transcribe the answer, then ask item 2.
12
14
 
13
15
  | Sub-command | What it does |
14
16
  |---|---|
@@ -55,15 +57,15 @@ okstra user-response list --home <resolved-home> --project <projectId> --limit 3
55
57
  ```
56
58
 
57
59
  Returns a JSON array (latest report mtime first); each entry:
58
- `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`.
60
+ `{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}`. `openBlockerCount` counts every open row that still owes the user an answer (`Blocks` = `approval` or `next-phase`); `openApprovalCount` is the `approval`-only subset that gates the frontmatter `approved` flip.
59
61
 
60
62
  - Empty array → answer `No task has open clarification items.` and stop.
61
- - Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, N open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
63
+ - Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, <openBlockerCount> open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
62
64
  - An entry with `unreadable: true` means its `## 1. Clarification Items` heading exists but drifted from the expected format. Flag it as `⚠ §1 format drift — the CLI could not parse its items` and do not proceed on it until the report is regenerated; do not fabricate rows for it.
63
65
 
64
66
  Carry the chosen entry's `reportPath` and `taskKey` forward.
65
67
 
66
- ## Step 2: Show open rows
68
+ ## Step 2: Fetch the open rows (data only — ask nothing yet)
67
69
 
68
70
  ```bash
69
71
  okstra user-response show --report <reportPath>
@@ -71,33 +73,79 @@ okstra user-response show --report <reportPath>
71
73
 
72
74
  Returns `{reportPath, rows: [{id, kind, blocks, status, statement, recommended, alternatives, contextRefs, resolvedRefs}]}`. `resolvedRefs` is `[{ref, definition}]` — the CLI has already looked up what each internal token (`RB-002`, `FU-001`, `§4.7`, …) means in the report body; `definition` is `null` only when the report text alone could not resolve it (e.g. a `path:line` pointer).
73
75
 
74
- **Do NOT paste the raw `statement` as the question.** A `statement` like `Rewrite RB-002 rollback (see §4.7)` is meaningless to the user on its own that is the whole reason this skill exists. For each open `C-*` row, you MUST present:
76
+ This call is a **data fetch, not a presentation step**. Do not print `rows` at the user, and do not paste a raw `statement` as a question a `statement` like `Rewrite RB-002 rollback (see §4.7)` is meaningless on its own, which is the whole reason this skill exists. Announce only the count and the plan:
77
+
78
+ > `<N>` open items — I'll go through them one at a time.
79
+
80
+ Step 3 then turns exactly one row at a time into a question.
81
+
82
+ ## Step 3: Ask one item at a time (loop)
83
+
84
+ Walk the open rows in report order. **One item per `AskUserQuestion` call** — never batch two items into one call, and never pre-announce the items still to come. Ask, transcribe, move on. Head each item with its position and what it is holding up:
85
+
86
+ > **[2/5] C-014** — blocks: approval gate
87
+
88
+ ### 3a. Background first (plain text, above the picker)
89
+
90
+ `AskUserQuestion` carries one sentence of question, so the background goes in the message text right before the call. Write these three blocks, **3–6 lines total** — enough that someone who never read the report can answer:
91
+
92
+ 1. **Situation** — what the run was doing when it stopped at this item, in the user's own domain terms.
93
+ 2. **What is undecided** — the actual fork, with every internal token expanded inline from `resolvedRefs[].definition`; the user must never need to know what `RB-002` is to answer. Say what is stuck right now in plain words: `blocks: approval` → the approval gate stays shut and `implementation` cannot start; `blocks: next-phase` → the next phase cannot begin.
94
+ 3. **What changes with your answer** — what each direction actually causes downstream.
95
+
96
+ Where the background comes from — in this order:
97
+
98
+ - `resolvedRefs[].definition`, which is already resolved and needs no file read;
99
+ - when a `definition` is `null`, or the three blocks would otherwise be empty, **Read** the report `§`/`path:line` that this item's `contextRefs[]` points at and take it from there;
100
+ - **never invent it.** If the report genuinely does not say why, write `The report states nothing beyond the statement itself.` and go straight to the question. Fabricated background corrupts the answer it collects.
101
+
102
+ Close the background with the raw source on one line, so the mapping back to the report stays visible:
103
+
104
+ > Source: `C-014` — "<raw statement>"
105
+
106
+ ### 3b. The picker — three answers plus `Enter directly`
107
+
108
+ One `AskUserQuestion` (single-select), with exactly four options in this order:
109
+
110
+ 1. **The recommendation** — the answer from `recommended` restated in plain language, label suffixed `(Recommended)`; its description carries the rationale (the part of the `recommended` cell after `—`).
111
+ 2. **Alternative** — `alternatives[0]`.
112
+ 3. **Alternative** — `alternatives[1]`.
113
+ 4. **`Enter directly`** — always last, always present.
114
+
115
+ Slots 2–3 fill from `alternatives[]` first. When `alternatives[]` is short, fill each remaining slot with the first of these that applies:
116
+
117
+ - **a concrete candidate the report itself names** — another option row, an approach §2 rejected, a value already in use elsewhere. Its description must open with `Not an okstra proposal — from <where>` and cite that source.
118
+ - **`Reframe this question`** — the item is held rather than answered, and the next run re-asks it.
119
+
120
+ That last entry is what guarantees the picker always reaches three answers plus `Enter directly`. Never pad a slot with an option no source supports, and never mark anything but `recommended` as recommended.
121
+
122
+ Keep each `label` to the answer itself (1–5 words); the description carries the consequence.
123
+
124
+ ### 3c. Transcribe the decision, then move on
75
125
 
76
- - **A rewritten, self-contained question** (lead with this): rewrite `statement` in plain language with **every internal token expanded inline** from its `resolvedRefs` `definition` — the user must never have to know what `RB-002` is to answer. If a token's `definition` is `null`, resolve it yourself **using Read** on the report `§`/`path:line` before asking; only if it is genuinely unresolvable do you say so plainly.
77
- - **`recommended`** — the answer okstra proposed (+ rationale), also stated in plain language. It is only a recommendation.
78
- - **`alternatives[]`** — list them if present, each in plain language.
79
- - **Raw source** (secondary, for traceability): the raw `id` + `statement` so the mapping back to the report stays visible.
126
+ Record one entry `{id, kind, value, rationale?, disposition}`, `kind` copied from the row `show` returned:
80
127
 
81
- ## Step 3: Per-item collect the user's decision (4 branches)
128
+ | The user picks | `value` | `disposition` |
129
+ |---|---|---|
130
+ | Option 1–3 | that option's full answer text, not its short label | `answer` |
131
+ | `Enter directly` → their own answer | the user's utterance verbatim (rationale into `rationale`) | `answer` |
132
+ | `Reframe this question`, or free text asking for it to be re-asked | what the user wants re-asked, verbatim (empty → the raw statement) | `reframe` |
82
133
 
83
- For each open item, the user picks one of four dispositions. You relay and transcribe; you never choose:
134
+ A reframe is not an answer, so it does not satisfy the approval gate.
84
135
 
85
- 1. **Answer** — the user accepts the recommendation or gives their own answer. `disposition: "answer"`, `value` = the user's utterance verbatim.
86
- 2. **Ask-to-explain (fallback)** — Step 2 already expanded every internal token, so the question should already be self-contained. If the user still asks "what does this mean?" or a `resolvedRefs` `definition` was `null`, open the report `§`/`path:line` that this item's `contextRefs[]` points at **using Read** and explain it in plain language. Only explain — **do not pick the answer for the user**; after explaining, hand the decision back to the user.
87
- 3. **Free-form** — the user gives a narrative answer that matches neither a recommendation nor an alternative. `value` = that narrative verbatim, with the rationale in `rationale` if needed. `disposition: "answer"`.
88
- 4. **Hold + reframe** — if the user asks to "re-frame this question itself", record it as `disposition: "reframe"`. Put what the user wants re-asked into `value` verbatim. A reframe is not an answer, so it does not satisfy the approval gate — the next run re-frames that question.
136
+ If the user replies with a question instead of an answer ("what does this mean?"), record nothing: **Read** the `§`/`path:line` from `contextRefs[]`, explain it in plain language, and re-ask the same item with the same four options. Explain only — **do not resolve it for them**; the decision goes back to the user.
89
137
 
90
- Each answer item's JSON shape: `{id, kind, value, rationale?, disposition}`. `kind` uses the same `kind` that `show` gave for that row.
138
+ Echo one line per finished item (`[2/5] C-014 → answer: 60s`), then ask the next one. Do not summarize the whole set until Step 4.
91
139
 
92
140
  ## Step 4: Echo the full sidecar back → explicit "confirmed" gate
93
141
 
94
- Once every open item is handled, **before** calling `write`, **echo back** everything collected (each `id`, `disposition`, `value`, `rationale` if present, and the approval decision from Step 5 below) to the user as-is. Then ask for explicit confirmation:
142
+ Once the loop has covered every open item, **before** calling `write`, **echo back** everything collected (each `id`, `disposition`, `value`, `rationale` if present, and the approval decision from Step 5 below) to the user as-is. Then ask for explicit confirmation:
95
143
 
96
144
  > Record it as shown above? (Reply `confirmed` to write the sidecar.)
97
145
 
98
146
  - **Never call `write`** until the user says `confirmed` (or gives clear approval).
99
147
  - If the user changes any item, show the echo-back again with the changed value and re-confirm.
100
- - Every `value` must be the user's utterance verbatim — this gate guarantees that.
148
+ - Every `value` must be what the user picked or typed, never a wording you settled on for them — this gate guarantees that.
101
149
 
102
150
  ## Step 5: (Optional) Record approval
103
151
 
@@ -166,7 +166,7 @@ def scan_forbidden_actions(
166
166
  resolve_team_needles_with_source,
167
167
  )
168
168
 
169
- since, until = resolve_run_window(team_state_path, team_state)
169
+ since, until = resolve_run_window(team_state_path, team_state, relax_start=False)
170
170
  lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
171
171
  team_needles, _needle_source = resolve_team_needles_with_source(
172
172
  team_state, project_root, since, until, projects_root=claude_projects_dir