okstra 0.133.0 → 0.134.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.
@@ -34,7 +34,7 @@ okstra preflight --runtime claude-code --json
34
34
 
35
35
  ## Flow
36
36
 
37
- 1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`. If the array is empty, stop with "no open clarification". A 3-option picker (top recommendations + the final option always "Enter directly" for pasting a `reportPath`/`task-key` directly). `unreadable:true` is a §1 format drift — flag it with `⚠` and do not proceed (do not fabricate rows).
37
+ 1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}` (`openBlockerCount` = open rows with `Blocks` in `{approval, next-phase}`; `openApprovalCount` = the `approval`-only subset). If the array is empty, stop with "no open clarification". A 3-option picker (top recommendations + the final option always "Enter directly" for pasting a `reportPath`/`task-key` directly). `unreadable:true` is a §1 format drift — flag it with `⚠` and do not proceed (do not fabricate rows).
38
38
  2. **show**: `okstra user-response show --report <reportPath>` → `rows[]` with `resolvedRefs` (the `definition` of internal tokens such as `RB-002`/`§4.7`). **Do not paste the raw `statement` as the question.** For each open row present: (a) a **self-contained question** with internal tokens expanded inline first, (b) `recommended` (only a recommendation) in plain language, (c) `alternatives[]`, (d) the raw `id`+`statement` as a secondary traceability aid. If `definition` is `null`, resolve the report `§`/`path:line` yourself **using Read**.
39
39
  3. **decision (4 branches)**: the user picks one per item — Answer (accept the recommendation / own answer, verbatim), Ask-to-explain (explain only and hand the decision back to the user), Free-form (narrative verbatim), Hold+reframe (`disposition:"reframe"`, does not satisfy the approval gate). Each item's JSON: `{id, kind, value, rationale?, disposition}`.
40
40
  4. **echo → confirmed gate**: before `write`, echo the whole collection (each `id`·`disposition`·`value`·`rationale`·approval) as-is and get explicit confirmation. Never `write` before `confirmed`. On any change, re-echo and re-confirm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.133.0",
3
- "builtAt": "2026-07-22T19:29:52.565Z",
2
+ "package": "0.134.0",
3
+ "builtAt": "2026-07-23T05:31:24.504Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -41,7 +41,9 @@ Do NOT duplicate the data.json contents here — the data.json is the canonical
41
41
 
42
42
  ## Heartbeat (BLOCKING)
43
43
 
44
- Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
44
+ Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. When a stage runs longer than that, append `- PROGRESS: in-stage:<stage> <ISO-8601-UTC>` with the same stage names. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
45
+
46
+ `data-json-write-start`, `render-start` and `write-result-start` open a stretch whose work is one uninterruptible tool call, so no `in-stage:` line can be appended while it runs. Those three carry a 20-minute budget instead of five; every other stage keeps the five-minute cadence.
45
47
 
46
48
  ## Execution Rules
47
49
 
@@ -258,23 +258,44 @@ def parse_clarification_rows(report_text: str) -> list[dict]:
258
258
 
259
259
  UNRESOLVED_STATUSES = {"open", "answered"}
260
260
 
261
+ # `Blocks` values that gate the user's `approved: true` flip.
262
+ APPROVAL_BLOCKS = frozenset({"approval"})
263
+ # `Blocks` values that owe the user an answer at all. `next-phase` rows never
264
+ # gate approval, but the next phase cannot start with them open either, so the
265
+ # user-response skill must list them — counting only `approval` made a report
266
+ # whose open items were all `next-phase` look like it had none.
267
+ USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
268
+
261
269
 
262
270
  @dataclass(frozen=True)
263
- class ApprovalGateScan:
264
- """Fail-closed read of the §1 approval gate.
271
+ class ClarificationScan:
272
+ """Fail-closed read of §1 for rows blocking on one `Blocks` value set.
265
273
 
266
274
  ``unreadable_reason`` is ``None`` only when the scan is confident: §1
267
275
  parsed cleanly (or is the legitimate table-less placeholder) and
268
276
  ``blockers`` is therefore authoritative. A non-None reason means the
