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.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +1 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/report-writer-worker.md +1 -1
  14. package/runtime/bin/okstra-claude-exec.sh +4 -1
  15. package/runtime/prompts/host-orchestration/README.md +18 -0
  16. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  17. package/runtime/prompts/launch.template.md +10 -1
  18. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/report-writer.md +1 -1
  23. package/runtime/prompts/lead/team-contract.md +2 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  25. package/runtime/prompts/profiles/_common-contract.md +3 -1
  26. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  27. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  28. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  29. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
  33. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  34. package/runtime/python/okstra_ctl/error_report.py +56 -0
  35. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  36. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  37. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  38. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  39. package/runtime/python/okstra_ctl/paths.py +38 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  41. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  42. package/runtime/python/okstra_ctl/recap.py +63 -0
  43. package/runtime/python/okstra_ctl/render_final_report.py +11 -62
  44. package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
  45. package/runtime/python/okstra_ctl/report_html/render.py +9 -8
  46. package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
  47. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
  48. package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
  49. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  50. package/runtime/python/okstra_ctl/report_views.py +7 -3
  51. package/runtime/python/okstra_ctl/run.py +41 -2
  52. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  53. package/runtime/python/okstra_ctl/usage_cells.py +47 -0
  54. package/runtime/python/okstra_ctl/user_response.py +25 -10
  55. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  56. package/runtime/python/okstra_ctl/wizard.py +64 -10
  57. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  58. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  59. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  60. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  61. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +56 -2
  63. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  64. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  66. package/runtime/skills/okstra-run/SKILL.md +28 -10
  67. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  68. package/runtime/templates/reports/final-report.template.md +4 -0
  69. package/runtime/templates/reports/html/assets/base.css +14 -1
  70. package/runtime/templates/reports/html/base.template.html +42 -0
  71. package/runtime/templates/reports/html/i18n/en.json +30 -1
  72. package/runtime/templates/reports/html/i18n/ko.json +30 -1
  73. package/runtime/templates/reports/html/macros/forms.html +15 -0
  74. package/runtime/templates/reports/html/macros/visualizations.html +3 -2
  75. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  76. package/runtime/templates/reports/i18n/en.json +2 -0
  77. package/runtime/validators/validate-run.py +331 -208
  78. package/runtime/validators/validate_session_conformance.py +102 -32
  79. package/src/cli-registry.mjs +34 -0
  80. package/src/commands/execute/incremental-scope.mjs +10 -0
  81. package/src/commands/execute/worker-audit-check.mjs +35 -0
  82. package/src/commands/inspect/error-issue.mjs +27 -0
  83. package/src/commands/inspect/profile-show.mjs +29 -0
  84. 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 _alternatives_from_expected(expected_form: str) -> list[str]:
482
- idx = expected_form.find(_ALTERNATIVES_CUE)
483
- if idx < 0:
484
- return []
485
- tail = expected_form[idx + len(_ALTERNATIVES_CUE):]
486
- parts = re.split(r"[/;]", tail)
487
- return [p.strip(" .,;—-") for p in parts if p.strip(" .,;—-")]
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
- "recommended": expected, # raw cell keeps answer + rationale
562
- "alternatives": _alternatives_from_expected(expected),
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}