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.
Files changed (47) hide show
  1. package/docs/architecture.md +13 -0
  2. package/docs/cli.md +4 -2
  3. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  4. package/docs/project-structure-overview.md +3 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/launch.template.md +4 -0
  8. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  9. package/runtime/prompts/lead/okstra-lead-contract.md +36 -12
  10. package/runtime/prompts/lead/plan-body-verification.md +22 -11
  11. package/runtime/prompts/lead/report-writer.md +11 -10
  12. package/runtime/prompts/lead/team-contract.md +2 -0
  13. package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
  14. package/runtime/prompts/profiles/_common-contract.md +2 -1
  15. package/runtime/prompts/profiles/implementation-planning.md +8 -1
  16. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  17. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  18. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  19. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  20. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  21. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  22. package/runtime/python/okstra_ctl/agent_activity.py +306 -0
  23. package/runtime/python/okstra_ctl/clarification_items.py +37 -20
  24. package/runtime/python/okstra_ctl/cmux.py +144 -59
  25. package/runtime/python/okstra_ctl/lead_events.py +47 -4
  26. package/runtime/python/okstra_ctl/render.py +11 -3
  27. package/runtime/python/okstra_ctl/report_finalize.py +51 -14
  28. package/runtime/python/okstra_ctl/report_html/common.py +5 -3
  29. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +17 -1
  30. package/runtime/python/okstra_ctl/report_translation.py +14 -0
  31. package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
  32. package/runtime/schemas/final-report-v2.0.schema.json +189 -0
  33. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  34. package/runtime/templates/reports/final-report-v2.template.md +8 -0
  35. package/runtime/templates/reports/html/assets/base.css +7 -0
  36. package/runtime/templates/reports/html/i18n/en.json +6 -1
  37. package/runtime/templates/reports/html/i18n/ko.json +6 -1
  38. package/runtime/templates/reports/html/macros/forms.html +21 -2
  39. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +25 -0
  40. package/runtime/templates/reports/i18n/en.json +4 -0
  41. package/runtime/templates/reports/report.js +26 -17
  42. package/runtime/templates/reports/user-response.template.md +3 -1
  43. package/runtime/templates/worker-prompt-preamble.md +8 -0
  44. package/runtime/validators/validate-run.py +989 -29
  45. package/runtime/validators/validate_session_conformance.py +523 -35
  46. package/src/cli-registry.mjs +7 -0
  47. package/src/commands/report/agent-activity.mjs +21 -0
@@ -1,4 +1,4 @@
1
- """Post-hoc checks for three BLOCKING neutral lead lifecycle contracts.
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 _worker_needles(worker: dict) -> list[str]:
174
- """worker 식별 needle 선언 형식 `worker=<role>` 와 role/workerId 표기를
175
- normalize 매칭으로 흡수한다 (`Claude worker` ↔ `claude-worker`)."""
176
- needles = []
177
- role = _norm(str(worker.get("role", "")))
178
- if role:
179
- needles.append(role)
180
- worker_id = _norm(str(worker.get("workerId", "")))
181
- if worker_id:
182
- needles.append(worker_id + "worker")
183
- return needles
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: dict, project_root: Path
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(event, team_state: dict, task_type: str, run_seq: str) -> bool:
312
- task_key = str(team_state.get("taskKey", ""))
313
- expected_runtime = str(team_state.get("leadRuntime", "") or "claude-code")
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(team_state, project_root)
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 = suffix.rsplit("-", 1)[1]
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(event, team_state, task_type, run_seq):
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 in ("progress", "progress-checkpoint"):
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(lines: list[tuple[str, str]], needles: list[str]) -> bool:
516
- return any(needle in _norm(line) for _ts, line in lines for needle in needles)
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
- needles = _worker_needles(worker)
1000
+ roles = _worker_roles(worker)
529
1001
  if status in _ATTEMPTED_STATUSES and not _phase_mentions_worker(
530
- by_phase.get("phase-4-dispatch", []), needles
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", []), needles
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 = [w for w in workers if not _is_report_writer(w)]
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 three post-hoc checks for the neutral lead lifecycle contract.
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, team_state_path, project_root, claude_projects_dir
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(