okstra 0.132.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.
Files changed (32) hide show
  1. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/agents/workers/report-writer-worker.md +3 -1
  5. package/runtime/prompts/lead/report-writer.md +4 -2
  6. package/runtime/prompts/profiles/final-verification.md +1 -1
  7. package/runtime/prompts/profiles/implementation.md +1 -1
  8. package/runtime/prompts/profiles/release-handoff.md +8 -5
  9. package/runtime/python/okstra_ctl/clarification_items.py +34 -12
  10. package/runtime/python/okstra_ctl/handoff.py +72 -32
  11. package/runtime/python/okstra_ctl/model_discovery.py +16 -9
  12. package/runtime/python/okstra_ctl/models.py +6 -5
  13. package/runtime/python/okstra_ctl/render_final_report.py +73 -26
  14. package/runtime/python/okstra_ctl/run.py +19 -11
  15. package/runtime/python/okstra_ctl/session.py +36 -7
  16. package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
  17. package/runtime/python/okstra_ctl/user_response.py +14 -5
  18. package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
  19. package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
  20. package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
  21. package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
  22. package/runtime/python/okstra_ctl/worktree.py +39 -9
  23. package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
  24. package/runtime/python/okstra_token_usage/collect.py +57 -1
  25. package/runtime/python/okstra_token_usage/report.py +44 -14
  26. package/runtime/schemas/final-report-v1.0.schema.json +1 -0
  27. package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
  28. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  29. package/runtime/templates/reports/final-report.template.md +3 -0
  30. package/runtime/validators/validate-run.py +15 -6
  31. package/runtime/validators/validate_session_conformance.py +16 -6
  32. package/src/commands/execute/handoff.mjs +1 -1
@@ -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
 
@@ -1421,7 +1435,7 @@ def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> st
1421
1435
  so the manifest carries the dispatch-correct spelling. SSOT for the mapping
1422
1436
  lives in model_discovery; this is only the run.py call seam."""
1423
1437
  return normalize_execution_for_dispatch(
1424
- provider=provider, execution=meta.execution, display=meta.display, role=role,
1438
+ provider=provider, execution=meta.execution, role=role,
1425
1439
  )
1426
1440
 
1427
1441
 
@@ -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):
@@ -2,7 +2,8 @@
2
2
 
3
3
  Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
4
4
  skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
5
- stage 의 worktree·registry 키·stage 브랜치를 정리(dirty 면 skip).
5
+ stage 의 worktree 디렉터리와 registry 키를 정리(dirty 면 skip). stage 브랜치는
6
+ 스택 보존을 위해 삭제하지 않는다.
6
7
  설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
7
8
  """
8
9
  from __future__ import annotations
@@ -64,7 +65,7 @@ def integrate_stages(
64
65
  ) -> IntegrateResult:
65
66
  """whole-task 통합: Phase A 머지 → Phase B teardown.
66
67
 
67
- teardown=False 면 Phase B(stage worktree·브랜치·registry 키 정리)를 건너뛴다 —
68
+ teardown=False 면 Phase B(stage worktree 디렉터리·registry 키 정리)를 건너뛴다 —
68
69
  container 는 머지만 하고 stage 트리를 보존한다(스펙 §10). final-verification
69
70
  호출은 기본값(True)으로 기존 행위를 유지한다."""
70
71
  from .consumers import latest_done_by_stage
@@ -137,11 +138,39 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
137
138
  return None
138
139
 
139
140
 
141
+ def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str) -> str:
142
+ """Warn text when the stage branch tip is not an ancestor of the task
143
+ worktree HEAD, or "" when it is (or cannot be resolved).
144
+
145
+ Phase A merges `done.head_commit`, never the branch ref, so a stage branch
146
+ can carry commits that whole-task verification never saw. `local-checkout
147
+ --stage <N>` hands that branch to the user as a verified branch, so the
148
+ divergence has to be reported."""
149
+ from .worktree import is_ancestor
150
+
151
+ tip = _git(task_wt, "rev-parse", "--verify", "--quiet", branch)
152
+ if tip.returncode != 0:
153
+ return ""
154
+ sha = tip.stdout.strip()
155
+ head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
156
+ if is_ancestor(task_wt, sha, head):
157
+ return ""
158
+ return (f"stage {stage_n} 브랜치 {branch} 의 tip {sha[:12]} 이 검증된 task "
159
+ f"HEAD 에 포함되지 않습니다 — 검증에 포함되지 않은 커밋이 있습니다")
160
+
161
+
140
162
  def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
141
- """clean stage worktree 제거 + registry release + branch -d.
142
- branch -d 성공한 뒤에만 branch 슬롯을 free 한다 물리 브랜치가
143
- 살아있는 채로 슬롯이 풀려 동시 run 이 같은 이름을 재예약하는 윈도우를
144
- 막기 위함. 반환 ('torn_down'|'skipped'|'warn', detail)."""
163
+ """Remove the clean stage worktree directory and mark the registry row
164
+ released. The stage branch is deliberately kept: the per-stage stack
165
+ branches must survive whole-task verification so that
166
+ `okstra handoff local-checkout --stage <N>` still has a target. The
167
+ branch's registry slot is not touched here — `consumers`'
168
+ `_release_stage_reservation` already freed it at stage-done time.
169
+
170
+ Warns when the surviving branch tip carries commits the verified merge
171
+ never included. Teardown itself is already complete at that point, so the
172
+ warning reports the divergence rather than undoing the cleanup. Returns
173
+ ('torn_down'|'skipped'|'warn', detail)."""
145
174
  from . import worktree_registry
146
175
 
147
176
  wt = (row or {}).get("worktree_path") or ""
@@ -152,12 +181,9 @@ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
152
181
  return outcome
