okstra 0.170.3 → 0.172.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.md +13 -0
- package/docs/cli.md +4 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +3 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +36 -12
- package/runtime/prompts/lead/plan-body-verification.md +22 -11
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +2 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +306 -0
- package/runtime/python/okstra_ctl/clarification_items.py +37 -20
- package/runtime/python/okstra_ctl/cmux.py +144 -59
- package/runtime/python/okstra_ctl/lead_events.py +47 -4
- package/runtime/python/okstra_ctl/render.py +11 -3
- package/runtime/python/okstra_ctl/report_finalize.py +51 -14
- package/runtime/python/okstra_ctl/report_html/common.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +17 -1
- package/runtime/python/okstra_ctl/report_translation.py +14 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
- package/runtime/schemas/final-report-v2.0.schema.json +189 -0
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report-v2.template.md +8 -0
- package/runtime/templates/reports/html/assets/base.css +7 -0
- package/runtime/templates/reports/html/i18n/en.json +6 -1
- package/runtime/templates/reports/html/i18n/ko.json +6 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +25 -0
- package/runtime/templates/reports/i18n/en.json +4 -0
- package/runtime/templates/reports/report.js +26 -17
- package/runtime/templates/reports/user-response.template.md +3 -1
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/validate-run.py +989 -29
- package/runtime/validators/validate_session_conformance.py +523 -35
- package/src/cli-registry.mjs +7 -0
- package/src/commands/report/agent-activity.mjs +21 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Post-hoc checks for
|
|
1
|
+
"""Post-hoc checks for neutral lead lifecycle and live-activity contracts.
|
|
2
2
|
|
|
3
3
|
Design: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
|
|
4
4
|
|
|
@@ -39,6 +39,12 @@ from okstra_ctl.worker_heartbeat import ( # noqa: E402
|
|
|
39
39
|
IN_STAGE_PREFIX,
|
|
40
40
|
max_gap_seconds_after,
|
|
41
41
|
)
|
|
42
|
+
from okstra_ctl.lead_events import ( # noqa: E402
|
|
43
|
+
LeadEvent,
|
|
44
|
+
LeadEventParseError,
|
|
45
|
+
read_lead_events,
|
|
46
|
+
)
|
|
47
|
+
from okstra_ctl.agent_activity import ACTIVITY_FIELDS # noqa: E402
|
|
42
48
|
from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
|
|
43
49
|
from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
|
|
44
50
|
from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
|
|
@@ -65,6 +71,15 @@ _PROGRESS_LINE_RE = re.compile(
|
|
|
65
71
|
r"^[ \t]*`*[ \t]*PROGRESS:[ \t]+(?P<phase>[^\s`]+)(?P<rest>[^`\n]*)`*[ \t]*$",
|
|
66
72
|
re.MULTILINE,
|
|
67
73
|
)
|
|
74
|
+
_ACTIVITY_ID_RE = re.compile(r"^A-(\d{3,})$")
|
|
75
|
+
_TARGETED_REVERIFICATION_REF_RE = re.compile(
|
|
76
|
+
r"^plan-body-verification:round-(\d+)$"
|
|
77
|
+
)
|
|
78
|
+
_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
|
|
79
|
+
_WORKER_FIELD_RE = re.compile(
|
|
80
|
+
r"(?:^|\s)worker=(?P<worker>.+?)(?=\s+[a-zA-Z][a-zA-Z0-9_.-]*=|$)"
|
|
81
|
+
)
|
|
82
|
+
_COMPLETED_STATUS_RE = re.compile(r"(?:^|\s)status=completed(?:\s|$)")
|
|
68
83
|
|
|
69
84
|
# heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
|
|
70
85
|
# `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
|
|
@@ -141,6 +156,7 @@ class SessionConformanceResult:
|
|
|
141
156
|
class _LeadEvidence:
|
|
142
157
|
progress: list[tuple[str, str, str]] = field(default_factory=list) # (ts, phase-id, line)
|
|
143
158
|
sidecar_reads: dict[str, list[str]] = field(default_factory=dict) # basename -> [ts]
|
|
159
|
+
activities: list[LeadEvent] = field(default_factory=list)
|
|
144
160
|
scanned_files: list[Path] = field(default_factory=list)
|
|
145
161
|
window: tuple[str | None, str | None] = (None, None)
|
|
146
162
|
|
|
@@ -170,17 +186,23 @@ def _is_report_writer(worker: dict) -> bool:
|
|
|
170
186
|
)
|
|
171
187
|
|
|
172
188
|
|
|
173
|
-
def
|
|
174
|
-
"""
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
def _worker_role(value: str) -> str | None:
|
|
190
|
+
"""역할 필드 전체에서 Unicode 대소문자 차이만 제거한다."""
|
|
191
|
+
return value.casefold() if value else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _worker_roles(worker: dict) -> set[str]:
|
|
195
|
+
role = _worker_role(str(worker.get("role") or ""))
|
|
196
|
+
return {role, role.replace(" ", "-")} if role else set()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
|
|
200
|
+
workers = [
|
|
201
|
+
worker
|
|
202
|
+
for worker in (team_state.get("workers") or [])
|
|
203
|
+
if isinstance(worker, dict)
|
|
204
|
+
]
|
|
205
|
+
return [worker for worker in workers if not _is_report_writer(worker)]
|
|
184
206
|
|
|
185
207
|
|
|
186
208
|
def _scan_one_jsonl(
|
|
@@ -227,7 +249,10 @@ def _scan_one_jsonl(
|
|
|
227
249
|
def _collect_lead_evidence(
|
|
228
250
|
team_state: dict,
|
|
229
251
|
team_state_path: Path,
|
|
252
|
+
run_manifest: Mapping[str, Any],
|
|
230
253
|
project_root: Path,
|
|
254
|
+
task_type: str,
|
|
255
|
+
suffix: str | None,
|
|
231
256
|
projects_root: Path | None,
|
|
232
257
|
) -> tuple[_LeadEvidence | None, str | None]:
|
|
233
258
|
"""lead 후보 jsonl 을 스캔해 증거를 모은다.
|
|
@@ -280,13 +305,24 @@ def _collect_lead_evidence(
|
|
|
280
305
|
evidence.progress.sort()
|
|
281
306
|
for ts_list in evidence.sidecar_reads.values():
|
|
282
307
|
ts_list.sort()
|
|
308
|
+
if _is_activity_contract_v1_planning(run_manifest):
|
|
309
|
+
activities, events_path, error = _read_scoped_lead_activities(
|
|
310
|
+
team_state, run_manifest, project_root, task_type, suffix
|
|
311
|
+
)
|
|
312
|
+
if error:
|
|
313
|
+
return None, error
|
|
314
|
+
evidence.activities.extend(activities)
|
|
315
|
+
if events_path is not None:
|
|
316
|
+
evidence.scanned_files.append(events_path)
|
|
283
317
|
return evidence, None
|
|
284
318
|
|
|
285
319
|
|
|
286
320
|
def _resolve_lead_events_path(
|
|
287
|
-
team_state:
|
|
321
|
+
team_state: Mapping[str, Any],
|
|
322
|
+
run_manifest: Mapping[str, Any],
|
|
323
|
+
project_root: Path,
|
|
288
324
|
) -> tuple[Path | None, str | None]:
|
|
289
|
-
raw = team_state.get("leadEventsPath") or (
|
|
325
|
+
raw = run_manifest.get("leadEventsPath") or team_state.get("leadEventsPath") or (
|
|
290
326
|
(team_state.get("artifacts") or {}).get("leadEventsPath")
|
|
291
327
|
if isinstance(team_state.get("artifacts"), dict)
|
|
292
328
|
else ""
|
|
@@ -308,9 +344,19 @@ def _resolve_lead_events_path(
|
|
|
308
344
|
return path, None
|
|
309
345
|
|
|
310
346
|
|
|
311
|
-
def _event_matches_run(
|
|
312
|
-
|
|
313
|
-
|
|
347
|
+
def _event_matches_run(
|
|
348
|
+
event: LeadEvent,
|
|
349
|
+
team_state: Mapping[str, Any],
|
|
350
|
+
run_manifest: Mapping[str, Any],
|
|
351
|
+
task_type: str,
|
|
352
|
+
run_seq: str,
|
|
353
|
+
) -> bool:
|
|
354
|
+
task_key = str(run_manifest.get("taskKey") or team_state.get("taskKey") or "")
|
|
355
|
+
expected_runtime = str(
|
|
356
|
+
run_manifest.get("leadRuntime")
|
|
357
|
+
or team_state.get("leadRuntime")
|
|
358
|
+
or "claude-code"
|
|
359
|
+
)
|
|
314
360
|
if event.lead_runtime != expected_runtime:
|
|
315
361
|
return False
|
|
316
362
|
if task_key and event.task_key != task_key:
|
|
@@ -318,6 +364,53 @@ def _event_matches_run(event, team_state: dict, task_type: str, run_seq: str) ->
|
|
|
318
364
|
return event.task_type == task_type and event.run_seq == run_seq
|
|
319
365
|
|
|
320
366
|
|
|
367
|
+
def _run_sequence(run_manifest: Mapping[str, Any], suffix: str | None) -> str:
|
|
368
|
+
sequences = run_manifest.get("runSequencesByCategory")
|
|
369
|
+
if isinstance(sequences, Mapping):
|
|
370
|
+
value = sequences.get("manifests")
|
|
371
|
+
if isinstance(value, str) and value:
|
|
372
|
+
return value
|
|
373
|
+
if suffix and "-" in suffix:
|
|
374
|
+
return suffix.rsplit("-", 1)[1]
|
|
375
|
+
return ""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _read_scoped_lead_activities(
|
|
379
|
+
team_state: Mapping[str, Any],
|
|
380
|
+
run_manifest: Mapping[str, Any],
|
|
381
|
+
project_root: Path,
|
|
382
|
+
task_type: str,
|
|
383
|
+
suffix: str | None,
|
|
384
|
+
) -> tuple[list[LeadEvent], Path | None, str | None]:
|
|
385
|
+
run_seq = _run_sequence(run_manifest, suffix)
|
|
386
|
+
if not run_seq:
|
|
387
|
+
return [], None, (
|
|
388
|
+
"activity contract cannot scope lead events because the run sequence "
|
|
389
|
+
"is missing from the run manifest and team-state filename."
|
|
390
|
+
)
|
|
391
|
+
events_path, error = _resolve_lead_events_path(
|
|
392
|
+
team_state, run_manifest, project_root
|
|
393
|
+
)
|
|
394
|
+
if error:
|
|
395
|
+
return [], None, error
|
|
396
|
+
try:
|
|
397
|
+
events = read_lead_events(events_path)
|
|
398
|
+
except LeadEventParseError as exc:
|
|
399
|
+
return [], events_path, (
|
|
400
|
+
"artifact lead event log is malformed — selected adapter evidence "
|
|
401
|
+
f"source cannot verify conformance: {exc}"
|
|
402
|
+
)
|
|
403
|
+
activities = [
|
|
404
|
+
event
|
|
405
|
+
for event in events
|
|
406
|
+
if event.event_type == "activity"
|
|
407
|
+
and _event_matches_run(
|
|
408
|
+
event, team_state, run_manifest, task_type, run_seq
|
|
409
|
+
)
|
|
410
|
+
]
|
|
411
|
+
return activities, events_path, None
|
|
412
|
+
|
|
413
|
+
|
|
321
414
|
def _progress_line_from_event(event) -> tuple[str, str, str] | None:
|
|
322
415
|
details = event.details
|
|
323
416
|
phase = details.get("phase")
|
|
@@ -352,6 +445,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
|
|
|
352
445
|
|
|
353
446
|
def _collect_artifact_lead_evidence(
|
|
354
447
|
team_state: dict,
|
|
448
|
+
run_manifest: Mapping[str, Any],
|
|
355
449
|
project_root: Path,
|
|
356
450
|
task_type: str,
|
|
357
451
|
suffix: str | None,
|
|
@@ -361,15 +455,12 @@ def _collect_artifact_lead_evidence(
|
|
|
361
455
|
"artifact lead event log cannot be scoped because team-state filename "
|
|
362
456
|
"does not expose a run artifact suffix."
|
|
363
457
|
)
|
|
364
|
-
events_path, error = _resolve_lead_events_path(
|
|
458
|
+
events_path, error = _resolve_lead_events_path(
|
|
459
|
+
team_state, run_manifest, project_root
|
|
460
|
+
)
|
|
365
461
|
if error:
|
|
366
462
|
return None, error
|
|
367
463
|
|
|
368
|
-
try:
|
|
369
|
-
from okstra_ctl.lead_events import LeadEventParseError, read_lead_events
|
|
370
|
-
except ImportError as exc:
|
|
371
|
-
return None, f"okstra_ctl.lead_events import failed — {exc}"
|
|
372
|
-
|
|
373
464
|
try:
|
|
374
465
|
events = read_lead_events(events_path)
|
|
375
466
|
except LeadEventParseError as exc:
|
|
@@ -378,12 +469,16 @@ def _collect_artifact_lead_evidence(
|
|
|
378
469
|
f"source `artifact-only` cannot verify conformance: {exc}"
|
|
379
470
|
)
|
|
380
471
|
|
|
381
|
-
run_seq =
|
|
472
|
+
run_seq = _run_sequence(run_manifest, suffix)
|
|
382
473
|
evidence = _LeadEvidence(scanned_files=[events_path])
|
|
383
474
|
for event in events:
|
|
384
|
-
if not _event_matches_run(
|
|
475
|
+
if not _event_matches_run(
|
|
476
|
+
event, team_state, run_manifest, task_type, run_seq
|
|
477
|
+
):
|
|
385
478
|
continue
|
|
386
|
-
if event.event_type
|
|
479
|
+
if event.event_type == "activity":
|
|
480
|
+
evidence.activities.append(event)
|
|
481
|
+
elif event.event_type in ("progress", "progress-checkpoint"):
|
|
387
482
|
progress = _progress_line_from_event(event)
|
|
388
483
|
if progress is not None:
|
|
389
484
|
evidence.progress.append(progress)
|
|
@@ -483,6 +578,377 @@ def _ids_reported_as_asked(report_path: Path) -> list[str]:
|
|
|
483
578
|
return asked
|
|
484
579
|
|
|
485
580
|
|
|
581
|
+
def _is_activity_contract_v1_planning(
|
|
582
|
+
run_manifest: Mapping[str, Any],
|
|
583
|
+
) -> bool:
|
|
584
|
+
return (
|
|
585
|
+
run_manifest.get("activityContractVersion") == 1
|
|
586
|
+
and run_manifest.get("taskType") == "implementation-planning"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _activity_index(events: list[LeadEvent]) -> dict[str, list[LeadEvent]]:
|
|
591
|
+
indexed: dict[str, list[LeadEvent]] = {}
|
|
592
|
+
for event in events:
|
|
593
|
+
kind = str(event.details.get("kind") or "")
|
|
594
|
+
indexed.setdefault(kind, []).append(event)
|
|
595
|
+
return indexed
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _read_report_data(report_path: Path) -> Mapping[str, Any]:
|
|
599
|
+
name = report_path.name
|
|
600
|
+
if not name.endswith(".md"):
|
|
601
|
+
return {}
|
|
602
|
+
data_path = report_path.with_name(name.removesuffix(".md") + ".data.json")
|
|
603
|
+
try:
|
|
604
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
605
|
+
except (OSError, json.JSONDecodeError):
|
|
606
|
+
return {}
|
|
607
|
+
return data if isinstance(data, Mapping) else {}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _progress_workers(
|
|
611
|
+
evidence: _LeadEvidence,
|
|
612
|
+
phase: str,
|
|
613
|
+
*,
|
|
614
|
+
completed_only: bool = False,
|
|
615
|
+
) -> set[str]:
|
|
616
|
+
workers: set[str] = set()
|
|
617
|
+
for _timestamp, event_phase, line in evidence.progress:
|
|
618
|
+
if event_phase != phase:
|
|
619
|
+
continue
|
|
620
|
+
if completed_only and _COMPLETED_STATUS_RE.search(line) is None:
|
|
621
|
+
continue
|
|
622
|
+
worker = _progress_worker(line)
|
|
623
|
+
if worker is not None:
|
|
624
|
+
workers.add(worker)
|
|
625
|
+
return workers
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _progress_worker(line: str) -> str | None:
|
|
629
|
+
match = _WORKER_FIELD_RE.search(line)
|
|
630
|
+
return match.group("worker") if match else None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _activity_has_agent(events: list[LeadEvent], roles: set[str]) -> bool:
|
|
634
|
+
return any(
|
|
635
|
+
_worker_role(str(event.details.get("agent") or "")) in roles
|
|
636
|
+
for event in events
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _roster_roles_for_value(value: str, workers: list[dict]) -> set[str]:
|
|
641
|
+
candidate = _worker_role(value)
|
|
642
|
+
for worker in workers:
|
|
643
|
+
roles = _worker_roles(worker)
|
|
644
|
+
if candidate in roles:
|
|
645
|
+
return roles
|
|
646
|
+
return {candidate} if candidate else set()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _check_activity_id_order(events: list[LeadEvent], errors: list[str]) -> None:
|
|
650
|
+
activity_ids = [str(event.details.get("activityId") or "") for event in events]
|
|
651
|
+
invalid = [
|
|
652
|
+
value or "<empty>"
|
|
653
|
+
for value in activity_ids
|
|
654
|
+
if not _ACTIVITY_ID_RE.fullmatch(value)
|
|
655
|
+
]
|
|
656
|
+
if invalid:
|
|
657
|
+
errors.append(
|
|
658
|
+
"activity contract: activityId values must use A-NNN format; "
|
|
659
|
+
f"invalid={invalid}."
|
|
660
|
+
)
|
|
661
|
+
duplicates = sorted(
|
|
662
|
+
{value for value in activity_ids if activity_ids.count(value) > 1}
|
|
663
|
+
)
|
|
664
|
+
if duplicates:
|
|
665
|
+
errors.append(
|
|
666
|
+
"activity contract: activityId values must be unique; "
|
|
667
|
+
f"duplicates={duplicates}."
|
|
668
|
+
)
|
|
669
|
+
if invalid:
|
|
670
|
+
return
|
|
671
|
+
numbers = [
|
|
672
|
+
int(_ACTIVITY_ID_RE.fullmatch(value).group(1))
|
|
673
|
+
for value in activity_ids
|
|
674
|
+
]
|
|
675
|
+
if any(current <= previous for previous, current in zip(numbers, numbers[1:])):
|
|
676
|
+
errors.append(
|
|
677
|
+
"activity contract: activityId values must be in strict A-NNN order; "
|
|
678
|
+
f"recorded={activity_ids}."
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _check_projected_agent_activity(
|
|
683
|
+
events: list[LeadEvent],
|
|
684
|
+
report_data: Mapping[str, Any],
|
|
685
|
+
errors: list[str],
|
|
686
|
+
) -> None:
|
|
687
|
+
expected = [
|
|
688
|
+
{field: event.details.get(field) for field in ACTIVITY_FIELDS}
|
|
689
|
+
for event in events
|
|
690
|
+
]
|
|
691
|
+
projected = report_data.get("agentActivity")
|
|
692
|
+
if projected == expected:
|
|
693
|
+
return
|
|
694
|
+
mismatch = "length"
|
|
695
|
+
if isinstance(projected, list) and len(projected) == len(expected):
|
|
696
|
+
mismatch = next(
|
|
697
|
+
(
|
|
698
|
+
f"index {index}"
|
|
699
|
+
for index, (actual, wanted) in enumerate(zip(projected, expected))
|
|
700
|
+
if actual != wanted
|
|
701
|
+
),
|
|
702
|
+
"content",
|
|
703
|
+
)
|
|
704
|
+
errors.append(
|
|
705
|
+
"activity contract: final-report data.json agentActivity must exactly "
|
|
706
|
+
"match canonical activity events in order and core fields; "
|
|
707
|
+
f"mismatch={mismatch}, expected={len(expected)}, "
|
|
708
|
+
f"projected={len(projected) if isinstance(projected, list) else '<missing>'}."
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _check_activity_worker_pairs(
|
|
713
|
+
evidence: _LeadEvidence,
|
|
714
|
+
indexed: dict[str, list[LeadEvent]],
|
|
715
|
+
analysis_workers: list[dict],
|
|
716
|
+
errors: list[str],
|
|
717
|
+
) -> None:
|
|
718
|
+
expected = (
|
|
719
|
+
("phase-4-dispatch", "worker-dispatched", False),
|
|
720
|
+
("phase-5-collect", "worker-completed", True),
|
|
721
|
+
)
|
|
722
|
+
for phase, kind, completed_only in expected:
|
|
723
|
+
workers = _progress_workers(
|
|
724
|
+
evidence, phase, completed_only=completed_only
|
|
725
|
+
)
|
|
726
|
+
for worker in sorted(workers):
|
|
727
|
+
roles = _roster_roles_for_value(worker, analysis_workers)
|
|
728
|
+
if _activity_has_agent(indexed.get(kind, []), roles):
|
|
729
|
+
continue
|
|
730
|
+
errors.append(
|
|
731
|
+
f"activity contract: missing `{kind}` for worker `{worker}` "
|
|
732
|
+
f"recorded by `PROGRESS: {phase}`."
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _check_activity_worker_agents(
|
|
737
|
+
indexed: dict[str, list[LeadEvent]],
|
|
738
|
+
analysis_workers: list[dict],
|
|
739
|
+
errors: list[str],
|
|
740
|
+
) -> None:
|
|
741
|
+
accepted = {
|
|
742
|
+
role
|
|
743
|
+
for worker in analysis_workers
|
|
744
|
+
for role in _worker_roles(worker)
|
|
745
|
+
}
|
|
746
|
+
roster = [str(worker.get("role") or "") for worker in analysis_workers]
|
|
747
|
+
for kind in ("worker-dispatched", "worker-completed"):
|
|
748
|
+
for event in indexed.get(kind, []):
|
|
749
|
+
agent = str(event.details.get("agent") or "")
|
|
750
|
+
if _worker_role(agent) in accepted:
|
|
751
|
+
continue
|
|
752
|
+
errors.append(
|
|
753
|
+
f"activity contract: `{kind}` agent `{agent or '<empty>'}` must "
|
|
754
|
+
f"match an actual manifest roster role; roster={roster}."
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _self_fix_rounds_applied(report_data: Mapping[str, Any]) -> int:
|
|
759
|
+
planning = report_data.get("implementationPlanning")
|
|
760
|
+
plan_verification = (
|
|
761
|
+
planning.get("planBodyVerification")
|
|
762
|
+
if isinstance(planning, Mapping)
|
|
763
|
+
else None
|
|
764
|
+
)
|
|
765
|
+
value = (
|
|
766
|
+
plan_verification.get("selfFixRoundsApplied")
|
|
767
|
+
if isinstance(plan_verification, Mapping)
|
|
768
|
+
else 0
|
|
769
|
+
)
|
|
770
|
+
return value if isinstance(value, int) and value >= 0 else 0
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _matching_user_reverification_round(
|
|
774
|
+
event: LeadEvent | None,
|
|
775
|
+
clarification_id: str,
|
|
776
|
+
plan_item_ids: set[str],
|
|
777
|
+
verification_rounds: int,
|
|
778
|
+
) -> int | None:
|
|
779
|
+
if event is None or event.details.get("outcome") != "resolved":
|
|
780
|
+
return None
|
|
781
|
+
evidence_refs = {
|
|
782
|
+
ref
|
|
783
|
+
for ref in (event.details.get("evidenceRefs") or [])
|
|
784
|
+
if isinstance(ref, str)
|
|
785
|
+
}
|
|
786
|
+
clarification_refs = {
|
|
787
|
+
ref for ref in evidence_refs if _CLARIFICATION_ID_RE.fullmatch(ref)
|
|
788
|
+
}
|
|
789
|
+
event_item_ids = {
|
|
790
|
+
item_id
|
|
791
|
+
for item_id in (event.details.get("planItemIds") or [])
|
|
792
|
+
if isinstance(item_id, str)
|
|
793
|
+
}
|
|
794
|
+
round_numbers = {
|
|
795
|
+
int(match.group(1))
|
|
796
|
+
for ref in evidence_refs
|
|
797
|
+
for match in [_TARGETED_REVERIFICATION_REF_RE.fullmatch(ref)]
|
|
798
|
+
if match is not None
|
|
799
|
+
}
|
|
800
|
+
if clarification_refs != {clarification_id} or event_item_ids != plan_item_ids:
|
|
801
|
+
return None
|
|
802
|
+
if len(round_numbers) != 1:
|
|
803
|
+
return None
|
|
804
|
+
round_number = next(iter(round_numbers))
|
|
805
|
+
return round_number if round_number <= verification_rounds else None
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _resolved_correctness_reverification_rounds(
|
|
809
|
+
indexed: dict[str, list[LeadEvent]],
|
|
810
|
+
report_data: Mapping[str, Any],
|
|
811
|
+
verification_rounds: int,
|
|
812
|
+
) -> set[int]:
|
|
813
|
+
evaluated_by_id = {
|
|
814
|
+
str(event.details.get("activityId") or ""): event
|
|
815
|
+
for event in indexed.get("user-decision-evaluated", [])
|
|
816
|
+
}
|
|
817
|
+
rounds: set[int] = set()
|
|
818
|
+
for row in report_data.get("clarificationItems") or []:
|
|
819
|
+
if not isinstance(row, Mapping) or row.get("status") != "resolved":
|
|
820
|
+
continue
|
|
821
|
+
context = row.get("approvalContext")
|
|
822
|
+
if not isinstance(context, Mapping):
|
|
823
|
+
continue
|
|
824
|
+
if context.get("classification") != "correctness-critical":
|
|
825
|
+
continue
|
|
826
|
+
resolution = context.get("resolution")
|
|
827
|
+
if not isinstance(resolution, Mapping):
|
|
828
|
+
continue
|
|
829
|
+
clarification_id = str(row.get("id") or "")
|
|
830
|
+
plan_item_ids = {
|
|
831
|
+
item_id
|
|
832
|
+
for item_id in (context.get("planItemIds") or [])
|
|
833
|
+
if isinstance(item_id, str)
|
|
834
|
+
}
|
|
835
|
+
if not plan_item_ids:
|
|
836
|
+
continue
|
|
837
|
+
check_refs = [
|
|
838
|
+
ref for ref in (resolution.get("checkRefs") or []) if isinstance(ref, str)
|
|
839
|
+
]
|
|
840
|
+
matched_rounds = [
|
|
841
|
+
_matching_user_reverification_round(
|
|
842
|
+
evaluated_by_id.get(check_ref),
|
|
843
|
+
clarification_id,
|
|
844
|
+
plan_item_ids,
|
|
845
|
+
verification_rounds,
|
|
846
|
+
)
|
|
847
|
+
for check_ref in check_refs
|
|
848
|
+
]
|
|
849
|
+
if matched_rounds and all(
|
|
850
|
+
round_number is not None for round_number in matched_rounds
|
|
851
|
+
):
|
|
852
|
+
unique_rounds = set(matched_rounds)
|
|
853
|
+
if len(unique_rounds) == 1:
|
|
854
|
+
rounds.update(unique_rounds)
|
|
855
|
+
return rounds
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _check_activity_round_counts(
|
|
859
|
+
indexed: dict[str, list[LeadEvent]],
|
|
860
|
+
report_data: Mapping[str, Any],
|
|
861
|
+
run_dir: Path,
|
|
862
|
+
suffix: str | None,
|
|
863
|
+
errors: list[str],
|
|
864
|
+
) -> None:
|
|
865
|
+
verification_rounds = _plan_body_rounds_ran(run_dir, suffix)
|
|
866
|
+
recorded_verifications = len(indexed.get("verification-round-completed", []))
|
|
867
|
+
user_reverification_rounds = _resolved_correctness_reverification_rounds(
|
|
868
|
+
indexed,
|
|
869
|
+
report_data,
|
|
870
|
+
verification_rounds,
|
|
871
|
+
)
|
|
872
|
+
automatic_rounds = verification_rounds - len(user_reverification_rounds)
|
|
873
|
+
if automatic_rounds > 2:
|
|
874
|
+
errors.append(
|
|
875
|
+
"activity contract: at most two plan verification batches are allowed; "
|
|
876
|
+
f"roundCount={verification_rounds}, "
|
|
877
|
+
f"humanReverificationRounds={sorted(user_reverification_rounds)}."
|
|
878
|
+
)
|
|
879
|
+
if recorded_verifications != automatic_rounds:
|
|
880
|
+
errors.append(
|
|
881
|
+
"activity contract: `verification-round-completed` count must match "
|
|
882
|
+
f"automatic plan-body rounds={automatic_rounds} from "
|
|
883
|
+
f"roundCount={verification_rounds}; recorded={recorded_verifications}."
|
|
884
|
+
)
|
|
885
|
+
self_fix_rounds = _self_fix_rounds_applied(report_data)
|
|
886
|
+
recorded_self_fixes = len(indexed.get("self-fix-applied", []))
|
|
887
|
+
if recorded_self_fixes != self_fix_rounds:
|
|
888
|
+
errors.append(
|
|
889
|
+
"activity contract: `self-fix-applied` count must match "
|
|
890
|
+
f"selfFixRoundsApplied={self_fix_rounds}; recorded={recorded_self_fixes}."
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _activity_references(event: LeadEvent, reference: str) -> bool:
|
|
895
|
+
refs = event.details.get("evidenceRefs")
|
|
896
|
+
return isinstance(refs, list) and reference in refs
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _check_activity_user_decisions(
|
|
900
|
+
indexed: dict[str, list[LeadEvent]],
|
|
901
|
+
report_data: Mapping[str, Any],
|
|
902
|
+
errors: list[str],
|
|
903
|
+
) -> None:
|
|
904
|
+
rows = report_data.get("clarificationItems")
|
|
905
|
+
if not isinstance(rows, list):
|
|
906
|
+
return
|
|
907
|
+
required = indexed.get("user-decision-required", [])
|
|
908
|
+
evaluated = indexed.get("user-decision-evaluated", [])
|
|
909
|
+
for row in rows:
|
|
910
|
+
if not isinstance(row, Mapping) or row.get("blocks") != "approval":
|
|
911
|
+
continue
|
|
912
|
+
clarification_id = str(row.get("id") or "<unknown>")
|
|
913
|
+
confirmation = str(row.get("userConfirmation") or "")
|
|
914
|
+
if confirmation.startswith("asked-") and not any(
|
|
915
|
+
_activity_references(event, clarification_id) for event in required
|
|
916
|
+
):
|
|
917
|
+
errors.append(
|
|
918
|
+
"activity contract: missing `user-decision-required` referencing "
|
|
919
|
+
f"approval clarification `{clarification_id}`."
|
|
920
|
+
)
|
|
921
|
+
if row.get("status") == "resolved" and not any(
|
|
922
|
+
_activity_references(event, clarification_id) for event in evaluated
|
|
923
|
+
):
|
|
924
|
+
errors.append(
|
|
925
|
+
"activity contract: missing `user-decision-evaluated` referencing "
|
|
926
|
+
f"resolved approval clarification `{clarification_id}`."
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _check_activity_contract(
|
|
931
|
+
evidence: _LeadEvidence,
|
|
932
|
+
team_state: Mapping[str, Any],
|
|
933
|
+
run_manifest: Mapping[str, Any],
|
|
934
|
+
report_path: Path,
|
|
935
|
+
run_dir: Path,
|
|
936
|
+
suffix: str | None,
|
|
937
|
+
errors: list[str],
|
|
938
|
+
) -> None:
|
|
939
|
+
if not _is_activity_contract_v1_planning(run_manifest):
|
|
940
|
+
return
|
|
941
|
+
indexed = _activity_index(evidence.activities)
|
|
942
|
+
analysis_workers = _analysis_workers(team_state)
|
|
943
|
+
report_data = _read_report_data(report_path)
|
|
944
|
+
_check_activity_id_order(evidence.activities, errors)
|
|
945
|
+
_check_projected_agent_activity(evidence.activities, report_data, errors)
|
|
946
|
+
_check_activity_worker_pairs(evidence, indexed, analysis_workers, errors)
|
|
947
|
+
_check_activity_worker_agents(indexed, analysis_workers, errors)
|
|
948
|
+
_check_activity_round_counts(indexed, report_data, run_dir, suffix, errors)
|
|
949
|
+
_check_activity_user_decisions(indexed, report_data, errors)
|
|
950
|
+
|
|
951
|
+
|
|
486
952
|
def _check_user_confirm_checkpoints(
|
|
487
953
|
by_phase: dict[str, list[tuple[str, str]]],
|
|
488
954
|
asked_ids: list[str],
|
|
@@ -512,8 +978,14 @@ def _check_user_confirm_checkpoints(
|
|
|
512
978
|
)
|
|
513
979
|
|
|
514
980
|
|
|
515
|
-
def _phase_mentions_worker(
|
|
516
|
-
|
|
981
|
+
def _phase_mentions_worker(
|
|
982
|
+
lines: list[tuple[str, str]], roles: set[str]
|
|
983
|
+
) -> bool:
|
|
984
|
+
return any(
|
|
985
|
+
_worker_role(worker) in roles
|
|
986
|
+
for _ts, line in lines
|
|
987
|
+
if (worker := _progress_worker(line)) is not None
|
|
988
|
+
)
|
|
517
989
|
|
|
518
990
|
|
|
519
991
|
def _check_worker_checkpoint_lines(
|
|
@@ -525,9 +997,9 @@ def _check_worker_checkpoint_lines(
|
|
|
525
997
|
for worker in analysis_workers:
|
|
526
998
|
role = str(worker.get("role", "")).strip() or "<unknown role>"
|
|
527
999
|
status = str(worker.get("status", "")).strip()
|
|
528
|
-
|
|
1000
|
+
roles = _worker_roles(worker)
|
|
529
1001
|
if status in _ATTEMPTED_STATUSES and not _phase_mentions_worker(
|
|
530
|
-
by_phase.get("phase-4-dispatch", []),
|
|
1002
|
+
by_phase.get("phase-4-dispatch", []), roles
|
|
531
1003
|
):
|
|
532
1004
|
errors.append(
|
|
533
1005
|
f"PROGRESS checkpoint missing: no `phase-4-dispatch worker=<role>` "
|
|
@@ -535,7 +1007,7 @@ def _check_worker_checkpoint_lines(
|
|
|
535
1007
|
"prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
536
1008
|
)
|
|
537
1009
|
if status == "completed" and not _phase_mentions_worker(
|
|
538
|
-
by_phase.get("phase-5-collect", []),
|
|
1010
|
+
by_phase.get("phase-5-collect", []), roles
|
|
539
1011
|
):
|
|
540
1012
|
errors.append(
|
|
541
1013
|
f"PROGRESS checkpoint missing: no `phase-5-collect worker=<role>` "
|
|
@@ -642,7 +1114,7 @@ def _check_progress_checkpoints(
|
|
|
642
1114
|
)
|
|
643
1115
|
|
|
644
1116
|
workers = [w for w in (team_state.get("workers") or []) if isinstance(w, dict)]
|
|
645
|
-
analysis_workers =
|
|
1117
|
+
analysis_workers = _analysis_workers(team_state)
|
|
646
1118
|
any_dispatched = any(
|
|
647
1119
|
str(w.get("status", "")).strip() in _DISPATCHED_STATUSES for w in workers
|
|
648
1120
|
)
|
|
@@ -896,12 +1368,13 @@ def validate_session_conformance(
|
|
|
896
1368
|
*,
|
|
897
1369
|
team_state: dict,
|
|
898
1370
|
team_state_path: Path,
|
|
1371
|
+
run_manifest: Mapping[str, Any],
|
|
899
1372
|
project_root: Path,
|
|
900
1373
|
report_path: Path,
|
|
901
1374
|
task_type: str,
|
|
902
1375
|
claude_projects_dir: Path | None = None,
|
|
903
1376
|
) -> SessionConformanceResult:
|
|
904
|
-
"""Run
|
|
1377
|
+
"""Run post-hoc checks for the neutral lead and activity contracts.
|
|
905
1378
|
|
|
906
1379
|
`claude_projects_dir` injects the Claude project root for tests and diagnostics.
|
|
907
1380
|
Heartbeat validation runs before selecting the adapter evidence source.
|
|
@@ -924,11 +1397,17 @@ def validate_session_conformance(
|
|
|
924
1397
|
return result
|
|
925
1398
|
if evidence_source == "artifact-only":
|
|
926
1399
|
evidence, error = _collect_artifact_lead_evidence(
|
|
927
|
-
team_state, project_root, task_type, suffix
|
|
1400
|
+
team_state, run_manifest, project_root, task_type, suffix
|
|
928
1401
|
)
|
|
929
1402
|
else:
|
|
930
1403
|
evidence, error = _collect_lead_evidence(
|
|
931
|
-
team_state,
|
|
1404
|
+
team_state,
|
|
1405
|
+
team_state_path,
|
|
1406
|
+
run_manifest,
|
|
1407
|
+
project_root,
|
|
1408
|
+
task_type,
|
|
1409
|
+
suffix,
|
|
1410
|
+
claude_projects_dir,
|
|
932
1411
|
)
|
|
933
1412
|
if error:
|
|
934
1413
|
result.errors.append(error)
|
|
@@ -936,6 +1415,15 @@ def validate_session_conformance(
|
|
|
936
1415
|
_check_progress_checkpoints(
|
|
937
1416
|
evidence, team_state, run_dir, suffix, report_path, result.errors
|
|
938
1417
|
)
|
|
1418
|
+
_check_activity_contract(
|
|
1419
|
+
evidence,
|
|
1420
|
+
team_state,
|
|
1421
|
+
run_manifest,
|
|
1422
|
+
report_path,
|
|
1423
|
+
run_dir,
|
|
1424
|
+
suffix,
|
|
1425
|
+
result.errors,
|
|
1426
|
+
)
|
|
939
1427
|
_check_cmux_adapter_read(evidence, team_state, result.errors)
|
|
940
1428
|
if task_type == "implementation":
|
|
941
1429
|
_check_implementation_entry_guard(
|