269
- gate must refuse approval — never soft-pass.
277
+ caller must refuse to act — never soft-pass.
270
278
  """
271
279
  blockers: list[ClarificationItem]
272
280
  unreadable_reason: Optional[str]
273
281
 
274
282
 
275
- def scan_approval_gate(report_text: str) -> ApprovalGateScan:
283
+ def scan_approval_gate(report_text: str) -> ClarificationScan:
276
284
  """Scan §1 for unresolved ``Blocks=approval`` rows (``Status`` in
277
285
  ``{open, answered}``), refusing to guess whenever the schema drifted."""
286
+ return scan_clarification_blockers(report_text, APPROVAL_BLOCKS)
287
+
288
+
289
+ def scan_open_user_input(report_text: str) -> ClarificationScan:
290
+ """Scan §1 for every unresolved row that still owes the user an answer
291
+ (``Blocks`` in ``{approval, next-phase}``)."""
292
+ return scan_clarification_blockers(report_text, USER_INPUT_BLOCKS)
293
+
294
+
295
+ def scan_clarification_blockers(
296
+ report_text: str, blocking_values: frozenset[str]
297
+ ) -> ClarificationScan:
298
+ """Shared fail-closed §1 walk for both gates above."""
278
299
  section = _section_1_slice(report_text)
279
300
  if section is None:
280
301
  if _LOOSE_SECTION_1_RE.search(report_text):
@@ -283,32 +304,33 @@ def scan_approval_gate(report_text: str) -> ApprovalGateScan:
283
304
  "the schema heading format (anchor/format drift)"
284
305
  )
285
306
  else:
307
+ expected = "/".join(sorted(blocking_values))
286
308
  reason = (
287
309
  "report has no `## 1. Clarification Items` section — the gate "
288
- "cannot confirm there are no unresolved `Blocks=approval` rows"
310
+ f"cannot confirm there are no unresolved `Blocks={expected}` rows"
289
311
  )
290
- return ApprovalGateScan([], reason)
312
+ return ClarificationScan([], reason)
291
313
  table = _walk_section_1_table(section)
292
314
  if table.items is None:
293
315
  if table.has_pipe_lines:
294
- return ApprovalGateScan([], (
316
+ return ClarificationScan([], (
295
317
  "§1 contains a table but its header row is not the schema "
296
318
  "header (`| ... | Statement | Expected form | User input |`)"
297
319
  ))
298
320
  # Renderer's emptyState placeholder: heading is intact and no table
299
- # was emitted — confidently "no approval-blocking items".
300
- return ApprovalGateScan([], None)
321
+ # was emitted — confidently "no blocking items".
322
+ return ClarificationScan([], None)
301
323
  if table.unparsed_row_count:
302
- return ApprovalGateScan([], (
324
+ return ClarificationScan([], (
303
325
  f"§1 table has {table.unparsed_row_count} row(s) whose metadata "
304
326
  "cell could not be parsed (Blocks/Status markers missing or "
305
327
  "malformed)"
306
328
  ))
307
329
  blockers = [
308
330
  it for it in table.items
309
- if it.blocks == "approval" and it.status in UNRESOLVED_STATUSES
331
+ if it.blocks in blocking_values and it.status in UNRESOLVED_STATUSES
310
332
  ]
311
- return ApprovalGateScan(blockers, None)
333
+ return ClarificationScan(blockers, None)
312
334
 
313
335
 
314
336
  # 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
@@ -1326,7 +1326,12 @@ def _resolve_pr_template(inp: PrepareInputs) -> tuple[str, str]:
1326
1326
 
1327
1327
  @dataclass
1328
1328
  class _ModelBindings:
1329
- """이 run 의 lead/worker 모델 + critic + executor 바인딩."""
1329
+ """이 run 의 lead/worker 모델 + critic + executor 바인딩.
1330
+
1331
+ `*_execution` 은 dispatch 에 쓰이는 CLI 신원 철자다. 카탈로그 값과 달리
1332
+ provider CLI 조회를 거치므로 실패할 수 있고, 그래서 이 묶음에서 함께
1333
+ 해소한다 — prepare 가 run seq 를 할당하거나 worktree 를 만들기 전에.
1334
+ """
1330
1335
 
1331
1336
  lead: ModelAssignment
1332
1337
  cw: ModelAssignment
@@ -1338,6 +1343,9 @@ class _ModelBindings:
1338
1343
  executor_display_name: str
1339
1344
  executor_worker_agent: str
1340
1345
  executor_model_meta: ModelAssignment
1346
+ codex_worker_execution: str
1347
+ antigravity_worker_execution: str
1348
+ executor_execution: str
1341
1349
 
1342
1350
 
1343
1351
  def recommended_role_models() -> dict[str, str]:
@@ -1413,6 +1421,12 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1413
1421
  executor_display_name=display_name,
1414
1422
  executor_worker_agent=worker_agent,
1415
1423
  executor_model_meta=model_meta,
1424
+ codex_worker_execution=_worker_execution_value(
1425
+ m["co"], "codex", "worker", workers),
1426
+ antigravity_worker_execution=_worker_execution_value(
1427
+ m["ge"], "antigravity", "worker", workers),
1428
+ executor_execution=_normalized_execution(
1429
+ model_meta, provider=executor_provider, role="executor"),
1416
1430
  )
1417
1431
 
1418
1432
 
@@ -2176,22 +2190,16 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2176
2190
  "CLAUDE_WORKER_MODEL": cw.display,
2177
2191
  "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
2178
2192
  "CODEX_WORKER_MODEL": co.display,
2179
- "CODEX_WORKER_MODEL_EXECUTION_VALUE": _worker_execution_value(
2180
- co, "codex", "worker", workers,
2181
- ),
2193
+ "CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
2182
2194
  "ANTIGRAVITY_WORKER_MODEL": ge.display,
2183
- "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": _worker_execution_value(
2184
- ge, "antigravity", "worker", workers,
2185
- ),
2195
+ "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
2186
2196
  "REPORT_WRITER_MODEL": rw.display,
2187
2197
  "REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
2188
2198
  "EXECUTOR_PROVIDER": executor_provider,
2189
2199
  "EXECUTOR_DISPLAY_NAME": executor_display_name,
2190
2200
  "EXECUTOR_WORKER_AGENT": executor_worker_agent,
2191
2201
  "EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
2192
- "EXECUTOR_MODEL_EXECUTION_VALUE": _normalized_execution(
2193
- executor_model_meta, provider=executor_provider, role="executor",
2194
- ),
2202
+ "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
2195
2203
  "CRITIC_CHOICE": critic_choice,
2196
2204
  "RELATED_TASKS_JSON": related_tasks_json_str,
2197
2205
  "RELATED_TASKS_BULLETS": bullets,
@@ -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
 
@@ -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,6 +178,55 @@ def _earliest_lead_session_ts(state: dict, team_state_path: Path) -> str | None:
178
178
  return earliest
179
179
 
180
180
 
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
+
181
230
  def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
182
231
  """이 run 의 [시작, 종료] ISO 윈도우.
