okstra 0.127.0 → 0.129.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 (53) hide show
  1. package/docs/architecture/storage-model.md +23 -3
  2. package/docs/architecture.md +28 -1
  3. package/docs/cli.md +9 -0
  4. package/docs/project-structure-overview.md +17 -7
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -3
  8. package/runtime/agents/workers/claude-worker.md +4 -4
  9. package/runtime/agents/workers/codex-worker.md +3 -3
  10. package/runtime/agents/workers/report-writer-worker.md +5 -5
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -1
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/convergence.md +45 -83
  15. package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
  16. package/runtime/prompts/lead/report-writer.md +3 -2
  17. package/runtime/prompts/lead/team-contract.md +19 -9
  18. package/runtime/prompts/profiles/_common-contract.md +1 -1
  19. package/runtime/prompts/profiles/error-analysis.md +1 -1
  20. package/runtime/prompts/profiles/final-verification.md +10 -6
  21. package/runtime/prompts/profiles/implementation-planning.md +5 -0
  22. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  23. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  24. package/runtime/python/okstra_ctl/analysis_packet.py +14 -15
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +51 -80
  26. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  27. package/runtime/python/okstra_ctl/convergence.py +223 -0
  28. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  29. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  30. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  31. package/runtime/python/okstra_ctl/dispatch_core.py +60 -1
  32. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  33. package/runtime/python/okstra_ctl/log_report.py +44 -5
  34. package/runtime/python/okstra_ctl/path_hints.py +28 -1
  35. package/runtime/python/okstra_ctl/paths.py +10 -1
  36. package/runtime/python/okstra_ctl/render.py +78 -11
  37. package/runtime/python/okstra_ctl/run.py +66 -2
  38. package/runtime/python/okstra_ctl/wizard.py +15 -4
  39. package/runtime/python/okstra_ctl/work_categories.py +37 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  41. package/runtime/python/okstra_ctl/worker_prompt_contract.py +316 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_headers.py +109 -10
  43. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  44. package/runtime/templates/implementation-worker-preamble.md +51 -0
  45. package/runtime/templates/operating-standard.md +4 -4
  46. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  47. package/runtime/templates/worker-error-contract.md +46 -0
  48. package/runtime/templates/worker-prompt-preamble.md +28 -227
  49. package/runtime/validators/lib/fixtures.sh +27 -12
  50. package/runtime/validators/validate-run.py +228 -45
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/execute/convergence.mjs +34 -0
  53. package/src/commands/inspect/log-report.mjs +5 -3
@@ -46,6 +46,9 @@ from okstra_ctl.md_table import ( # noqa: E402
46
46
  split_pipe_row as _split_pipe_row,
47
47
  )
48
48
  from okstra_ctl.final_report_paths import final_report_data_path as _data_path_for # noqa: E402
49
+ from okstra_ctl.improvement_assignment import ( # noqa: E402
50
+ validate_primary_lens_assignments,
51
+ )
49
52
  from okstra_ctl.design_prep import ( # noqa: E402
50
53
  DesignPrepError,
51
54
  _planning_seq as _design_prep_planning_seq,
@@ -58,6 +61,11 @@ from okstra_ctl.design_surfaces import ( # noqa: E402
58
61
  detect_design_surfaces,
59
62
  expected_prep_plan_item_id,
60
63
  )
64
+ from okstra_ctl.worker_prompt_contract import ( # noqa: E402
65
+ PromptRecord,
66
+ validate_initial_prompt_records,
67
+ )
68
+ from okstra_ctl.convergence_engine import validate_final_state # noqa: E402
61
69
 
62
70
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
63
71
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
@@ -367,6 +375,201 @@ def effective_run_task_type(run_manifest: dict, task_manifest: dict) -> str:
367
375
  ).strip()
368
376
 
369
377
 
