okstra 0.64.1 → 0.66.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/bin/okstra +1 -0
- package/docs/kr/architecture.md +2 -0
- package/docs/kr/cli.md +12 -4
- package/docs/kr/performance-improvement-plan-v2.md +2 -1
- package/docs/project-structure-overview.md +1 -0
- package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +1029 -0
- package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +168 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +4 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -0
- package/runtime/agents/workers/gemini-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +4 -0
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +96 -37
- package/runtime/python/okstra_ctl/context_cost.py +86 -8
- package/runtime/python/okstra_ctl/locks.py +32 -0
- package/runtime/python/okstra_ctl/migrate.py +45 -6
- package/runtime/python/okstra_ctl/models.py +5 -0
- package/runtime/python/okstra_ctl/pr_template.py +2 -7
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/run.py +58 -44
- package/runtime/python/okstra_ctl/run_context.py +3 -8
- package/runtime/python/okstra_ctl/seeding.py +25 -18
- package/runtime/python/okstra_ctl/wizard.py +9 -11
- package/runtime/python/okstra_ctl/worktree.py +13 -0
- package/runtime/python/okstra_project/dirs.py +10 -1
- package/runtime/python/okstra_token_usage/claude.py +226 -61
- package/runtime/python/okstra_token_usage/cli.py +10 -1
- package/runtime/python/okstra_token_usage/collect.py +34 -27
- package/runtime/python/okstra_token_usage/cursor.py +93 -0
- package/runtime/python/okstra_token_usage/paths.py +29 -2
- package/runtime/python/okstra_token_usage/pricing.py +7 -3
- package/runtime/skills/okstra-coding-preflight/clean-code.md +15 -0
- package/runtime/skills/okstra-inspect/SKILL.md +16 -11
- package/runtime/skills/okstra-run/templates/pr-body.template.md +13 -16
- package/runtime/skills/okstra-schedule/SKILL.md +3 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
- package/runtime/validators/lib/fixtures.sh +73 -10
- package/runtime/validators/lib/runners.sh +4 -0
- package/runtime/validators/validate-run.py +53 -0
- package/runtime/validators/validate_session_conformance.py +430 -0
- package/src/migrate.mjs +31 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""agents/SKILL.md BLOCKING 계약 3종의 post-hoc conformance 검사.
|
|
2
|
+
|
|
3
|
+
설계: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
|
|
4
|
+
|
|
5
|
+
| 검사 | 선언 위치 | 증거 |
|
|
6
|
+
|------|----------|------|
|
|
7
|
+
| 1. lead PROGRESS 체크포인트 라인 | agents/SKILL.md "Progress reporting (BLOCKING)" | lead 세션 jsonl 의 assistant text 블록 |
|
|
8
|
+
| 2. claude-worker 5분 heartbeat | agents/workers/claude-worker.md "Heartbeat" | audit 사이드카의 `- PROGRESS: <stage> <ISO>` 라인 |
|
|
9
|
+
| 3. implementation sidecar entry guard | agents/SKILL.md "Entry guard (BLOCKING)" | lead 세션 jsonl 의 Read tool_use |
|
|
10
|
+
|
|
11
|
+
스캔 규칙 (false-pass 방지가 핵심):
|
|
12
|
+
- `type == "assistant"` 레코드만 본다 — Skill 호출 시 SKILL.md 본문(체크포인트
|
|
13
|
+
라인 예시 포함)이 tool_result(user 레코드)로 transcript 에 주입되므로,
|
|
14
|
+
assistant 외 레코드를 보면 즉시 false pass 가 난다. `isSidechain` 레코드도 제외.
|
|
15
|
+
- run 윈도우(`resolve_run_window`)로 스코핑한다 — in-session lead 는 세션 전체
|
|
16
|
+
jsonl 에 기록되므로, 같은 세션의 직전 run 이 남긴 라인이 증거로 오인된다.
|
|
17
|
+
(실측: dev-9692 lead 세션 1개에 implementation run 001–003 이 모두 들어 있음.)
|
|
18
|
+
- validator 실행 시점(Phase 7) **이후** 에 출력되는 체크포인트
|
|
19
|
+
(`phase-7-teardown`, `complete`)는 구조적으로 검사 불가 — 요구하지 않는다.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
_DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
|
|
32
|
+
_ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
33
|
+
|
|
34
|
+
# lead 의 체크포인트 라인 — assistant text 블록 안에서 line-anchored 로만 인정.
|
|
35
|
+
_PROGRESS_LINE_RE = re.compile(r"^PROGRESS:[ \t]+(?P<phase>\S+)(?P<rest>.*)$", re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
# claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
|
|
38
|
+
_HEARTBEAT_LINE_RE = re.compile(
|
|
39
|
+
r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
|
|
40
|
+
)
|
|
41
|
+
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
42
|
+
# 고정 grace 60초를 더한다.
|
|
43
|
+
_HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
44
|
+
|
|
45
|
+
# Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
|
|
46
|
+
# 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
|
|
47
|
+
_SIDECAR_BASENAMES = (
|
|
48
|
+
"_implementation-executor.md",
|
|
49
|
+
"_implementation-verifier.md",
|
|
50
|
+
"_implementation-deliverable.md",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SessionConformanceResult:
|
|
56
|
+
errors: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ok(self) -> bool:
|
|
60
|
+
return not self.errors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class _LeadEvidence:
|
|
65
|
+
progress: list[tuple[str, str, str]] = field(default_factory=list) # (ts, phase-id, line)
|
|
66
|
+
sidecar_reads: dict[str, list[str]] = field(default_factory=dict) # basename -> [ts]
|
|
67
|
+
scanned_files: list[Path] = field(default_factory=list)
|
|
68
|
+
window: tuple[str | None, str | None] = (None, None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ensure_token_usage_importable() -> None:
|
|
72
|
+
"""okstra_token_usage 패키지를 레이아웃별(repo/scripts, runtime/python,
|
|
73
|
+
OKSTRA_PYTHONPATH)로 해소 — validate-run.py `_import_token_usage` 와 동일 후보."""
|
|
74
|
+
here = Path(__file__).resolve().parent
|
|
75
|
+
candidates = [here.parent / "scripts", here.parent / "python"]
|
|
76
|
+
env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
|
|
77
|
+
if env_pp:
|
|
78
|
+
candidates.append(Path(env_pp))
|
|
79
|
+
for candidate in candidates:
|
|
80
|
+
if candidate.is_dir() and (candidate / "okstra_token_usage").is_dir():
|
|
81
|
+
if str(candidate) not in sys.path:
|
|
82
|
+
sys.path.insert(0, str(candidate))
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _norm(value: str) -> str:
|
|
87
|
+
return re.sub(r"[^a-z0-9]", "", (value or "").lower())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_report_writer(worker: dict) -> bool:
|
|
91
|
+
return "reportwriter" in _norm(str(worker.get("role", ""))) or "reportwriter" in _norm(
|
|
92
|
+
str(worker.get("workerId", ""))
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _worker_needles(worker: dict) -> list[str]:
|
|
97
|
+
"""worker 식별 needle — 선언 형식 `worker=<role>` 와 role/workerId 표기를
|
|
98
|
+
normalize 매칭으로 흡수한다 (`Claude worker` ↔ `claude-worker`)."""
|
|
99
|
+
needles = []
|
|
100
|
+
role = _norm(str(worker.get("role", "")))
|
|
101
|
+
if role:
|
|
102
|
+
needles.append(role)
|
|
103
|
+
worker_id = _norm(str(worker.get("workerId", "")))
|
|
104
|
+
if worker_id:
|
|
105
|
+
needles.append(worker_id + "worker")
|
|
106
|
+
return needles
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _scan_one_jsonl(
|
|
110
|
+
path: Path, since: str | None, until: str | None
|
|
111
|
+
) -> tuple[list[tuple[str, str, str]], dict[str, list[str]], str | None]:
|
|
112
|
+
"""jsonl 한 파일에서 (progress, sidecar reads, agentName) 을 추출한다."""
|
|
113
|
+
from okstra_token_usage.paths import ts_in_window
|
|
114
|
+
|
|
115
|
+
progress: list[tuple[str, str, str]] = []
|
|
116
|
+
reads: dict[str, list[str]] = {}
|
|
117
|
+
agent_name: str | None = None
|
|
118
|
+
try:
|
|
119
|
+
fh = path.open(encoding="utf-8")
|
|
120
|
+
except OSError:
|
|
121
|
+
return progress, reads, agent_name
|
|
122
|
+
with fh:
|
|
123
|
+
for raw in fh:
|
|
124
|
+
try:
|
|
125
|
+
rec = json.loads(raw)
|
|
126
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
127
|
+
continue
|
|
128
|
+
if agent_name is None and rec.get("agentName"):
|
|
129
|
+
agent_name = rec["agentName"]
|
|
130
|
+
if rec.get("type") != "assistant" or rec.get("isSidechain"):
|
|
131
|
+
continue
|
|
132
|
+
ts = rec.get("timestamp") or ""
|
|
133
|
+
if ts and not ts_in_window(ts, since, until):
|
|
134
|
+
continue
|
|
135
|
+
msg = rec.get("message") or {}
|
|
136
|
+
for block in msg.get("content") or []:
|
|
137
|
+
if not isinstance(block, dict):
|
|
138
|
+
continue
|
|
139
|
+
if block.get("type") == "text":
|
|
140
|
+
for m in _PROGRESS_LINE_RE.finditer(block.get("text") or ""):
|
|
141
|
+
progress.append((ts, m.group("phase"), m.group(0).strip()))
|
|
142
|
+
elif block.get("type") == "tool_use" and block.get("name") == "Read":
|
|
143
|
+
base = Path(str((block.get("input") or {}).get("file_path") or "")).name
|
|
144
|
+
if base in _SIDECAR_BASENAMES:
|
|
145
|
+
reads.setdefault(base, []).append(ts)
|
|
146
|
+
return progress, reads, agent_name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _collect_lead_evidence(
|
|
150
|
+
team_state: dict,
|
|
151
|
+
team_state_path: Path,
|
|
152
|
+
project_root: Path,
|
|
153
|
+
projects_root: Path | None,
|
|
154
|
+
) -> tuple[_LeadEvidence | None, str | None]:
|
|
155
|
+
"""lead 후보 jsonl 을 스캔해 증거를 모은다.
|
|
156
|
+
|
|
157
|
+
후보 = {기록된 lead.sessionId} ∪ {team 태그는 있으나 agentName 이 없는 세션}.
|
|
158
|
+
후자는 `claude --resume` 으로 lead 세션이 fork 된 경우(새 sessionId,
|
|
159
|
+
agentName 없음)를 흡수한다 — worker 세션은 agentName 이 있어 자연 배제된다.
|
|
160
|
+
"""
|
|
161
|
+
from okstra_token_usage.claude import find_claude_team_sessions
|
|
162
|
+
from okstra_token_usage.collect import resolve_run_window, resolve_team_name
|
|
163
|
+
from okstra_token_usage.paths import claude_project_dir
|
|
164
|
+
|
|
165
|
+
since, until = resolve_run_window(team_state_path, team_state)
|
|
166
|
+
lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
|
|
167
|
+
sessions = find_claude_team_sessions(
|
|
168
|
+
project_root, resolve_team_name(team_state), lead_sid, projects_root=projects_root
|
|
169
|
+
)
|
|
170
|
+
evidence = _LeadEvidence(window=(since, until))
|
|
171
|
+
for sid, path in sorted(sessions.items()):
|
|
172
|
+
progress, reads, agent_name = _scan_one_jsonl(path, since, until)
|
|
173
|
+
if agent_name and sid != lead_sid:
|
|
174
|
+
continue # agentName 이 찍힌 세션은 worker — lead 후보에서 제외
|
|
175
|
+
evidence.scanned_files.append(path)
|
|
176
|
+
evidence.progress.extend(progress)
|
|
177
|
+
for base, ts_list in reads.items():
|
|
178
|
+
evidence.sidecar_reads.setdefault(base, []).extend(ts_list)
|
|
179
|
+
if not evidence.scanned_files:
|
|
180
|
+
proj_dir = claude_project_dir(project_root, projects_root)
|
|
181
|
+
return None, (
|
|
182
|
+
f"lead session jsonl not found under {proj_dir} "
|
|
183
|
+
f"(lead.sessionId={lead_sid or '<empty>'}) — PROGRESS checkpoint / "
|
|
184
|
+
"implementation entry-guard conformance cannot be verified, which "
|
|
185
|
+
"fails the run (same principle as the token-usage accuracy contract)."
|
|
186
|
+
)
|
|
187
|
+
evidence.progress.sort()
|
|
188
|
+
for ts_list in evidence.sidecar_reads.values():
|
|
189
|
+
ts_list.sort()
|
|
190
|
+
return evidence, None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _convergence_rounds_ran(run_dir: Path, suffix: str | None) -> bool:
|
|
194
|
+
"""이 run 의 convergence state artifact 가 실제 round 를 1회 이상 돌았는지.
|
|
195
|
+
auto-disable(`totalRounds: 0`)·artifact 부재는 phase-5.5 라인을 요구하지 않는다."""
|
|
196
|
+
if not suffix:
|
|
197
|
+
return False
|
|
198
|
+
path = run_dir / "state" / f"convergence-{suffix}.json"
|
|
199
|
+
try:
|
|
200
|
+
doc = json.loads(path.read_text())
|
|
201
|
+
except (OSError, json.JSONDecodeError):
|
|
202
|
+
return False
|
|
203
|
+
return isinstance(doc, dict) and (doc.get("totalRounds") or 0) >= 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _phase_mentions_worker(lines: list[tuple[str, str]], needles: list[str]) -> bool:
|
|
207
|
+
return any(needle in _norm(line) for _ts, line in lines for needle in needles)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_worker_checkpoint_lines(
|
|
211
|
+
by_phase: dict[str, list[tuple[str, str]]],
|
|
212
|
+
analysis_workers: list[dict],
|
|
213
|
+
errors: list[str],
|
|
214
|
+
) -> None:
|
|
215
|
+
"""phase-4-dispatch / phase-5-collect 의 per-worker 라인 (SKILL.md: once per worker)."""
|
|
216
|
+
for worker in analysis_workers:
|
|
217
|
+
role = str(worker.get("role", "")).strip() or "<unknown role>"
|
|
218
|
+
status = str(worker.get("status", "")).strip()
|
|
219
|
+
needles = _worker_needles(worker)
|
|
220
|
+
if status in _ATTEMPTED_STATUSES and not _phase_mentions_worker(
|
|
221
|
+
by_phase.get("phase-4-dispatch", []), needles
|
|
222
|
+
):
|
|
223
|
+
errors.append(
|
|
224
|
+
f"PROGRESS checkpoint missing: no `phase-4-dispatch worker=<role>` "
|
|
225
|
+
f"line names worker `{role}` — one line per dispatched worker, "
|
|
226
|
+
"agents/SKILL.md 'Progress reporting (BLOCKING)'."
|
|
227
|
+
)
|
|
228
|
+
if status == "completed" and not _phase_mentions_worker(
|
|
229
|
+
by_phase.get("phase-5-collect", []), needles
|
|
230
|
+
):
|
|
231
|
+
errors.append(
|
|
232
|
+
f"PROGRESS checkpoint missing: no `phase-5-collect worker=<role>` "
|
|
233
|
+
f"line names completed worker `{role}` — one line per collected "
|
|
234
|
+
"result, agents/SKILL.md 'Progress reporting (BLOCKING)'."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_progress_checkpoints(
|
|
239
|
+
evidence: _LeadEvidence,
|
|
240
|
+
team_state: dict,
|
|
241
|
+
run_dir: Path,
|
|
242
|
+
suffix: str | None,
|
|
243
|
+
errors: list[str],
|
|
244
|
+
) -> None:
|
|
245
|
+
by_phase: dict[str, list[tuple[str, str]]] = {}
|
|
246
|
+
for ts, phase, line in evidence.progress:
|
|
247
|
+
by_phase.setdefault(phase, []).append((ts, line))
|
|
248
|
+
|
|
249
|
+
def require(phase: str, condition: bool, detail: str) -> None:
|
|
250
|
+
if condition and phase not in by_phase:
|
|
251
|
+
errors.append(
|
|
252
|
+
f"PROGRESS checkpoint missing: `{phase}` ({detail}) — "
|
|
253
|
+
"agents/SKILL.md 'Progress reporting (BLOCKING)'."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
intake = by_phase.get("phase-1-intake", [])
|
|
257
|
+
if not any("complete" not in line.lower() for _ts, line in intake):
|
|
258
|
+
errors.append(
|
|
259
|
+
"PROGRESS checkpoint missing: `phase-1-intake reading task bundle` "
|
|
260
|
+
"(start-of-Phase-1 line) — agents/SKILL.md 'Progress reporting (BLOCKING)'."
|
|
261
|
+
)
|
|
262
|
+
if not any("complete" in line.lower() for _ts, line in intake):
|
|
263
|
+
errors.append(
|
|
264
|
+
"PROGRESS checkpoint missing: `phase-1-intake complete` "
|
|
265
|
+
"(after all intake reads) — agents/SKILL.md 'Progress reporting (BLOCKING)'."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
workers = [w for w in (team_state.get("workers") or []) if isinstance(w, dict)]
|
|
269
|
+
analysis_workers = [w for w in workers if not _is_report_writer(w)]
|
|
270
|
+
any_dispatched = any(
|
|
271
|
+
str(w.get("status", "")).strip() in _DISPATCHED_STATUSES for w in workers
|
|
272
|
+
)
|
|
273
|
+
require("phase-2-prompts", bool(workers), "before any Write to assigned prompt paths")
|
|
274
|
+
require("phase-3-team-create", any_dispatched, "immediately before the TeamCreate call")
|
|
275
|
+
_check_worker_checkpoint_lines(by_phase, analysis_workers, errors)
|
|
276
|
+
require(
|
|
277
|
+
"phase-5.5-convergence",
|
|
278
|
+
_convergence_rounds_ran(run_dir, suffix),
|
|
279
|
+
"at the start of each convergence round (state artifact records totalRounds >= 1)",
|
|
280
|
+
)
|
|
281
|
+
report_writer = next((w for w in workers if _is_report_writer(w)), None)
|
|
282
|
+
require(
|
|
283
|
+
"phase-6-synthesis",
|
|
284
|
+
report_writer is not None
|
|
285
|
+
and str(report_writer.get("status", "")).strip() in _DISPATCHED_STATUSES,
|
|
286
|
+
"at the start of Phase 6 (report-writer dispatch)",
|
|
287
|
+
)
|
|
288
|
+
require("phase-7-persist", True, "at the start of Phase 7")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_iso(ts: str) -> datetime | None:
|
|
292
|
+
try:
|
|
293
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
294
|
+
except ValueError:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
|
|
299
|
+
rel = path.name
|
|
300
|
+
try:
|
|
301
|
+
content = path.read_text(encoding="utf-8")
|
|
302
|
+
except OSError as exc:
|
|
303
|
+
errors.append(f"claude-worker audit sidecar unreadable: {rel} ({exc})")
|
|
304
|
+
return
|
|
305
|
+
entries = [(m.group("stage"), m.group("ts")) for m in _HEARTBEAT_LINE_RE.finditer(content)]
|
|
306
|
+
if not entries:
|
|
307
|
+
errors.append(
|
|
308
|
+
f"`{rel}` has no `- PROGRESS: <stage> <ISO-8601-UTC>` heartbeat lines — "
|
|
309
|
+
"the claude-worker MUST write `started` immediately and append one "
|
|
310
|
+
"line per stage at <= 5-minute cadence (agents/workers/claude-worker.md "
|
|
311
|
+
"'Heartbeat', agents/SKILL.md Common Mistakes)."
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
if entries[0][0] != "started":
|
|
315
|
+
errors.append(
|
|
316
|
+
f"`{rel}`: first heartbeat stage must be `started` "
|
|
317
|
+
f"(found `{entries[0][0]}`) — the sidecar is written BEFORE the "
|
|
318
|
+
"per-file reads, with a `- PROGRESS: started <ISO>` line."
|
|
319
|
+
)
|
|
320
|
+
# result 파일이 존재하면 마지막 단계 마커도 있어야 한다. timeout 으로 중단된
|
|
321
|
+
# worker(result 없음)에는 요구하지 않는다 — hang 이전 구간의 cadence 만 본다.
|
|
322
|
+
result_file = path.with_name(rel.replace("-audit-", "-"))
|
|
323
|
+
if result_file.exists() and not any(s == "write-result-start" for s, _ in entries):
|
|
324
|
+
errors.append(
|
|
325
|
+
f"`{rel}`: heartbeat is missing the `write-result-start` stage line "
|
|
326
|
+
f"although the worker result `{result_file.name}` exists — every "
|
|
327
|
+
"stage must append its own line (agents/workers/claude-worker.md 'Heartbeat')."
|
|
328
|
+
)
|
|
329
|
+
prev: datetime | None = None
|
|
330
|
+
for stage, raw_ts in entries:
|
|
331
|
+
ts = _parse_iso(raw_ts)
|
|
332
|
+
if ts is None:
|
|
333
|
+
errors.append(
|
|
334
|
+
f"`{rel}`: heartbeat line for stage `{stage}` has an unparseable "
|
|
335
|
+
f"ISO-8601 timestamp `{raw_ts}`."
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
if prev is not None:
|
|
339
|
+
gap = (ts - prev).total_seconds()
|
|
340
|
+
if gap < 0:
|
|
341
|
+
errors.append(
|
|
342
|
+
f"`{rel}`: heartbeat timestamps regress at stage `{stage}` ({raw_ts})."
|
|
343
|
+
)
|
|
344
|
+
elif gap > _HEARTBEAT_MAX_GAP_SECONDS:
|
|
345
|
+
errors.append(
|
|
346
|
+
f"`{rel}`: heartbeat gap before stage `{stage}` is {int(gap)}s — "
|
|
347
|
+
"the append cadence MUST NOT exceed 5 minutes (+60s grace); emit "
|
|
348
|
+
"`- PROGRESS: in-stage:<stage> <ISO>` during long stages."
|
|
349
|
+
)
|
|
350
|
+
prev = ts
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _check_heartbeat_sidecars(run_dir: Path, task_type: str, errors: list[str]) -> None:
|
|
354
|
+
"""검사 2 — 사이드카 **존재** 는 validate-run.py validate_worker_results_audit 가
|
|
355
|
+
이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다."""
|
|
356
|
+
worker_results_dir = run_dir / "worker-results"
|
|
357
|
+
if not worker_results_dir.is_dir():
|
|
358
|
+
return
|
|
359
|
+
for path in sorted(worker_results_dir.glob(f"claude-worker-audit-{task_type}-*.md")):
|
|
360
|
+
_check_heartbeat_sidecar(path, errors)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _check_implementation_sidecar_reads(evidence: _LeadEvidence, errors: list[str]) -> None:
|
|
364
|
+
"""검사 3 — entry guard. fresh-read 규칙(이전 run 기억으로 갈음 불가)은 run
|
|
365
|
+
윈도우 스코핑이 보장한다: 이번 윈도우 안의 Read 만 증거로 인정된다."""
|
|
366
|
+
anchors: dict[str, str] = {}
|
|
367
|
+
for ts, phase, _line in evidence.progress: # progress 는 ts 정렬 — 첫 항목이 최초 발생
|
|
368
|
+
if phase in ("phase-6-synthesis", "phase-7-persist") and ts:
|
|
369
|
+
anchors.setdefault(phase, ts)
|
|
370
|
+
expectations = (
|
|
371
|
+
("_implementation-executor.md", "phase-6-synthesis", "Phase 5"),
|
|
372
|
+
("_implementation-verifier.md", "phase-6-synthesis", "Phase 5"),
|
|
373
|
+
("_implementation-deliverable.md", "phase-7-persist", "Phase 6"),
|
|
374
|
+
)
|
|
375
|
+
for basename, anchor_phase, read_at in expectations:
|
|
376
|
+
ts_list = evidence.sidecar_reads.get(basename) or []
|
|
377
|
+
if not ts_list:
|
|
378
|
+
errors.append(
|
|
379
|
+
f"implementation entry guard: no `Read` of `{basename}` found in "
|
|
380
|
+
f"the lead session jsonl within this run's window — the sidecar "
|
|
381
|
+
f"MUST be read fresh at {read_at} every implementation run "
|
|
382
|
+
"(agents/SKILL.md 'Entry guard (BLOCKING)')."
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
anchor_ts = anchors.get(anchor_phase)
|
|
386
|
+
if anchor_ts and min(ts_list) >= anchor_ts:
|
|
387
|
+
errors.append(
|
|
388
|
+
f"implementation entry guard: `{basename}` was first Read at "
|
|
389
|
+
f"{min(ts_list)}, not before the first `PROGRESS: {anchor_phase}` "
|
|
390
|
+
f"line ({anchor_ts}) — it must be loaded at {read_at}, before "
|
|
391
|
+
"that checkpoint (agents/SKILL.md 'Entry guard (BLOCKING)')."
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def validate_session_conformance(
|
|
396
|
+
*,
|
|
397
|
+
team_state: dict,
|
|
398
|
+
team_state_path: Path,
|
|
399
|
+
project_root: Path,
|
|
400
|
+
report_path: Path,
|
|
401
|
+
task_type: str,
|
|
402
|
+
claude_projects_dir: Path | None = None,
|
|
403
|
+
) -> SessionConformanceResult:
|
|
404
|
+
"""post-hoc conformance 검사 3종을 수행하고 실패 목록을 돌려준다.
|
|
405
|
+
|
|
406
|
+
`claude_projects_dir` 는 테스트/진단용 주입 시드 (기본: 실제 ~/.claude/projects).
|
|
407
|
+
검사 2(heartbeat)는 디스크 사이드카만 보므로 lead jsonl 미발견 시에도 수행된다.
|
|
408
|
+
"""
|
|
409
|
+
result = SessionConformanceResult()
|
|
410
|
+
_ensure_token_usage_importable()
|
|
411
|
+
try:
|
|
412
|
+
from okstra_token_usage.collect import run_artifact_suffix
|
|
413
|
+
except ImportError as exc: # pragma: no cover — 설치본은 항상 패키지를 동반
|
|
414
|
+
result.errors.append(f"okstra_token_usage import failed — {exc}")
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
run_dir = report_path.parent.parent # reports/ 의 부모 = run 디렉터리
|
|
418
|
+
_check_heartbeat_sidecars(run_dir, task_type, result.errors)
|
|
419
|
+
|
|
420
|
+
evidence, error = _collect_lead_evidence(
|
|
421
|
+
team_state, team_state_path, project_root, claude_projects_dir
|
|
422
|
+
)
|
|
423
|
+
if error:
|
|
424
|
+
result.errors.append(error)
|
|
425
|
+
return result
|
|
426
|
+
suffix = run_artifact_suffix(team_state_path)
|
|
427
|
+
_check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
|
|
428
|
+
if task_type == "implementation":
|
|
429
|
+
_check_implementation_sidecar_reads(evidence, result.errors)
|
|
430
|
+
return result
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { runPythonModule } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra migrate — move legacy .project-docs/okstra/ to .okstra/ (one-shot)
|
|
4
|
+
|
|
5
|
+
A thin shim over \`python3 -m okstra_ctl.migrate\`. Dry-run by default:
|
|
6
|
+
prints the migration plan as JSON and exits without touching anything.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
okstra migrate [--apply] [--cwd <dir>] [--quiet]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--apply Execute the migration (git mv + .gitignore + okstra-home
|
|
13
|
+
registry row updates). Without it, preview only.
|
|
14
|
+
--cwd Project root to migrate. Default: current directory.
|
|
15
|
+
--quiet Single-line JSON output.
|
|
16
|
+
|
|
17
|
+
Exits 1 when the migration is refused (.okstra/ already exists, or no
|
|
18
|
+
legacy .project-docs/okstra/ found).
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export async function run(args) {
|
|
22
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
23
|
+
process.stdout.write(USAGE);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const { code } = await runPythonModule({
|
|
27
|
+
module: "okstra_ctl.migrate",
|
|
28
|
+
args,
|
|
29
|
+
});
|
|
30
|
+
return code ?? 1;
|
|
31
|
+
}
|