153
182
  worktree_registry.release_status(project_id, task_group, task_id, stage_number=stage_n)
154
183
  if branch:
155
- bd = _git(task_wt, "branch", "-d", branch)
156
- if bd.returncode != 0:
157
- return ("warn", f"stage {stage_n} branch -d {branch} 실패(미머지?): "
158
- f"{bd.stderr.strip()}")
159
- worktree_registry.free_branch_slot(project_id, task_group, task_id,
160
- stage_number=stage_n)
184
+ warning = _unmerged_branch_tip_warning(task_wt, stage_n, branch)
185
+ if warning:
186
+ return ("warn", warning)
161
187
  return ("torn_down", "")
162
188
 
163
189
 
@@ -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
  ),
@@ -41,6 +41,7 @@ _NON_BODY_PREFIXES = (
41
41
  "**Worker Preamble Path:**",
42
42
  *ERRORS_PATH_HEADERS,
43
43
  "**Read scope:**",
44
+ "**File write mode:**",
44
45
  "**Worktree:**",
45
46
  "**Verification scope:**",
46
47
  "**Verification base ref:**",
@@ -63,6 +64,9 @@ _WORKER_SPECIFIC_PREFIXES = (
63
64
  "Assigned worker prompt history path:",
64
65
  "**Errors sidecar path:**",
65
66
  "**Worker Result Path:**",
67
+ # Only antigravity carries this (worker_prompt_headers.PLAIN_FILE_WRITE_HEADER)
68
+ # because only agy has an artifact store; unstripped it reads as divergence.
69
+ "**File write mode:**",
66
70
  "**Model:**",
67
71
  "**Pane role:**",
68
72
  )
@@ -37,6 +37,24 @@ READ_SCOPE_HEADER = (
37
37
  "or Assumptions* instead of reading it."
38
38
  )
39
39
 
40
+ # `agy`'s write tool validates the target against the Gemini artifact store
41
+ # whenever the model attaches ArtifactMetadata, and rejects every path outside
42
+ # `~/.gemini/antigravity-cli/brain/<uuid>/`. All okstra worker outputs live under
43
+ # `<PROJECT_ROOT>/.okstra/`, so an attached ArtifactMetadata fails the write —
44
+ # usually on the audit sidecar, after which the CLI exits 0 having persisted no
45
+ # result file. Only antigravity carries this: codex and claude have no artifact
46
+ # store, and a shared header would break the cross-worker prompt-body equality
47
+ # that `worker_prompt_contract` enforces.
48
+ PLAIN_FILE_WRITE_HEADER = (
49
+ "**File write mode:** Write every okstra output (result file, audit "
50
+ "sidecar, errors sidecar) as a plain project file at the absolute path this "
51
+ "prompt gives. Do NOT attach ArtifactMetadata and do NOT route these writes "
52
+ "through the Gemini artifact store — artifact paths are restricted to the "
53
+ "brain folder, so an artifact write under `<PROJECT_ROOT>/.okstra/` is "
54
+ "rejected and the run ends with no result file."
55
+ )
56
+ ANTIGRAVITY_WORKER_ID = "antigravity"
57
+
40
58
 
41
59
  def worker_prompt_headers(
42
60
  *,
@@ -81,6 +99,8 @@ def worker_prompt_headers(
81
99
  f"**Errors sidecar path:** {errors_sidecar_path}",
82
100
  READ_SCOPE_HEADER,
83
101
  ])
102
+ if worker_id == ANTIGRAVITY_WORKER_ID:
103
+ headers.append(PLAIN_FILE_WRITE_HEADER)
84
104
  if _string_value(manifest.get("taskType")) == "improvement-discovery":
85
105
  headers.append(
86
106
  f"{GRILLING_LOG_HEADER} "
@@ -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)
@@ -275,10 +275,11 @@ def release_status(
275
275
  ) -> Optional[WorktreeEntry]:
276
276
  """Mark the entry as `released` WITHOUT freeing its branch index slot.
277
277
 
278
- Use when the physical branch still exists and its name must stay
279
- reserved e.g. teardown defers the slot-free until `git branch -d`
280
- actually succeeds, so the registry never advertises a free branch
281
- name that is still alive on disk. Returns the prior entry, or None.
278
+ Use when the caller must not touch the branch index either the slot is
279
+ still needed, or another caller owns its lifetime. Stage teardown is the
280
+ latter case: `consumers._release_stage_reservation` already ran `release()`
281
+ at stage-done time, so teardown only flips the status. Returns the prior
282
+ entry, or None.
282
283
  """
283
284
  key = task_key(project_id, task_group, task_id, stage_number, group_id)
284
285
  with _registry_lock():
@@ -323,11 +324,12 @@ def release(
323
324
  group_id: Optional[str] = None,
324
325
  ) -> Optional[WorktreeEntry]:
325
326
  """Mark the entry as `released` and free its branch slot (worktree dir
326
- intact — preservation is the project's policy). Used by callers that
327
- release at a point where the branch is gone or intentionally orphaned
328
- (done-time reservation release, group cleanup). Teardown of a physical
329
- branch must instead pair `release_status` + `free_branch_slot` so the
330
- slot is freed only after `git branch -d` succeeds.
327
+ intact — preservation is the project's policy). Used at the points that
328
+ end a reservation's life: done-time stage release
329
+ (`consumers._release_stage_reservation`) and group cleanup. Both free the
330
+ slot even when the physical branch survives stage stack branches are
331
+ deliberately kept, and their names are task-scoped, so a freed slot cannot
332
+ collide with a live branch from another task.
331
333
  Returns the prior entry, or None when not found.
332
334
  """
333
335
  entry = release_status(project_id, task_group, task_id, stage_number, group_id)
@@ -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