378
+ def _resolve_prompt_record_path(project_root: Path, prompt_value: str) -> Path:
379
+ prompt_path = Path(prompt_value)
380
+ return prompt_path if prompt_path.is_absolute() else project_root / prompt_path
381
+
382
+
383
+ def _fallback_dispatch_kind(prompt_value: str) -> str:
384
+ match = re.search(r"-reverify-(r\d+)", Path(prompt_value).name)
385
+ return f"reverify-{match.group(1)}" if match else "initial"
386
+
387
+
388
+ def _persisted_prompt_records(
389
+ *,
390
+ project_root: Path,
391
+ selected_ids: set[str],
392
+ team_state: dict,
393
+ ) -> list[PromptRecord]:
394
+ dispatches = team_state.get("workerDispatches")
395
+ source = dispatches if isinstance(dispatches, list) and dispatches else (
396
+ team_state.get("workers") or []
397
+ )
398
+ records: list[PromptRecord] = []
399
+ seen: set[tuple[str, str, str]] = set()
400
+ for worker in source:
401
+ if not isinstance(worker, dict):
402
+ continue
403
+ worker_id = str(worker.get("workerId") or "").strip()
404
+ prompt_value = str(worker.get("promptPath") or "").strip()
405
+ if worker_id not in selected_ids or not prompt_value:
406
+ continue
407
+ dispatch_kind = str(worker.get("kind") or "").strip()
408
+ if not dispatch_kind:
409
+ dispatch_kind = _fallback_dispatch_kind(prompt_value)
410
+ identity = (worker_id, dispatch_kind, prompt_value)
411
+ if identity in seen:
412
+ continue
413
+ seen.add(identity)
414
+ records.append(
415
+ PromptRecord(
416
+ worker_id=worker_id,
417
+ dispatch_kind=dispatch_kind,
418
+ path=_resolve_prompt_record_path(project_root, prompt_value),
419
+ )
420
+ )
421
+ return records
422
+
423
+
424
+ def _markdown_section(text: str, heading: str) -> str:
425
+ pattern = rf"(?ms)^##\s+{re.escape(heading)}\s*$\n(.*?)(?=^##\s|\Z)"
426
+ match = re.search(pattern, text)
427
+ return match.group(1) if match else ""
428
+
429
+
430
+ def _parse_resolved_lenses(grilling_text: str) -> list[str]:
431
+ section = _markdown_section(grilling_text, "Resolved lenses")
432
+ return [
433
+ match.group(1).strip().strip("`")
434
+ for line in section.splitlines()
435
+ if (match := re.match(r"^\s*-\s+(.+?)\s*$", line))
436
+ ]
437
+
438
+
439
+ def _parse_primary_assignments(
440
+ grilling_text: str,
441
+ ) -> tuple[dict[str, str], list[str]]:
442
+ section = _markdown_section(grilling_text, "Primary Pass Assignments")
443
+ rows = [
444
+ (line.strip(), _split_pipe_row(line.strip()))
445
+ for line in section.splitlines()
446
+ if line.strip().startswith("|") and line.strip().endswith("|")
447
+ ]
448
+ errors: list[str] = []
449
+ if not rows or rows[0][1] != ["Worker ID", "Primary lens"]:
450
+ return {}, [
451
+ "Primary Pass Assignments table must start with "
452
+ "`| Worker ID | Primary lens |`"
453
+ ]
454
+ assignments: dict[str, str] = {}
455
+ for raw_line, row in rows[1:]:
456
+ if _is_markdown_separator(raw_line):
457
+ continue
458
+ if len(row) != 2 or not all(cell.strip() for cell in row):
459
+ errors.append("Primary Pass Assignments rows must contain two values")
460
+ continue
461
+ worker_id, lens = (cell.strip().strip("`") for cell in row)
462
+ if worker_id in assignments:
463
+ errors.append(f"duplicate worker assignment row: {worker_id}")
464
+ continue
465
+ assignments[worker_id] = lens
466
+ return assignments, errors
467
+
468
+
469
+ def _grilling_log_path_from_prompts(
470
+ *,
471
+ project_root: Path,
472
+ worker_ids: list[str],
473
+ records: list[PromptRecord],
474
+ ) -> tuple[Path | None, list[str]]:
475
+ header = "**Phase 1.5 Grilling Log:**"
476
+ values: set[str] = set()
477
+ errors: list[str] = []
478
+ for record in records:
479
+ if record.worker_id not in worker_ids or record.dispatch_kind != "initial":
480
+ continue
481
+ try:
482
+ text = record.path.read_text(encoding="utf-8")
483
+ except OSError:
484
+ continue
485
+ matches = [
486
+ line.strip()[len(header):].strip().strip("`")
487
+ for line in text.splitlines()
488
+ if line.strip().startswith(header)
489
+ ]
490
+ if len(matches) == 1 and matches[0]:
491
+ values.add(matches[0])
492
+ if not values:
493
+ return None, ["initial analyser prompts have no grilling-log path"]
494
+ if len(values) != 1:
495
+ return None, ["initial analyser prompts reference different grilling logs"]
496
+ return _resolve_prompt_record_path(project_root, values.pop()), errors
497
+
498
+
499
+ def _validate_improvement_primary_assignments(
500
+ *,
501
+ project_root: Path,
502
+ worker_ids: list[str],
503
+ records: list[PromptRecord],
504
+ ) -> list[str]:
505
+ grilling_path, errors = _grilling_log_path_from_prompts(
506
+ project_root=project_root,
507
+ worker_ids=worker_ids,
508
+ records=records,
509
+ )
510
+ if grilling_path is None:
511
+ return errors
512
+ try:
513
+ grilling_text = grilling_path.read_text(encoding="utf-8")
514
+ except OSError as exc:
515
+ return [*errors, f"cannot read grilling log {grilling_path}: {exc}"]
516
+ lenses = _parse_resolved_lenses(grilling_text)
517
+ assignments, parse_errors = _parse_primary_assignments(grilling_text)
518
+ return [
519
+ *errors,
520
+ *parse_errors,
521
+ *validate_primary_lens_assignments(assignments, worker_ids, lenses),
522
+ ]
523
+
524
+
525
+ def _validate_initial_analysis_prompts(
526
+ data: dict,
527
+ failures: list[str],
528
+ ) -> None:
529
+ """Validate persisted prompts using their functional audience contract."""
530
+ task_type = str(data.get("taskType") or "").strip()
531
+ if not task_type:
532
+ return
533
+ run_manifest = data.get("runManifest") or {}
534
+ team_contract = run_manifest.get("teamContract") or {}
535
+ selected_workers = team_contract.get("requiredWorkerRoles") or []
536
+ selected_worker_ids = [
537
+ str(worker.get("workerId") or "").strip()
538
+ for worker in selected_workers
539
+ if isinstance(worker, dict)
540
+ and str(worker.get("workerId") or "").strip()
541
+ ]
542
+ selected_ids = set(selected_worker_ids)
543
+ project_root = Path(data["projectRoot"])
544
+ team_state = data.get("teamState") or {}
545
+ manifest = dict(run_manifest)
546
+ manifest["taskType"] = task_type
547
+ records = _persisted_prompt_records(
548
+ project_root=project_root,
549
+ selected_ids=selected_ids,
550
+ team_state=team_state,
551
+ )
552
+ errors = validate_initial_prompt_records(
553
+ manifest=manifest,
554
+ records=records,
555
+ )
556
+ if task_type == "improvement-discovery":
557
+ errors.extend(
558
+ _validate_improvement_primary_assignments(
559
+ project_root=project_root,
560
+ worker_ids=[
561
+ worker_id
562
+ for worker_id in selected_worker_ids
563
+ if worker_id != "report-writer"
564
+ ],
565
+ records=records,
566
+ )
567
+ )
568
+ failures.extend(
569
+ f"{task_type} prompt contract: {error}" for error in errors
570
+ )
571
+
572
+
370
573
  def _is_legal_concurrent_run_skip(
371
574
  team_create: object, concurrent_run_authorized: bool
372
575
  ) -> bool:
