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.
- package/docs/architecture/storage-model.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/for-ai/skills/okstra-pr-gen.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +9 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- package/runtime/bin/okstra-antigravity-exec.sh +12 -2
- package/runtime/bin/okstra-claude-exec.sh +10 -1
- package/runtime/bin/okstra-codex-exec.sh +12 -2
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +0 -1
- package/runtime/prompts/profiles/_implementation-executor.md +20 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +34 -12
- package/runtime/python/okstra_ctl/convergence_engine.py +11 -2
- package/runtime/python/okstra_ctl/models.py +2 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/run.py +30 -11
- package/runtime/python/okstra_ctl/session.py +36 -7
- package/runtime/python/okstra_ctl/user_response.py +14 -5
- package/runtime/python/okstra_ctl/worker_artifact_paths.py +5 -1
- package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
- package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
- package/runtime/python/okstra_ctl/worktree.py +39 -9
- package/runtime/python/okstra_token_usage/collect.py +75 -9
- package/runtime/python/okstra_token_usage/pricing.py +4 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +24 -0
- package/runtime/skills/okstra-user-response/SKILL.md +67 -19
- package/runtime/validators/forbidden_actions.py +1 -1
- package/runtime/validators/validate-run.py +26 -8
- 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
|
-
"""
|
|
70
|
-
|
|
101
|
+
"""이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
|
|
102
|
+
다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
|
|
103
|
+
여기에 모은다.
|
|
71
104
|
"""
|
|
72
|
-
sid =
|
|
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
|
-
|
|
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
|
-
"""
|
|
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 =
|
|
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, "
|
|
147
|
+
out.append({**base, "openBlockerCount": 0, "openApprovalCount": 0,
|
|
148
|
+
"unreadable": True})
|
|
142
149
|
continue
|
|
143
150
|
if not scan.blockers:
|
|
144
151
|
continue
|
|
145
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
시작
|
|
193
|
-
늦게 찍히면(관측: dev-9902 — createdAt
|
|
194
|
-
since 가 앞선 세션을 잘라낸다. Task 3 이
|
|
195
|
-
첫 ts 최소값이 createdAt 보다 앞서면 그 값으로
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
since
|
|
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,
|
|
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.**
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
A reframe is not an answer, so it does not satisfy the approval gate.
|
|
84
135
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|