183
232
 
@@ -193,7 +242,11 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
193
242
  늦게 찍히면(관측: dev-9902 — createdAt 11:43Z 가 05:34Z 시작 세션보다 늦음)
194
243
  since 가 앞선 세션을 잘라낸다. Task 3 이 기록한 `leadSessionIds[]` 세션의
195
244
  첫 ts 최소값이 createdAt 보다 앞서면 그 값으로 since 를 앞당긴다.
196
- `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변."""
245
+ `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변.
246
+
247
+ 완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
248
+ (`_previous_run_end`). 세션 jsonl 은 run 마다 새로 생기지 않으므로 하한이
249
+ 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다."""
197
250
  suffix = run_artifact_suffix(team_state_path)
198
251
  if not suffix:
199
252
  return None, None
@@ -202,6 +255,9 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
202
255
  earliest = _earliest_lead_session_ts(state, team_state_path)
203
256
  if earliest and (since is None or earliest < since):
204
257
  since = earliest
258
+ floor = _previous_run_end(run_dir, suffix)
259
+ if floor and since and since < floor:
260
+ since = floor
205
261
  until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
206
262
  return since, until
207
263
 
@@ -55,10 +55,10 @@ okstra user-response list --home <resolved-home> --project <projectId> --limit 3
55
55
  ```
56
56
 
57
57
  Returns a JSON array (latest report mtime first); each entry:
58
- `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`.
58
+ `{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
59
 
60
60
  - 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).
61
+ - 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
62
  - 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
63
 
64
64
  Carry the chosen entry's `reportPath` and `taskKey` forward.
@@ -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()
@@ -5778,7 +5787,7 @@ def main() -> int:
5778
5787
  contract["required_agent_status_entries"],
5779
5788
  failures,
5780
5789
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
5781
- unavailable_ok_labels=_cli_wrapper_worker_labels(team_state),
5790
+ unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
5782
5791
  )
5783
5792
  validate_team_state_usage(team_state, failures)
5784
5793
 
@@ -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 은 동일하다.
@@ -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(