@@ -3090,63 +3293,34 @@ def _validate_forbidden_actions(
3090
3293
  failures.extend(f"forbidden-action: {v}" for v in violations)
3091
3294
 
3092
3295
 
3093
- CLASSIFICATION_COUNT_KEYS = {
3094
- "full-consensus": "fullConsensus",
3095
- "partial-consensus": "partialConsensus",
3096
- "contested": "contested",
3097
- "worker-unique": "workerUnique",
3098
- }
3099
-
3100
-
3101
- def classification_counts_from_findings(findings) -> dict:
3102
- """Recompute `finalClassificationCounts` from `findings[].classification`.
3103
-
3104
- Shared with `tests/contract/test_convergence_state_contract.py` so the
3105
- invariant has one definition — it used to live only in that test, which
3106
- meant it was checked against four hand-written fixtures and never against
3107
- the state a lead actually writes.
3108
- """
3109
- counts = {value: 0 for value in CLASSIFICATION_COUNT_KEYS.values()}
3110
- for finding in findings or []:
3111
- if not isinstance(finding, dict):
3112
- continue
3113
- key = CLASSIFICATION_COUNT_KEYS.get(finding.get("classification"))
3114
- if key:
3115
- counts[key] += 1
3116
- return counts
3117
-
3296
+ _CONVERGENCE_INTERMEDIATE_PREFIXES = (
3297
+ "convergence-groups-",
3298
+ "convergence-work-",
3299
+ "convergence-round-",
3300
+ )
3118
3301
 
3119
- def _validate_convergence_state_counts(run_dir, failures) -> None:
3120
- """A run's convergence state must agree with its own findings list.
3121
3302
 
3122
- `finalClassificationCounts` is a lead-authored summary of
3123
- `findings[].classification`. Nothing recomputed it against the real
3124
- artifact, so a miscount survived into the report as two different truths
3125
- (observed on dev-6901 planning 009: declared partial 6 / worker-unique 4
3126
- against an actual 5 / 5). Downstream readers pick one arbitrarily.
3127
- """
3303
+ def _validate_convergence_states(run_dir, failures) -> None:
3304
+ """Replay shared engine invariants for every public final state artifact."""
3128
3305
  from pathlib import Path as _Path
3129
3306
 
3130
3307
  state_dir = _Path(run_dir) / "state"
3131
3308
  if not state_dir.is_dir():
3132
3309
  return
3133
3310
  for state_path in sorted(state_dir.glob("convergence-*.json")):
3311
+ if state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES):
3312
+ continue
3134
3313
  try:
