okstra 0.158.1 → 0.160.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/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +56 -2
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""불변식 감사 — 에러 로그에 흔적을 안 남긴 채 잘못 끝난 런을 아티팩트로 잡는다.
|
|
2
|
+
|
|
3
|
+
리드의 자기 보고가 아니라 run-manifest / final-report 가 남긴 사실만 읽는다.
|
|
4
|
+
검증 라운드를 빼먹은 리드는 그 라운드를 돌렸는지에 대한 믿을 만한 증인이 아니다.
|
|
5
|
+
읽기 전용 — 어떤 타겟의 `.okstra/` 도 쓰거나 옮기거나 지우지 않는다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from okstra_ctl.error_zip import run_dirs
|
|
17
|
+
from okstra_ctl.final_report_paths import DATA_JSON_SUFFIX, final_report_data_path
|
|
18
|
+
from okstra_ctl.paths import okstra_home, resolve_under_root
|
|
19
|
+
from okstra_ctl.reconcile import NON_TERMINAL_RECENT_STATUSES
|
|
20
|
+
from okstra_ctl.task_target import project_rel
|
|
21
|
+
from okstra_ctl.workflow import PHASE_SEQUENCE
|
|
22
|
+
|
|
23
|
+
# 불변식 이름은 여기서만 정의한다. `INVARIANTS` 와 방출부가 같은 상수를 봐야,
|
|
24
|
+
# 이름을 하나 더할 때 한쪽만 고치고도 스위트가 green 인 상태가 생기지 않는다 —
|
|
25
|
+
# 그 틈이 벌어지면 소비자의 분류 라우팅이 한 종류를 조용히 흘린다.
|
|
26
|
+
INVARIANT_VERIFICATION_ROUNDS_RAN = "verification-rounds-ran"
|
|
27
|
+
INVARIANT_RUN_PRODUCED_ITS_REPORT = "run-produced-its-report"
|
|
28
|
+
INVARIANT_ROSTER_WAS_FULFILLED = "roster-was-fulfilled"
|
|
29
|
+
INVARIANT_APPROVAL_NOT_FORGOTTEN = "approval-not-forgotten"
|
|
30
|
+
|
|
31
|
+
INVARIANTS = (
|
|
32
|
+
INVARIANT_VERIFICATION_ROUNDS_RAN,
|
|
33
|
+
INVARIANT_RUN_PRODUCED_ITS_REPORT,
|
|
34
|
+
INVARIANT_ROSTER_WAS_FULFILLED,
|
|
35
|
+
INVARIANT_APPROVAL_NOT_FORGOTTEN,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# 게이트를 "통과"로 선언하는 값. 정본 enum 은
|
|
39
|
+
# `schemas/final-report-v2.0.schema.json` 의 PlanBodyVerification.gateResult
|
|
40
|
+
# (passed / passed-with-dissent / blocked-by-disagreement / aborted-non-result)
|
|
41
|
+
# 이고 `validators/validate-run.py` 도 같은 두 값을 통과로 취급한다. 실측 run-index
|
|
42
|
+
# 에서 통과 게이트 23건 중 19건이 passed-with-dissent 라 passed 만 보면 대부분을
|
|
43
|
+
# 놓친다.
|
|
44
|
+
_PASSING_GATES = frozenset({"passed", "passed-with-dissent"})
|
|
45
|
+
|
|
46
|
+
# 승인 대기가 이 일수를 넘으면 잊힌 것으로 본다. 사람이 일부러 기다리는 상태와
|
|
47
|
+
# 잊은 상태를 시각만으로 가를 수는 없어, 창을 넉넉히 잡는다.
|
|
48
|
+
APPROVAL_STALE_DAYS = 14
|
|
49
|
+
|
|
50
|
+
# 승인 게이트는 implementation 진입 직전 한 번만 의미를 갖는다 —
|
|
51
|
+
# `validators/validate-run.py` 가 `current_phase == "implementation"` 인 run 이
|
|
52
|
+
# 검증을 통과할 때만 이 플래그를 내린다. 이 phase 부터의 완료 기록이 하나라도
|
|
53
|
+
# 있으면 그 태스크는 게이트를 이미 지났고, 남은 플래그는 못 내린 잔재다.
|
|
54
|
+
_PHASES_AT_OR_PAST_APPROVAL_GATE = frozenset(
|
|
55
|
+
PHASE_SEQUENCE[PHASE_SEQUENCE.index("implementation"):])
|
|
56
|
+
|
|
57
|
+
_MANIFEST_GLOB = "run-manifest-*.json"
|
|
58
|
+
|
|
59
|
+
# 워커 결과는 마크다운이다. 같은 디렉터리의 `*.json` 은 결과가 아니라 에러
|
|
60
|
+
# 사이드카(`codex-worker-errors-<task-type>-001.json`)라, 그것을 결과로 세면
|
|
61
|
+
# 부르다 터진 워커가 정상 참여로 둔갑한다. 확장자를 허용목록으로 못박는다.
|
|
62
|
+
_WORKER_RESULT_GLOB = "*.md"
|
|
63
|
+
|
|
64
|
+
_SEQ_RESULT_RE = re.compile(r"-(\d{3,})\.md$")
|
|
65
|
+
|
|
66
|
+
# team-state 가 워커의 종결을 기록할 때 쓰는 값. 정본 enum 은
|
|
67
|
+
# `schemas/convergence-round-results-v1.0.schema.json` 의 workers[].status 이고,
|
|
68
|
+
# 실측 team-state 139건의 워커 항목도 이 네 값만 쓴다(completed 385 / not-run 46
|
|
69
|
+
# / error 14 / timeout 3). 허용목록으로 둬야 앞으로 생길 비종결 상태(`running`
|
|
70
|
+
# 류)가 결과 부재의 면죄부로 슬쩍 통과하지 않는다.
|
|
71
|
+
_RECORDED_WORKER_STATUSES = frozenset(
|
|
72
|
+
{"completed", "not-run", "error", "timeout"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_json(path: Path) -> dict:
|
|
76
|
+
try:
|
|
77
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, json.JSONDecodeError):
|
|
79
|
+
return {}
|
|
80
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _latest_manifest(run_dir: Path) -> tuple[dict, Path | None]:
|
|
84
|
+
"""정본 위치는 `<run_dir>/manifests/run-manifest-<task-type>-<NNN>.json`
|
|
85
|
+
(`paths.py` 의 `run_manifests / f"run-manifest{suffixes['manifests']}.json"`).
|
|
86
|
+
run_dir 바로 아래는 이식된 번들·비정형 트리용 폴백. 한 run_dir 은 한
|
|
87
|
+
task-type 이라 파일명 정렬이 곧 seq 정렬이다."""
|
|
88
|
+
files = (sorted((run_dir / "manifests").glob(_MANIFEST_GLOB))
|
|
89
|
+
or sorted(run_dir.glob(_MANIFEST_GLOB)))
|
|
90
|
+
if not files:
|
|
91
|
+
return {}, None
|
|
92
|
+
return _load_json(files[-1]), files[-1]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_report(run_dir: Path, project_root: str, expected: str) -> Path | None:
|
|
96
|
+
"""`expectedReportPath` 를 실제 파일로 푼다. 루트 밖 이탈이면 `None`.
|
|
97
|
+
|
|
98
|
+
이 값은 project_root 기준 상대경로다 — `render.py` 가
|
|
99
|
+
`FINAL_REPORT_RELATIVE_PATH` 에서 채우고 그 값은 `paths.py` 의
|
|
100
|
+
`_rel(project_root, final_report)` 이며, 다른 소비자도
|
|
101
|
+
`dispatch_state.resolve_required_path(project_root, ...)` 로 푼다.
|
|
102
|
+
|
|
103
|
+
글로벌 인덱스에서 온 신뢰 불가 값이라 `resolve_under_root` 로 루트 밖
|
|
104
|
+
이탈을 막는다. 이탈이면 `run_dir` 기준으로 폴백하지 않고 판정을 포기한다 —
|
|
105
|
+
`expected` 가 절대경로면 `run_dir / expected` 가 좌변을 통째로 버려
|
|
106
|
+
가드가 무효가 되고, 그렇게 얻은 루트 밖 경로와 그 파일 내용이 감사 결과에
|
|
107
|
+
실려 나간다. 루트 안이면서 그 자리에 없을 때만 `run_dir` 기준으로 한 번 더
|
|
108
|
+
본다(이식된 번들 방어). 둘 다 없으면 정본 기준인 루트 해석 결과를 돌려준다."""
|
|
109
|
+
from_root = resolve_under_root(project_root, expected)
|
|
110
|
+
if from_root is None:
|
|
111
|
+
return None
|
|
112
|
+
if from_root.is_file():
|
|
113
|
+
return from_root
|
|
114
|
+
from_run = run_dir / expected
|
|
115
|
+
return from_run if from_run.is_file() else from_root
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _report_data_path(report_path: Path) -> Path:
|
|
119
|
+
"""구조화 본문이 있는 파일. 정본 `expectedReportPath` 는 마크다운이고
|
|
120
|
+
본문은 `.data.json` 형제다. 이미 data.json 을 가리키면 그대로 쓴다 —
|
|
121
|
+
`final_report_data_path` 는 `.md` 가 아닌 이름에 `with_suffix` 를 걸어
|
|
122
|
+
`x.data.json` 을 `x.data.data.json` 으로 바꿔 놓는다."""
|
|
123
|
+
if report_path.name.endswith(DATA_JSON_SUFFIX):
|
|
124
|
+
return report_path
|
|
125
|
+
return final_report_data_path(report_path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_clock(manifest: dict, manifest_path: Path) -> dt.datetime | None:
|
|
129
|
+
"""이 run 이 스스로 적은 시각. 파일 mtime 이 아니라 manifest 의 `createdAt` 을
|
|
130
|
+
먼저 본다 — 번들을 복사하거나 체크아웃하면 mtime 은 현재로 리셋되어 오래된
|
|
131
|
+
관측이 전부 조용히 신선해진다. 실측 manifest 139건 전부 tz-aware ISO-8601
|
|
132
|
+
`createdAt` 을 갖고 있고 mtime 과의 차는 모두 하루 미만이다.
|
|
133
|
+
|
|
134
|
+
소비자가 둘이다 — 승인 경과일 계산과, 위반이 언제 관측됐는지(`observedAt`).
|
|
135
|
+
후자가 없으면 감사 후보는 시계가 없어 중복 판정에서 에러 로그 후보와 같은
|
|
136
|
+
규칙을 쓸 수 없다."""
|
|
137
|
+
try:
|
|
138
|
+
parsed = dt.datetime.fromisoformat(
|
|
139
|
+
str(manifest.get("createdAt", "")).replace("Z", "+00:00"))
|
|
140
|
+
except ValueError:
|
|
141
|
+
parsed = None
|
|
142
|
+
if parsed is not None:
|
|
143
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=dt.timezone.utc)
|
|
144
|
+
try:
|
|
145
|
+
return dt.datetime.fromtimestamp(
|
|
146
|
+
manifest_path.stat().st_mtime, dt.timezone.utc)
|
|
147
|
+
except OSError:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _violation(*, invariant, manifest, manifest_path, project_root, detail,
|
|
152
|
+
source) -> dict:
|
|
153
|
+
observed = _run_clock(manifest, manifest_path)
|
|
154
|
+
return {
|
|
155
|
+
"invariant": invariant,
|
|
156
|
+
"taskKey": str(manifest.get("taskKey", "")),
|
|
157
|
+
"taskType": str(manifest.get("taskType", "")),
|
|
158
|
+
"projectRoot": project_root,
|
|
159
|
+
"detail": detail,
|
|
160
|
+
"source": source,
|
|
161
|
+
# 위반이 관측된 시각. 감사 후보의 중복 판정이 이 값을 `lastSeen` 으로
|
|
162
|
+
# 쓴다 — 없으면 "이미 보고한 발생인가"를 물을 수 없어, 감사 경로만
|
|
163
|
+
# 자기만의 특수 분기를 갖게 된다.
|
|
164
|
+
"observedAt": observed.isoformat() if observed is not None else "",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _as_int(value: object) -> int | None:
|
|
169
|
+
"""감사는 스키마를 지키지 못한 런을 잡으려고 도는데, 그 런의 깨진 필드
|
|
170
|
+
하나에 int() 가 터지면 나머지 전부를 못 본다. 못 읽는 값은 건너뛴다."""
|
|
171
|
+
try:
|
|
172
|
+
return int(value or 0)
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_round_counts(node) -> list[tuple[int, object, str]]:
|
|
178
|
+
"""리포트 어디에 있든 roundCount/gateResult 쌍을 찾는다. 리포트 종류마다
|
|
179
|
+
블록 이름이 달라 경로를 하드코딩하지 않는다.
|
|
180
|
+
|
|
181
|
+
판정용 정규화 값과 **파일에 적힌 원값**을 함께 돌려준다 — 위반 본문은
|
|
182
|
+
원값을 인용해야 한다. `"roundCount": null` 인 파일을 두고 본문이
|
|
183
|
+
`roundCount=0` 이라고 쓰면 근거를 잘못 인용하는 것이다."""
|
|
184
|
+
out: list[tuple[int, object, str]] = []
|
|
185
|
+
if isinstance(node, dict):
|
|
186
|
+
if "roundCount" in node and "gateResult" in node:
|
|
187
|
+
raw = node.get("roundCount")
|
|
188
|
+
count = _as_int(raw)
|
|
189
|
+
if count is not None:
|
|
190
|
+
out.append((count, raw, str(node.get("gateResult", ""))))
|
|
191
|
+
for value in node.values():
|
|
192
|
+
out.extend(_find_round_counts(value))
|
|
193
|
+
elif isinstance(node, list):
|
|
194
|
+
for value in node:
|
|
195
|
+
out.extend(_find_round_counts(value))
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _never_ran(row: dict) -> bool:
|
|
200
|
+
"""아직 돌지 않은(또는 도는 중인) run 은 리포트를 내놓을 차례가 아니다.
|
|
201
|
+
상태 목록은 `reconcile.NON_TERMINAL_RECENT_STATUSES` 가 정본 — 여기서 다시
|
|
202
|
+
적으면 종료 상태의 정의가 두 벌이 된다."""
|
|
203
|
+
return str(row.get("status", "")) in NON_TERMINAL_RECENT_STATUSES
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_report(run_dir: Path, manifest: dict, manifest_path: Path,
|
|
207
|
+
project_root: str, row: dict) -> list[dict]:
|
|
208
|
+
expected = str(manifest.get("expectedReportPath", ""))
|
|
209
|
+
if not expected:
|
|
210
|
+
return []
|
|
211
|
+
report_path = _resolve_report(run_dir, project_root, expected)
|
|
212
|
+
if report_path is None:
|
|
213
|
+
return []
|
|
214
|
+
if not report_path.is_file():
|
|
215
|
+
if _never_ran(row):
|
|
216
|
+
return []
|
|
217
|
+
# `source` 는 없는 파일이 아니라 그 주장을 만든 manifest 를 가리킨다 —
|
|
218
|
+
# 위반 본문을 읽는 사람이 열어볼 수 있는 파일이어야 한다.
|
|
219
|
+
return [_violation(
|
|
220
|
+
invariant=INVARIANT_RUN_PRODUCED_ITS_REPORT, manifest=manifest, manifest_path=manifest_path,
|
|
221
|
+
project_root=project_root,
|
|
222
|
+
detail=(f"expected report "
|
|
223
|
+
f"{project_rel(report_path, Path(project_root))} is absent"),
|
|
224
|
+
source=str(manifest_path),
|
|
225
|
+
)]
|
|
226
|
+
data_path = _report_data_path(report_path)
|
|
227
|
+
if not data_path.is_file():
|
|
228
|
+
return []
|
|
229
|
+
report = _load_json(data_path)
|
|
230
|
+
out = []
|
|
231
|
+
for round_count, raw_count, gate_result in _find_round_counts(report):
|
|
232
|
+
if round_count == 0 and gate_result.lower() in _PASSING_GATES:
|
|
233
|
+
out.append(_violation(
|
|
234
|
+
invariant=INVARIANT_VERIFICATION_ROUNDS_RAN, manifest=manifest, manifest_path=manifest_path,
|
|
235
|
+
project_root=project_root,
|
|
236
|
+
detail=f"roundCount={raw_count!r} with gateResult={gate_result!r}",
|
|
237
|
+
source=str(data_path),
|
|
238
|
+
))
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _names_a_result_for(worker: str, names) -> bool:
|
|
243
|
+
"""결과 파일명 하나가 이 워커의 것인가. `recommendedWorkers` 는 맨
|
|
244
|
+
이름(`claude`, `report-writer`)이고 파일은 `claude-worker-<task-type>-001.md`
|
|
245
|
+
라 둘은 같은 값이 아니라 접두 관계다. 구분자 `-` 까지 붙여 비교해야 이름
|
|
246
|
+
하나가 다른 이름의 접두일 때 서로를 삼키지 않는다."""
|
|
247
|
+
return any(str(name).startswith(f"{worker}-") for name in names)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _current_seq_results(results_dir: Path) -> list[str]:
|
|
251
|
+
"""가장 최신 seq 의 결과 파일명만 남긴다.
|
|
252
|
+
|
|
253
|
+
한 worker-results 디렉터리에 여러 seq 가 공존한다(`paths.py` 가 결과
|
|
254
|
+
디렉터리에 manifest 와 **별도의** seq 를 매긴다 — 실측 139건 중 39건이 2개
|
|
255
|
+
이상). 전 seq 를 세면 seq 001 에 참여하고 002 에서 사라진 워커가 충족으로
|
|
256
|
+
둔갑한다. 같은 함정의 선례가 `context_cost._current_seq_worker_results` 다.
|
|
257
|
+
|
|
258
|
+
기준은 manifest 의 seq 가 아니라 **결과 파일에 실제로 찍힌 최대 seq** 다.
|
|
259
|
+
실측 129건 중 25건에서 manifest seq 가 결과 최대 seq 보다 앞서 있어(예:
|
|
260
|
+
manifest 003 / 결과 002) manifest 기준으로 자르면 그 25건은 결과 집합이
|
|
261
|
+
통째로 비어 로스터 전원이 미보고로 뒤집힌다."""
|
|
262
|
+
names = [p.name for p in results_dir.glob(_WORKER_RESULT_GLOB)]
|
|
263
|
+
by_seq: dict[str, list[str]] = {}
|
|
264
|
+
for name in names:
|
|
265
|
+
match = _SEQ_RESULT_RE.search(name)
|
|
266
|
+
if match:
|
|
267
|
+
by_seq.setdefault(match.group(1), []).append(name)
|
|
268
|
+
# seq 를 못 읽는 배치는 전부 살린다. 여기서 빈 목록을 돌려주면 읽지 못한
|
|
269
|
+
# 이름 하나 때문에 없는 위반을 만들어 낸다.
|
|
270
|
+
return by_seq[max(by_seq)] if by_seq else names
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _explained_workers(manifest: dict, project_root: str) -> list[str] | None:
|
|
274
|
+
"""team-state 가 종결 상태를 기록해 둔 워커의 결과 파일명.
|
|
275
|
+
|
|
276
|
+
team-state 에 **닿지 못하면** 빈 목록이 아니라 `None` 을 돌려준다. 둘은 다른
|
|
277
|
+
사실이다 — 빈 목록은 "확인했고 아무 기록도 없었다"이고 `None` 은 "확인조차
|
|
278
|
+
못 했다"이다. 위반 본문이 그 둘을 뭉개면 감사가 하지 않은 확인을 했다고
|
|
279
|
+
주장하게 된다.
|
|
280
|
+
|
|
281
|
+
로스터는 디스패치 대상의 상위집합이라(`dispatch_core._select_workers` 가
|
|
282
|
+
`recommended` 를 dispatcher 지원 목록으로 걸러 낸다) 부르지 않은 워커가
|
|
283
|
+
정상적으로 생기고, 부르지 않았거나 실패한 워커는 사유와 함께 team-state 에
|
|
284
|
+
남는다. 실측 로스터 미보고 9건 전부가 `not-run`(render-only 모드·사용자가
|
|
285
|
+
기다리지 말라고 지시) / `error`(Gemini 쿼터 소진, Codex 빈 stdout) /
|
|
286
|
+
`timeout` 으로 기록돼 있었다. 이 모듈이 겨냥한 것은 "흔적을 안 남긴 채 잘못
|
|
287
|
+
끝난" 런이라, 사유가 적힌 부재는 정확히 그 반대다.
|
|
288
|
+
|
|
289
|
+
워커 식별은 항목의 `agent` 가 아니라 `resultPath` 파일명으로 한다 —
|
|
290
|
+
report-writer 항목의 `agent` 는 실행 제공자인 `claude` 라 로스터 이름과
|
|
291
|
+
다르다."""
|
|
292
|
+
state_rel = str(manifest.get("teamStatePath", "") or "")
|
|
293
|
+
if not state_rel:
|
|
294
|
+
return None
|
|
295
|
+
state_path = resolve_under_root(project_root, state_rel)
|
|
296
|
+
if state_path is None or not state_path.is_file():
|
|
297
|
+
return None
|
|
298
|
+
entries = _load_json(state_path).get("workers")
|
|
299
|
+
if not isinstance(entries, list):
|
|
300
|
+
return None
|
|
301
|
+
return [
|
|
302
|
+
Path(str(entry.get("resultPath", ""))).name
|
|
303
|
+
for entry in entries
|
|
304
|
+
if isinstance(entry, dict)
|
|
305
|
+
and str(entry.get("status", "")) in _RECORDED_WORKER_STATUSES
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _check_roster(manifest: dict, manifest_path: Path, project_root: str,
|
|
310
|
+
row: dict) -> list[dict]:
|
|
311
|
+
"""로스터에 오른 워커가 결과도 안 남기고 사유도 안 남긴 run 을 잡는다.
|
|
312
|
+
|
|
313
|
+
`workerResultsDirectoryPath` 는 run_dir 이 아니라 **project_root** 기준
|
|
314
|
+
상대경로다(`render.py` 가 `WORKER_RESULTS_RELATIVE_PATH` 에서 채운다). 실측
|
|
315
|
+
manifest 139건 전부 그랬고, run_dir 기준으로 풀면 한 건도 디렉터리에 닿지
|
|
316
|
+
않아 감사가 통째로 빈손이 된다. `expectedReportPath` 와 같은 이유로
|
|
317
|
+
`resolve_under_root` 를 거쳐 루트 밖 이탈도 함께 막는다."""
|
|
318
|
+
raw_workers = manifest.get("recommendedWorkers")
|
|
319
|
+
# 문자열이 들어오면 문자 단위로 순회해 `no result from c, l, a, u, d, e` 가
|
|
320
|
+
# 나온다. 같은 방어의 선례는 `backfill.py` 의 `raw_workers` 처리.
|
|
321
|
+
if not isinstance(raw_workers, list):
|
|
322
|
+
return []
|
|
323
|
+
recommended = [str(w) for w in raw_workers]
|
|
324
|
+
results_rel = str(manifest.get("workerResultsDirectoryPath", ""))
|
|
325
|
+
if not recommended or not results_rel or _never_ran(row):
|
|
326
|
+
return []
|
|
327
|
+
results_dir = resolve_under_root(project_root, results_rel)
|
|
328
|
+
if results_dir is None or not results_dir.is_dir():
|
|
329
|
+
return []
|
|
330
|
+
produced = _current_seq_results(results_dir)
|
|
331
|
+
explained = _explained_workers(manifest, project_root)
|
|
332
|
+
missing = [w for w in recommended
|
|
333
|
+
if not _names_a_result_for(w, produced)
|
|
334
|
+
and not _names_a_result_for(w, explained or [])]
|
|
335
|
+
if not missing:
|
|
336
|
+
return []
|
|
337
|
+
# 확인하지 않은 사실을 위반 본문이 주장하면 안 된다. team-state 에 닿지
|
|
338
|
+
# 못한 run 에까지 "team-state 가 이들의 결과를 기록하지 않았다"고 쓰면,
|
|
339
|
+
# 그 문장을 읽는 사람은 감사가 하지 않은 대조를 했다고 믿는다.
|
|
340
|
+
checked = (", and team-state records no outcome for them"
|
|
341
|
+
if explained is not None
|
|
342
|
+
else " (no readable team-state to check for a recorded outcome)")
|
|
343
|
+
return [_violation(
|
|
344
|
+
invariant=INVARIANT_ROSTER_WAS_FULFILLED, manifest=manifest, manifest_path=manifest_path,
|
|
345
|
+
project_root=project_root,
|
|
346
|
+
detail=(f"no result from {', '.join(missing)} in "
|
|
347
|
+
f"{project_rel(results_dir, Path(project_root))}{checked}"),
|
|
348
|
+
source=str(manifest_path),
|
|
349
|
+
)]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _approval_clock(manifest: dict, manifest_path: Path,
|
|
353
|
+
now: dt.datetime) -> dt.datetime:
|
|
354
|
+
"""승인 대기가 시작된 시각. 시계를 못 읽으면 `now` 로 떨어져 경과일이 0 이
|
|
355
|
+
되고, 읽지 못한 것이 잊힌 승인으로 둔갑하지 않는다."""
|
|
356
|
+
return _run_clock(manifest, manifest_path) or now
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _approval_is_still_open(manifest: dict, project_root: str) -> bool:
|
|
360
|
+
"""승인 게이트가 지금도 열려 있는가.
|
|
361
|
+
|
|
362
|
+
run-manifest 의 `workflowSnapshot.awaitingApproval` 은 렌더 시점에 얼어붙은
|
|
363
|
+
값이다. 그 플래그를 내리는 것은 검증을 통과한 implementation run 이 자기
|
|
364
|
+
manifest 에 쓸 때 한 번뿐이라(`validators/validate-run.py` 의
|
|
365
|
+
`validation_status == "passed" and current_phase == "implementation"`),
|
|
366
|
+
게이트를 세운 implementation-planning manifest 는 승인이 떨어지고 릴리스까지
|
|
367
|
+
끝난 뒤에도 영원히 `true` 로 남는다.
|
|
368
|
+
|
|
369
|
+
현재 값은 task-manifest 의 `workflow.awaitingApproval` 에 있다(같은 파일이
|
|
370
|
+
거기에 쓴다). 실측 스냅샷 30건 중 13건이 이미 내려간 게이트였고, 그중에는
|
|
371
|
+
implementation → final-verification 까지 지나간 태스크도 있다 — 그대로 두면
|
|
372
|
+
이미 배포된 일감에 "N일째 승인 대기" 이슈를 연다.
|
|
373
|
+
|
|
374
|
+
대조할 현재값에 닿지 못하면 스냅샷을 그대로 믿는다. 없는 근거로 위반을
|
|
375
|
+
지우면 이 불변식이 조용히 꺼진다."""
|
|
376
|
+
task_rel = str(manifest.get("taskManifestPath", "") or "")
|
|
377
|
+
if not task_rel:
|
|
378
|
+
return True
|
|
379
|
+
task_path = resolve_under_root(project_root, task_rel)
|
|
380
|
+
if task_path is None or not task_path.is_file():
|
|
381
|
+
return True
|
|
382
|
+
workflow = _load_json(task_path).get("workflow")
|
|
383
|
+
if not isinstance(workflow, dict):
|
|
384
|
+
return True
|
|
385
|
+
if _passed_the_approval_gate(workflow):
|
|
386
|
+
return False
|
|
387
|
+
if "awaitingApproval" not in workflow:
|
|
388
|
+
return True
|
|
389
|
+
return bool(workflow.get("awaitingApproval"))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _passed_the_approval_gate(workflow: dict) -> bool:
|
|
393
|
+
"""이 태스크가 승인 게이트를 이미 지났는가.
|
|
394
|
+
|
|
395
|
+
플래그가 열려 있다는 사실만으로는 잊힌 승인이라고 말할 수 없다. 게이트는
|
|
396
|
+
implementation 진입 직전에만 의미를 갖는데, 그 뒤로 나아간 태스크에서도
|
|
397
|
+
플래그가 열린 채 남는다 — 실측 15건 중 6건이 implementation 을, 3건이
|
|
398
|
+
final-verification 까지 끝낸 태스크였다. 그런 태스크에 "N일째 승인 대기"를
|
|
399
|
+
붙이면 이미 배포된 일감을 할 일 목록에 올리는 것이고, 이 불변식이 사람에게
|
|
400
|
+
보이는 감사 리포트 17행 중 15행을 그 잡음으로 채운다.
|
|
401
|
+
|
|
402
|
+
완료 기록을 보는 것이지 `currentPhase` 를 보는 것이 아니다 —
|
|
403
|
+
`currentPhase` 는 다음에 할 일을 가리키므로 게이트 앞에서 멈춘 태스크와
|
|
404
|
+
게이트를 지나 되돌아온 태스크를 구분하지 못한다.
|
|
405
|
+
|
|
406
|
+
`phaseStates` 는 phase 이름 → 상태 **문자열** 이다(`render._derive_phase_states`
|
|
407
|
+
와 `implementation_outcome._promote_workflow` 가 둘 다 문자열을 넣는다).
|
|
408
|
+
`completed-awaiting-approval` 은 게이트 그 자체이므로 통과로 세지 않는다."""
|
|
409
|
+
states = workflow.get("phaseStates")
|
|
410
|
+
if not isinstance(states, dict):
|
|
411
|
+
return False
|
|
412
|
+
return any(
|
|
413
|
+
str(states.get(phase, "")) == "completed"
|
|
414
|
+
for phase in _PHASES_AT_OR_PAST_APPROVAL_GATE
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _check_approval(manifest: dict, manifest_path: Path, project_root: str,
|
|
419
|
+
now: dt.datetime) -> list[dict]:
|
|
420
|
+
"""`_never_ran` 을 걸지 않는다 — 실측 승인 대기 30건 중 22건이 `prepared` 다.
|
|
421
|
+
승인 대기는 본래 멈춰 선 상태라, 미실행 필터를 얹으면 이 불변식은 잡으려던
|
|
422
|
+
것의 대부분을 못 본다."""
|
|
423
|
+
snapshot = manifest.get("workflowSnapshot")
|
|
424
|
+
if not isinstance(snapshot, dict) or not snapshot.get("awaitingApproval"):
|
|
425
|
+
return []
|
|
426
|
+
if not _approval_is_still_open(manifest, project_root):
|
|
427
|
+
return []
|
|
428
|
+
since = _approval_clock(manifest, manifest_path, now)
|
|
429
|
+
waited = (now - since).days
|
|
430
|
+
if waited < APPROVAL_STALE_DAYS:
|
|
431
|
+
return []
|
|
432
|
+
return [_violation(
|
|
433
|
+
invariant=INVARIANT_APPROVAL_NOT_FORGOTTEN, manifest=manifest, manifest_path=manifest_path,
|
|
434
|
+
project_root=project_root,
|
|
435
|
+
detail=(f"awaiting approval for {waited} days "
|
|
436
|
+
f"since {since.date().isoformat()}"),
|
|
437
|
+
source=str(manifest_path),
|
|
438
|
+
)]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def audit_runs(home: Path, *, now: dt.datetime) -> list[dict]:
|
|
442
|
+
violations: list[dict] = []
|
|
443
|
+
for project_root, run_dir, row in run_dirs(home):
|
|
444
|
+
if not run_dir.exists():
|
|
445
|
+
continue
|
|
446
|
+
manifest, manifest_path = _latest_manifest(run_dir)
|
|
447
|
+
if manifest_path is None:
|
|
448
|
+
continue
|
|
449
|
+
violations.extend(
|
|
450
|
+
_check_report(run_dir, manifest, manifest_path, project_root, row))
|
|
451
|
+
violations.extend(
|
|
452
|
+
_check_roster(manifest, manifest_path, project_root, row))
|
|
453
|
+
violations.extend(
|
|
454
|
+
_check_approval(manifest, manifest_path, project_root, now))
|
|
455
|
+
return violations
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def main(argv: list[str] | None = None) -> int:
|
|
459
|
+
parser = argparse.ArgumentParser(
|
|
460
|
+
prog="okstra run-audit",
|
|
461
|
+
description="런 아티팩트에서 불변식 위반을 찾는다 (읽기 전용).",
|
|
462
|
+
)
|
|
463
|
+
parser.parse_args(argv)
|
|
464
|
+
violations = audit_runs(okstra_home(), now=dt.datetime.now(dt.timezone.utc))
|
|
465
|
+
by_invariant: dict[str, int] = {}
|
|
466
|
+
for v in violations:
|
|
467
|
+
by_invariant[v["invariant"]] = by_invariant.get(v["invariant"], 0) + 1
|
|
468
|
+
print(json.dumps({
|
|
469
|
+
"violationCount": len(violations),
|
|
470
|
+
"byInvariant": by_invariant,
|
|
471
|
+
"violations": violations,
|
|
472
|
+
}, ensure_ascii=False, indent=2))
|
|
473
|
+
return 0
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""How a token, cost, or duration figure reads in a report cell.
|
|
2
|
+
|
|
3
|
+
Phase 7 fills the same numbers into two views — the AI-handoff Markdown and
|
|
4
|
+
the reader's HTML — so they format here once. A cell that is still null prints
|
|
5
|
+
``--`` rather than a zero: ``0`` tokens and ``$0.00`` are what a run that spent
|
|
6
|
+
nothing would look like, which is a different claim from "not measured".
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_int(value: Any) -> str:
|
|
14
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
15
|
+
return "--"
|
|
16
|
+
try:
|
|
17
|
+
return f"{int(value):,}"
|
|
18
|
+
except (TypeError, ValueError):
|
|
19
|
+
return "--"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_usd(value: Any) -> str:
|
|
23
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
24
|
+
return "--"
|
|
25
|
+
try:
|
|
26
|
+
return f"${float(value):.2f}"
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return "--"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_duration_ms(value: Any) -> str:
|
|
32
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
33
|
+
return "--"
|
|
34
|
+
try:
|
|
35
|
+
ms = int(value)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return "--"
|
|
38
|
+
# A negative elapsed time is nonsensical (clock skew between start/end
|
|
39
|
+
# timestamps); divmod would otherwise produce a malformed "-1m 59s".
|
|
40
|
+
if ms < 0:
|
|
41
|
+
return "--"
|
|
42
|
+
total_seconds = ms // 1000
|
|
43
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
44
|
+
minutes, seconds = divmod(remainder, 60)
|
|
45
|
+
if hours:
|
|
46
|
+
return f"{hours}h {minutes:02d}m {seconds:02d}s"
|
|
47
|
+
return f"{minutes}m {seconds:02d}s"
|
|
@@ -20,6 +20,7 @@ from typing import Optional
|
|
|
20
20
|
|
|
21
21
|
from okstra_ctl.report_views import (
|
|
22
22
|
serialize_user_response, UserResponseEntry, UserResponseApproval, infer_run_meta,
|
|
23
|
+
parse_expected_form_options,
|
|
23
24
|
)
|
|
24
25
|
from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
|
|
25
26
|
from okstra_ctl.listing import list_runs, absolute_final_report_path
|
|
@@ -473,18 +474,31 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
|
|
|
473
474
|
# match. `§x.y` and `path.ext:line` are the other two ref shapes.
|
|
474
475
|
_SECTION_REF_RE = re.compile(r"§[\d.]+|[A-Z]{1,4}-\d+|[\w./-]+\.\w+:\d+")
|
|
475
476
|
_ID_TOKEN_RE = re.compile(r"^[A-Z]{1,4}-\d+$")
|
|
476
|
-
_ALTERNATIVES_CUE = "Alternatives:"
|
|
477
477
|
_DEFINITION_SNIPPET_CAP = 200
|
|
478
478
|
_SNIPPET_NOISE_RE = re.compile(r'<a id="[^"]*"></a>|`|\*\*')
|
|
479
|
+
_OPTION_LETTER_LABEL_RE = re.compile(r"^\([a-z]\)\s*")
|
|
479
480
|
|
|
480
481
|
|
|
481
|
-
def
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
482
|
+
def _options_from_expected_form(expected_form: str) -> list[dict]:
|
|
483
|
+
"""Rebuild a schema-v1 row's options from its ``Expected form`` cell.
|
|
484
|
+
|
|
485
|
+
v1 keeps the choices as one string and has nowhere to record their impact,
|
|
486
|
+
so those fields come back empty and the picker reports them as unstated
|
|
487
|
+
rather than inventing them. Splitting goes through the canonical
|
|
488
|
+
``parse_expected_form_options`` — the parser the HTML view already uses and
|
|
489
|
+
the only one under test.
|
|
490
|
+
"""
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
|
+
"role": "recommended" if value == "recommended" else "alternative",
|
|
494
|
+
"answer": _OPTION_LETTER_LABEL_RE.sub("", label).strip(),
|
|
495
|
+
"rationale": "",
|
|
496
|
+
"scopeImpact": [],
|
|
497
|
+
"addedWork": "",
|
|
498
|
+
"directionChange": "",
|
|
499
|
+
}
|
|
500
|
+
for value, label in parse_expected_form_options(expected_form)
|
|
501
|
+
]
|
|
488
502
|
|
|
489
503
|
|
|
490
504
|
def _clean_snippet(line: str) -> str:
|
|
@@ -558,8 +572,9 @@ def show_open_rows(report_path: Path) -> dict:
|
|
|
558
572
|
refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
|
|
559
573
|
rows.append({"id": it.row_id, "kind": it.kind, "blocks": it.blocks,
|
|
560
574
|
"status": it.status, "statement": statement,
|
|
561
|
-
"
|
|
562
|
-
|
|
575
|
+
"expectedForm": expected,
|
|
576
|
+
# v2 authors the choices; v1 only ever had the string.
|
|
577
|
+
"options": r["options"] or _options_from_expected_form(expected),
|
|
563
578
|
"contextRefs": refs,
|
|
564
579
|
"resolvedRefs": resolve_refs(text, refs)})
|
|
565
580
|
return {"reportPath": str(report_path), "rows": rows}
|