okstra 0.142.0 → 0.143.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.
@@ -8,9 +8,12 @@ import importlib.util
8
8
  import json
9
9
  import os
10
10
  import re
11
+ import shlex
11
12
  import sys
13
+ from collections.abc import Mapping
12
14
  from datetime import datetime, timezone
13
15
  from pathlib import Path
16
+ from typing import Any
14
17
 
15
18
  # Make the okstra packages importable however this validator is reached:
16
19
  # ``scripts/`` next to the repo checkout, ``python/`` next to the installed
@@ -188,10 +191,29 @@ def advance_next_phase(
188
191
  return default_next_phase(current_phase)
189
192
 
190
193
 
194
+ def _error_analysis_next_phase(data: Mapping[str, Any]) -> str | None:
195
+ if not isinstance(data, Mapping):
196
+ return None
197
+ header = data.get("header")
198
+ if not isinstance(header, Mapping) or header.get("taskType") != "error-analysis":
199
+ return None
200
+ error_analysis = data.get("errorAnalysis")
201
+ if not isinstance(error_analysis, Mapping):
202
+ return None
203
+ routing = error_analysis.get("routing")
204
+ if not isinstance(routing, Mapping):
205
+ return None
206
+ target = routing.get("nextTaskType")
207
+ if target in {"error-analysis", "implementation-planning"}:
208
+ return str(target)
209
+ return None
210
+
211
+
191
212
  def update_workflow_metadata(
192
213
  run_manifest: dict,
193
214
  task_manifest: dict,
194
215
  validation_status: str,
216
+ report_data: Mapping[str, Any] | None = None,
195
217
  ) -> None:
196
218
  workflow = task_manifest.get("workflow", {})
197
219
  if not isinstance(workflow, dict):
@@ -221,7 +243,14 @@ def update_workflow_metadata(
221
243
  # Validation just passed → actively advance to the next phase in
222
244
  # the sequence rather than preserving a stale value that may equal
223
245
  # current_phase (which would cause the lifecycle pointer to stall).
224
- next_recommended_phase = advance_next_phase(current_phase, phase_sequence)
246
+ report_next_phase = (
247
+ _error_analysis_next_phase(report_data or {})
248
+ if current_phase == "error-analysis"
249
+ else None
250
+ )
251
+ next_recommended_phase = report_next_phase or advance_next_phase(
252
+ current_phase, phase_sequence
253
+ )
225
254
  else:
226
255
  current_phase_state = "blocked"
227
256
  if current_phase:
@@ -322,6 +351,7 @@ def update_validation_metadata(
322
351
  task_manifest: dict,
323
352
  validation_status: str,
324
353
  failures: list[str],
354
+ report_data: Mapping[str, Any] | None = None,
325
355
  ) -> None:
326
356
  checked_at = utc_now()
327
357
 
@@ -350,7 +380,12 @@ def update_validation_metadata(
350
380
  task_manifest["currentStatus"] = (
351
381
  "completed" if validation_status == "passed" else "contract-violated"
352
382
  )
353
- update_workflow_metadata(run_manifest, task_manifest, validation_status)
383
+ update_workflow_metadata(
384
+ run_manifest,
385
+ task_manifest,
386
+ validation_status,
387
+ report_data=report_data,
388
+ )
354
389
 
355
390
 
356
391
  def extract_contract(
@@ -1645,7 +1680,10 @@ def _validate_conformance(
1645
1680
 
1646
1681
 
1647
1682
  def _check_incremental_audit_block(
1648
- report_path: Path, content: str, failures: list[str]
1683
+ report_path: Path,
1684
+ content: str,
1685
+ failures: list[str],
1686
+ report_data: Mapping[str, Any] | None = None,
1649
1687
  ) -> None:
1650
1688
  """Enforce the Section 0 incremental audit block.
1651
1689
 
@@ -1657,7 +1695,11 @@ def _check_incremental_audit_block(
1657
1695
  decision are exempt, so this never conflicts with the empty-Section-0
1658
1696
  stub rule (that fires only when NO carry-in was provided at all).
1659
1697
  """
1660
- data = _load_final_report_data(report_path)
1698
+ data = (
1699
+ report_data
1700
+ if report_data is not None
1701
+ else _load_final_report_data(report_path)
1702
+ )
1661
1703
  planning = data.get("implementationPlanning")
1662
1704
  if not isinstance(planning, dict):
1663
1705
  return
@@ -1689,6 +1731,7 @@ def validate_report(
1689
1731
  *,
1690
1732
  allow_unavailable_token_usage: bool = False,
1691
1733
  unavailable_ok_labels: frozenset[str] = frozenset(),
1734
+ report_data: Mapping[str, Any] | None = None,
1692
1735
  ) -> None:
1693
1736
  if not report_path.exists():
1694
1737
  failures.append(f"final report is missing: {report_path}")
@@ -1779,7 +1822,12 @@ def validate_report(
1779
1822
 
1780
1823
  # Incremental audit block — an `incremental`-mode re-run must expose its
1781
1824
  # scope decision in Section 0 so the narrowed re-verification is auditable.
1782
- _check_incremental_audit_block(report_path, content, failures)
1825
+ _check_incremental_audit_block(
1826
+ report_path,
1827
+ content,
1828
+ failures,
1829
+ report_data=report_data,
1830
+ )
1783
1831
 
1784
1832
  # Deprecated section headings — pre-1.0 hard removal.
1785
1833
  for pattern, remedy in _DEPRECATED_FINAL_REPORT_PATTERNS:
@@ -2240,6 +2288,213 @@ def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
2240
2288
  )
2241
2289
 
2242
2290
 
2291
+ def _route_target_matches(value: Any, target: str, *, command: bool) -> bool:
2292
+ if not isinstance(value, str):
2293
+ return False
2294
+ if not command:
2295
+ pattern = rf"(?<![\w-]){re.escape(target)}(?![\w-])"
2296
+ return re.search(pattern, value) is not None
2297
+ try:
2298
+ tokens = shlex.split(value)
2299
+ except ValueError:
2300
+ return False
2301
+ task_type_values: list[str] = []
2302
+ for index, token in enumerate(tokens):
2303
+ if token in {"task-type", "--task-type"}:
2304
+ task_type_values.append(
2305
+ tokens[index + 1] if index + 1 < len(tokens) else ""
2306
+ )
2307
+ for prefix in ("task-type=", "--task-type="):
2308
+ if token.startswith(prefix):
2309
+ task_type_values.append(token[len(prefix):])
2310
+ return bool(task_type_values) and all(
2311
+ task_type_value == target for task_type_value in task_type_values
2312
+ )
2313
+
2314
+
2315
+ def _validate_error_analysis_consistency(
2316
+ data: Mapping[str, Any], failures: list[str]
2317
+ ) -> None:
2318
+ error_analysis_value = data.get("errorAnalysis")
2319
+ error_analysis = (
2320
+ error_analysis_value if isinstance(error_analysis_value, Mapping) else {}
2321
+ )
2322
+ reproduction_value = error_analysis.get("reproduction")
2323
+ reproduction = (
2324
+ reproduction_value if isinstance(reproduction_value, Mapping) else {}
2325
+ )
2326
+ reproduction_status = reproduction.get("status")
2327
+ blocked_reason = reproduction.get("blockedReason")
2328
+ if reproduction_status == "blocked-before-repro":
2329
+ if not isinstance(blocked_reason, str) or not blocked_reason.strip():
2330
+ failures.append(
2331
+ "final-report data.json: blocked-before-repro requires a non-empty "
2332
+ "errorAnalysis.reproduction.blockedReason."
2333
+ )
2334
+ elif blocked_reason != "":
2335
+ failures.append(
2336
+ "final-report data.json: errorAnalysis.reproduction.blockedReason "
2337
+ "must be exactly empty unless status is blocked-before-repro."
2338
+ )
2339
+
2340
+ candidates_value = error_analysis.get("causeCandidates")
2341
+ candidates = candidates_value if isinstance(candidates_value, list) else []
2342
+ candidate_ids: list[str] = []
2343
+ for candidate in candidates:
2344
+ if not isinstance(candidate, Mapping):
2345
+ continue
2346
+ candidate_id = candidate.get("id")
2347
+ if isinstance(candidate_id, str):
2348
+ candidate_ids.append(candidate_id)
2349
+ duplicate_ids = sorted(
2350
+ candidate_id
2351
+ for candidate_id in set(candidate_ids)
2352
+ if candidate_ids.count(candidate_id) > 1
2353
+ )
2354
+ if duplicate_ids:
2355
+ failures.append(
2356
+ "final-report data.json: duplicate cause candidate id(s): "
2357
+ + ", ".join(duplicate_ids)
2358
+ + "."
2359
+ )
2360
+
2361
+ routing_value = error_analysis.get("routing")
2362
+ routing = routing_value if isinstance(routing_value, Mapping) else {}
2363
+ target = routing.get("nextTaskType")
2364
+ leading_cause_id = routing.get("leadingCauseId")
2365
+ candidate_id_set = set(candidate_ids)
2366
+ if target == "implementation-planning":
2367
+ if not candidates:
2368
+ failures.append(
2369
+ "final-report data.json: implementation-planning routing requires "
2370
+ "at least one cause candidate."
2371
+ )
2372
+ if (
2373
+ not isinstance(leading_cause_id, str)
2374
+ or leading_cause_id not in candidate_id_set
2375
+ ):
2376
+ failures.append(
2377
+ "final-report data.json: implementation-planning routing "
2378
+ "leadingCauseId must reference a cause candidate."
2379
+ )
2380
+ elif target == "error-analysis" and (
2381
+ not isinstance(leading_cause_id, str)
2382
+ or (leading_cause_id != "" and leading_cause_id not in candidate_id_set)
2383
+ ):
2384
+ failures.append(
2385
+ "final-report data.json: error-analysis routing leadingCauseId must be "
2386
+ "empty or reference a cause candidate."
2387
+ )
2388
+
2389
+ expected_direction = {
2390
+ "implementation-planning": "begin-planning",
2391
+ "error-analysis": "continue-investigation",
2392
+ }.get(target)
2393
+ verdict_card_value = data.get("verdictCard")
2394
+ verdict_card = (
2395
+ verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
2396
+ )
2397
+ final_verdict_value = data.get("finalVerdict")
2398
+ final_verdict = (
2399
+ final_verdict_value if isinstance(final_verdict_value, Mapping) else {}
2400
+ )
2401
+ if expected_direction:
2402
+ for field_name, verdict in (
2403
+ ("verdictCard", verdict_card),
2404
+ ("finalVerdict", final_verdict),
2405
+ ):
2406
+ if verdict.get("direction") != expected_direction:
2407
+ failures.append(
2408
+ f"final-report data.json: {field_name}.direction must be "
2409
+ f"`{expected_direction}` for `{target}` routing."
2410
+ )
2411
+
2412
+ follow_up_tasks_value = data.get("followUpTasks")
2413
+ follow_up_tasks = (
2414
+ follow_up_tasks_value if isinstance(follow_up_tasks_value, list) else []
2415
+ )
2416
+ continuations = [
2417
+ row
2418
+ for row in follow_up_tasks
2419
+ if isinstance(row, Mapping) and row.get("origin") == "phase-continuation"
2420
+ ]
2421
+ if len(continuations) != 1:
2422
+ failures.append(
2423
+ "final-report data.json: followUpTasks must contain exactly one "
2424
+ "phase-continuation row."
2425
+ )
2426
+ else:
2427
+ continuation = continuations[0]
2428
+ frontmatter_value = data.get("frontmatter")
2429
+ frontmatter = (
2430
+ frontmatter_value if isinstance(frontmatter_value, Mapping) else {}
2431
+ )
2432
+ task_id = frontmatter.get("taskId")
2433
+ if continuation.get("suggestedTaskType") != target:
2434
+ failures.append(
2435
+ "final-report data.json: phase-continuation suggestedTaskType "
2436
+ "must match the routing target."
2437
+ )
2438
+ if continuation.get("newTaskId") != task_id:
2439
+ failures.append(
2440
+ "final-report data.json: phase-continuation newTaskId must match "
2441
+ "frontmatter.taskId."
2442
+ )
2443
+ if continuation.get("priority") != "P0":
2444
+ failures.append(
2445
+ "final-report data.json: phase-continuation priority must be P0."
2446
+ )
2447
+ if continuation.get("autoSpawn") != "no":
2448
+ failures.append(
2449
+ "final-report data.json: phase-continuation autoSpawn must be no."
2450
+ )
2451
+
2452
+ if isinstance(target, str) and target in {
2453
+ "error-analysis",
2454
+ "implementation-planning",
2455
+ }:
2456
+ for field_name, value in (
2457
+ ("verdictCard.nextStep", verdict_card.get("nextStep")),
2458
+ ("finalVerdict.nextStep", final_verdict.get("nextStep")),
2459
+ ):
2460
+ if not _route_target_matches(value, target, command=False):
2461
+ failures.append(
2462
+ f"final-report data.json: {field_name} must contain routing "
2463
+ f"target `{target}`."
2464
+ )
2465
+
2466
+ next_steps_value = data.get("recommendedNextSteps")
2467
+ next_steps = next_steps_value if isinstance(next_steps_value, list) else []
2468
+ first_step = (
2469
+ next_steps[0]
2470
+ if next_steps and isinstance(next_steps[0], Mapping)
2471
+ else {}
2472
+ )
2473
+ step_text = first_step.get("text")
2474
+ if not _route_target_matches(step_text, target, command=False):
2475
+ failures.append(
2476
+ "final-report data.json: recommendedNextSteps[0].text must contain "
2477
+ f"routing target `{target}`."
2478
+ )
2479
+ commands_value = first_step.get("commands")
2480
+ commands = commands_value if isinstance(commands_value, list) else []
2481
+ if not commands:
2482
+ failures.append(
2483
+ "final-report data.json: recommendedNextSteps[0].commands must "
2484
+ "contain at least one command."
2485
+ )
2486
+ for index, command_value in enumerate(commands):
2487
+ command = command_value if isinstance(command_value, Mapping) else {}
2488
+ for command_field in ("claudeCode", "terminal"):
2489
+ value = command.get(command_field)
2490
+ if not _route_target_matches(value, target, command=True):
2491
+ failures.append(
2492
+ "final-report data.json: recommendedNextSteps[0].commands"
2493
+ f"[{index}].{command_field} must contain routing target "
2494
+ f"`{target}`."
2495
+ )
2496
+
2497
+
2243
2498
  def _load_final_report_data(report_path: Path) -> dict:
2244
2499
  """Best-effort parse of the final-report data.json sibling. Returns {} when
2245
2500
  absent or unparseable — those conditions are already surfaced as failures by
@@ -2258,7 +2513,7 @@ def validate_final_report_data(
2258
2513
  failures: list[str],
2259
2514
  *,
2260
2515
  report_contracts: set[str] | None = None,
2261
- ) -> None:
2516
+ ) -> Mapping[str, Any] | None:
2262
2517
  """Validate the final-report data.json against the v1.0 schema.
2263
2518
 
2264
2519
  The data.json is the source-of-truth that the renderer reads to
@@ -2271,6 +2526,8 @@ def validate_final_report_data(
2271
2526
  Missing data.json is reported as a single failure rather than a
2272
2527
  cascade of substring failures — that points the writer at the right
2273
2528
  fix (write the data.json) instead of futilely editing the markdown.
2529
+ The returned mapping is the exact loaded snapshot consumed by later
2530
+ finalization checks and workflow persistence.
2274
2531
  """
2275
2532
  if schema_validate is None or load_schema is None:
2276
2533
  # Module-load fallback path; should never fire in a real install.
@@ -2315,7 +2572,9 @@ def validate_final_report_data(
2315
2572
  _validate_verifier_fail_blocks_verdict(data, failures)
2316
2573
  if task_type == "implementation":
2317
2574
  _validate_stage_carry_sidecar_exists(data, report_path, failures)
2318
- if task_type == "final-verification":
2575
+ if task_type == "error-analysis":
2576
+ _validate_error_analysis_consistency(data, failures)
2577
+ elif task_type == "final-verification":
2319
2578
  _validate_final_verification_consistency(data, failures)
2320
2579
  _validate_verified_row_recorded(data, report_path, failures)
2321
2580
  elif task_type == "implementation-planning":
@@ -2364,6 +2623,8 @@ def validate_final_report_data(
2364
2623
  for warning in warnings:
2365
2624
  print(f"validate-run: warning: {warning}", file=sys.stderr)
2366
2625
 
2626
+ return data
2627
+
2367
2628
 
2368
2629
  # path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
2369
2630
  # namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
@@ -3727,20 +3988,6 @@ def _validate_missing_qa_categories_recorded(
3727
3988
  )
3728
3989
 
3729
3990
 
3730
- def _load_report_data(report_path: Path) -> dict | None:
3731
- """The report's data.json, or ``None`` when it is missing or malformed.
3732
- Both cases are already reported by `validate_final_report_data`; callers
3733
- here only need to skip rather than re-report."""
3734
- data_path = _data_path_for(report_path)
3735
- if not data_path.is_file():
3736
- return None
3737
- try:
3738
- loaded = json.loads(data_path.read_text(encoding="utf-8"))
3739
- except (OSError, json.JSONDecodeError):
3740
- return None
3741
- return loaded if isinstance(loaded, dict) else None
3742
-
3743
-
3744
3991
  def _validate_verification_target_match(
3745
3992
  data: dict,
3746
3993
  run_manifest: dict,
@@ -6340,20 +6587,20 @@ def main() -> int:
6340
6587
  report_contracts = _normalize_report_contracts(
6341
6588
  run_manifest.get("reportContracts")
6342
6589
  )
6343
- validate_final_report_data(
6590
+ report_data = validate_final_report_data(
6344
6591
  report_path,
6345
6592
  failures,
6346
6593
  report_contracts=report_contracts,
6347
6594
  )
6348
- _validate_fix_cycle(
6349
- run_manifest, _load_final_report_data(report_path), failures
6350
- )
6595
+ validation_data = report_data if isinstance(report_data, Mapping) else {}
6596
+ _validate_fix_cycle(run_manifest, validation_data, failures)
6351
6597
  validate_report(
6352
6598
  report_path,
6353
6599
  contract["required_agent_status_entries"],
6354
6600
  failures,
6355
6601
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
6356
6602
  unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
6603
+ report_data=validation_data,
6357
6604
  )
6358
6605
  validate_team_state_usage(team_state, failures)
6359
6606
 
@@ -6401,11 +6648,12 @@ def main() -> int:
6401
6648
  if task_type in _END_STATE_PHASES:
6402
6649
  if task_type == "implementation-planning":
6403
6650
  _validate_planning_conformance_declared(report_path, failures)
6404
- report_data = _load_final_report_data(report_path)
6405
- _validate_end_state_coverage(report_data, brief_path, failures)
6651
+ _validate_end_state_coverage(validation_data, brief_path, failures)
6406
6652
  if task_type == "implementation-planning":
6407
- _validate_requirement_provenance(report_data, brief_path, failures)
6408
- _validate_stage_has_requirement(report_data, failures)
6653
+ _validate_requirement_provenance(
6654
+ validation_data, brief_path, failures
6655
+ )
6656
+ _validate_stage_has_requirement(validation_data, failures)
6409
6657
  if task_type == "improvement-discovery":
6410
6658
  run_dir = report_path.parent.parent
6411
6659
  _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
@@ -6422,22 +6670,20 @@ def main() -> int:
6422
6670
  validate_report_views(report_path, failures)
6423
6671
  if task_type == "implementation":
6424
6672
  _validate_missing_qa_categories_recorded(
6425
- _load_report_data(report_path) or {}, project_root, failures
6673
+ validation_data, project_root, failures
6426
6674
  )
6427
6675
  if task_type == "implementation-planning":
6428
- _validate_plan_body_state_file(
6429
- _load_report_data(report_path) or {}, report_path, failures
6430
- )
6676
+ _validate_plan_body_state_file(validation_data, report_path, failures)
6431
6677
  if task_type == "final-verification":
6432
6678
  _validate_verification_target_match(
6433
- _load_report_data(report_path) or {},
6679
+ validation_data,
6434
6680
  run_manifest,
6435
6681
  project_root,
6436
6682
  failures,
6437
6683
  )
6438
6684
  # Phase-agnostic: any task-type can be launched with a carry-in.
6439
6685
  _validate_clarification_carry_in_recorded(
6440
- _load_report_data(report_path) or {},
6686
+ validation_data,
6441
6687
  report_path,
6442
6688
  run_manifest_path,
6443
6689
  project_root,
@@ -6446,7 +6692,12 @@ def main() -> int:
6446
6692
 
6447
6693
  validation_status = "passed" if not failures else "failed"
6448
6694
  update_validation_metadata(
6449
- team_state, run_manifest, task_manifest, validation_status, failures
6695
+ team_state,
6696
+ run_manifest,
6697
+ task_manifest,
6698
+ validation_status,
6699
+ failures,
6700
+ report_data=validation_data,
6450
6701
  )
6451
6702
 
6452
6703
  write_json(team_state_path, team_state)