3135
3314
  state = json.loads(state_path.read_text(encoding="utf-8"))
3136
3315
  except (OSError, json.JSONDecodeError) as exc:
3137
- failures.append(f"convergence state {state_path.name} unreadable: {exc}")
3138
- continue
3139
- declared = state.get("finalClassificationCounts")
3140
- if not isinstance(declared, dict):
3141
- continue
3142
- actual = classification_counts_from_findings(state.get("findings"))
3143
- if declared != actual:
3144
3316
  failures.append(
3145
- f"convergence state {state_path.name}: finalClassificationCounts "
3146
- f"{declared} does not match findings[].classification {actual}. "
3147
- "The counts are a summary of the findings list — a mismatch means "
3148
- "the run reports two different classifications for the same work."
3317
+ f"convergence state {state_path.name}: unreadable JSON: {exc}"
3149
3318
  )
3319
+ continue
3320
+ failures.extend(
3321
+ f"convergence state {state_path.name}: {error}"
3322
+ for error in validate_final_state(state)
3323
+ )
3150
3324
 
3151
3325
 
3152
3326
  def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
@@ -3524,6 +3698,15 @@ def main() -> int:
3524
3698
  # contain every required section. Substring checks below are a
3525
3699
  # safety net for hand-edited or pre-v1.0 reports.
3526
3700
  task_type = effective_run_task_type(run_manifest, task_manifest)
3701
+ _validate_initial_analysis_prompts(
3702
+ {
3703
+ "taskType": task_type,
3704
+ "projectRoot": project_root,
3705
+ "runManifest": run_manifest,
3706
+ "teamState": team_state,
3707
+ },
3708
+ failures,
3709
+ )
3527
3710
  report_contracts = _normalize_report_contracts(
3528
3711
  run_manifest.get("reportContracts")
3529
3712
  )
@@ -3587,7 +3770,7 @@ def main() -> int:
3587
3770
  run_dir = report_path.parent.parent
3588
3771
  _validate_requirements_discovery_fanout(run_dir, failures)
3589
3772
  # Phase-agnostic: convergence runs in every finding-producing phase.
3590
- _validate_convergence_state_counts(report_path.parent.parent, failures)
3773
+ _validate_convergence_states(report_path.parent.parent, failures)
3591
3774
  validate_report_views(report_path, failures)
3592
3775
 
3593
3776
  validation_status = "passed" if not failures else "failed"
@@ -66,6 +66,13 @@ export const COMMAND_REGISTRY = [
66
66
  category: "admin",
67
67
  summary: ["Move legacy .project-docs/okstra/ to .okstra/ (one-shot)"],
68
68
  },
69
+ {
70
+ name: "convergence",
71
+ module: "./commands/execute/convergence.mjs",
72
+ export: "run",
73
+ category: "admin",
74
+ summary: ["Advance and validate deterministic re-verification state"],
75
+ },
69
76
  {
70
77
  name: "git-reconcile",
71
78
  module: "./commands/execute/git-reconcile.mjs",
@@ -0,0 +1,34 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra convergence — advance deterministic re-verification state
4
+
5
+ Usage:
6
+ okstra convergence seed --groups <path> --work-state <path> \\
7
+ --final-state <path> --migration-dir <path> [--restart-from-round0]
8
+ okstra convergence plan-round --work-state <path> --plan <path>
9
+ okstra convergence apply-round --work-state <path> --plan <path> \\
10
+ --results <path>
11
+ okstra convergence finalize --work-state <path> --output <path>
12
+ okstra convergence validate --state <path> --kind <working|final>
13
+
14
+ This internal admin command is consumed by the Phase 5.5 lead contract. It
15
+ does not install or revive the obsolete okstra-convergence skill.
16
+ `;
17
+
18
+ export async function run(args) {
19
+ if (args.includes("--help") || args.includes("-h")) {
20
+ process.stdout.write(USAGE);
21
+ return 0;
22
+ }
23
+ if (args.length === 0) {
24
+ process.stdout.write(USAGE);
25
+ return 2;
26
+ }
27
+ const result = await runPythonModule({
28
+ module: "okstra_ctl.convergence",
29
+ args,
30
+ stdio: "inherit-stdout",
31
+ });
32
+ return result.code;
33
+ }
34
+
@@ -6,9 +6,11 @@ Usage:
6
6
  okstra log-report [--project-root <dir>] [--cwd <dir>] [--top <N>] [--json]
7
7
 
8
8
  Output: JSON { ok, projectRoot, logsRoot, topLargest[], perTask[], totals }.
9
- Scans <project-root>/.okstra/tasks/**/runs/*/prompts/*.log. Sizes are raw bytes
10
- and mtimes are epoch seconds the caller formats KB/MB and ages. Read-only: it
11
- never deletes logs (cleanup commands stay in the skill).
9
+ Scans <project-root>/.okstra/tasks/**/runs/*/prompts/*.log. Each entry reports
10
+ the wrapper transcript as transcriptBytes and its sibling prompt Markdown as
11
+ promptBytes; sizeBytes remains the transcript-size compatibility field. Sizes
12
+ are raw bytes and mtimes are epoch seconds — the caller formats KB/MB and ages.
13
+ Read-only: it never deletes logs (cleanup commands stay in the skill).
12
14
  `;
13
15
 
14
16
  export async function run(args) {