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
@@ -24,30 +24,23 @@ PROFILE_SECTIONS = (
24
24
  "Primary focus areas",
25
25
  "Expected output emphasis",
26
26
  "Non-goals",
27
- "Clarification request policy",
28
27
  )
29
- PROFILE_SECTIONS_BY_TASK_TYPE = {
28
+ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
30
29
  "requirements-discovery": (
31
- "Fan-out",
32
- "Decision-tree walk",
30
+ "Worker discovery procedure",
33
31
  ),
34
32
  "error-analysis": (
35
- "Diagnosis loop",
33
+ "Worker diagnosis procedure",
36
34
  ),
37
35
  "implementation-planning": (
38
- "Section heading contract",
39
- "Required deliverable shape",
40
- "No-placeholder rule",
41
- "Self-review pass before finalising the report",
36
+ "Worker planning procedure",
37
+ "Design principles applied when scoring options",
42
38
  ),
43
39
  "final-verification": (
44
- "Required deliverable shape",
45
- "Self-review pass before finalising the report",
40
+ "Worker verification procedure",
46
41
  ),
47
42
  "improvement-discovery": (
48
- "Phase 1.5 — Lead reflect-back grilling",
49
- "Worker diversity rule",
50
- "Decision-tree walk",
43
+ "Worker candidate procedure",
51
44
  ),
52
45
  }
53
46
  CLARIFICATION_SECTIONS = (
@@ -81,6 +74,7 @@ def build_analysis_packet(
81
74
  parts.extend(
82
75
  _intro_block(
83
76
  task_key,
77
+ task_type,
84
78
  instruction_set_relative_path,
85
79
  bool(clarification_response_path),
86
80
  )
@@ -113,6 +107,7 @@ def _packet_frontmatter(brief_text: str, task_key: str) -> str:
113
107
 
114
108
  def _intro_block(
115
109
  task_key: str,
110
+ task_type: str,
116
111
  instruction_set: str,
117
112
  has_clarification: bool,
118
113
  ) -> list[str]:
@@ -132,6 +127,10 @@ def _intro_block(
132
127
  f"- Analysis material: `{instruction_set}/analysis-material.md`",
133
128
  f"- Reference expectations: `{instruction_set}/reference-expectations.md`",
134
129
  ]
130
+ if task_type == "final-verification":
131
+ lines.append(
132
+ f"- Verification target: `{instruction_set}/verification-target.md`"
133
+ )
135
134
  if has_clarification:
136
135
  lines.append(
137
136
  f"- Clarification response: `{instruction_set}/clarification-response.md`"
@@ -151,7 +150,7 @@ def _brief_block(brief_text: str) -> list[str]:
151
150
  def _profile_block(task_type: str, profile_text: str) -> list[str]:
152
151
  section_names = (
153
152
  PROFILE_SECTIONS
154
- + PROFILE_SECTIONS_BY_TASK_TYPE.get(task_type, ())
153
+ + WORKER_PROFILE_SECTIONS_BY_TASK_TYPE.get(task_type, ())
155
154
  )
156
155
  profile_sections = _extract_sections(profile_text, section_names)
157
156
  profile_bullets = _extract_bullet_sections(profile_text, section_names)
@@ -27,6 +27,18 @@ from .path_hints import hydrate_active_run_context
27
27
  from .wrapper_status import status_path_for_prompt
28
28
  from .paths import task_dir
29
29
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
30
+ from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
31
+ from .worker_prompt_body import (
32
+ analysis_input_lines as _analysis_input_lines,
33
+ analysis_prompt_body as _analysis_prompt_body,
34
+ existing_input_lines as _existing_input_lines,
35
+ instruction_path as _instruction_path,
36
+ mcp_pointer_line as _mcp_pointer_line,
37
+ )
38
+ from .worker_prompt_policy import (
39
+ PromptPlan,
40
+ resolve_prompt_plan_for_manifest,
41
+ )
30
42
 
31
43
 
32
44
  SUPPORTED_CLI_WORKERS = {
@@ -38,10 +50,6 @@ BACKEND_MIXED = "mixed"
38
50
  MAX_WORKER_ATTEMPTS = 2
39
51
  REPORT_WRITER_WORKER_ID = "report-writer"
40
52
  REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
41
- ANALYSIS_WORKER_LABELS = {
42
- "codex": "Codex worker",
43
- "antigravity": "Antigravity worker",
44
- }
45
53
 
46
54
 
47
55
  class DispatchError(Exception):
@@ -182,6 +190,7 @@ def build_dispatch_plan(
182
190
  )
183
191
  for worker_id in selected_workers
184
192
  )
193
+ _validate_initial_prompts(manifest, workers)
185
194
  return DispatchPlan(
186
195
  project_root=project_root,
187
196
  workspace_root=workspace_root,
@@ -193,6 +202,20 @@ def build_dispatch_plan(
193
202
  )
194
203
 
195
204
 
205
+ def _validate_initial_prompts(
206
+ manifest: Mapping[str, Any],
207
+ workers: Sequence[WorkerDispatch],
208
+ ) -> None:
209
+ records = [
210
+ PromptRecord(worker.worker_id, "initial", worker.prompt_path)
211
+ for worker in workers
212
+ ]
213
+ errors = validate_initial_prompt_records(manifest=manifest, records=records)
214
+ if errors:
215
+ task_type = _require_string(manifest, "taskType")
216
+ raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
217
+
218
+
196
219
  def dispatch_plan(plan: DispatchPlan) -> int:
197
220
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
198
221
  for worker in plan.workers:
@@ -441,6 +464,7 @@ def _render_missing_analysis_worker_prompt(
441
464
  prompt_rel = _worker_prompt_path(manifest, worker_id)
442
465
  model = _require_string(worker_state, "modelExecutionValue")
443
466
  role = _analysis_pane_role(manifest, active_context, worker_id)
467
+ plan = _initial_prompt_plan(manifest, worker_id)
444
468
  lines = _base_prompt_headers(
445
469
  project_root=project_root,
446
470
  manifest=manifest,
@@ -450,7 +474,16 @@ def _render_missing_analysis_worker_prompt(
450
474
  result_rel=result_rel,
451
475
  )
452
476
  lines.extend(_worktree_headers(manifest, active_context, role))
453
- lines.extend(_analysis_prompt_body(manifest, active_context, worker_id, model, role))
477
+ lines.extend(
478
+ _analysis_prompt_body(
479
+ manifest,
480
+ active_context,
481
+ worker_id,
482
+ model,
483
+ role,
484
+ plan,
485
+ )
486
+ )
454
487
  executor_tail = _implementation_executor_tail(
455
488
  manifest,
456
489
  active_context,
@@ -595,43 +628,6 @@ def _build_report_writer_dispatch(
595
628
  )
596
629
 
597
630
 
598
- def _analysis_prompt_body(
599
- manifest: Mapping[str, Any],
600
- active_context: Mapping[str, Any],
601
- worker_id: str,
602
- model: str,
603
- role: str,
604
- ) -> list[str]:
605
- label = ANALYSIS_WORKER_LABELS[worker_id]
606
- return [
607
- f"**Model:** {label}, {model}",
608
- f"**Pane role:** {role}",
609
- "",
610
- f"# {label} Dispatch",
611
- "",
612
- "## Role",
613
- (
614
- f"You are the {label} for okstra cross-verification. "
615
- "Produce an independent worker result."
616
- ),
617
- "",
618
- "## Task",
619
- f"- Task key: `{_require_string(manifest, 'taskKey')}`",
620
- f"- Task type: `{_require_string(manifest, 'taskType')}`",
621
- "",
622
- "## Inputs",
623
- *_analysis_input_lines(manifest, active_context),
624
- "",
625
- _mcp_pointer_line(),
626
- "",
627
- "## Output Contract",
628
- "- Read the Worker Preamble Path end-to-end before analysis.",
629
- "- Write the worker result to Result Path and no other canonical result path.",
630
- "- Write the audit sidecar and any tool-failure entries as the preamble requires.",
631
- "- Cite evidence with file paths and line numbers whenever you make a claim.",
632
- ]
633
-
634
-
635
631
  def _report_writer_prompt_body(
636
632
  manifest: Mapping[str, Any],
637
633
  active_context: Mapping[str, Any],
@@ -669,13 +665,6 @@ def _report_writer_prompt_body(
669
665
  ]
670
666
 
671
667
 
672
- def _mcp_pointer_line() -> str:
673
- return (
674
- '**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
675
- "section (already in your Required reading)."
676
- )
677
-
678
-
679
668
  def _base_prompt_headers(
680
669
  *,
681
670
  project_root: Path,
@@ -691,6 +680,7 @@ def _base_prompt_headers(
691
680
  prompt_rel=prompt_rel,
692
681
  result_rel=result_rel,
693
682
  worker_id=worker_id,
683
+ dispatch_kind="initial",
694
684
  manifest=manifest,
695
685
  active_context=active_context,
696
686
  )
@@ -698,19 +688,18 @@ def _base_prompt_headers(
698
688
  raise DispatchError(str(exc)) from exc
699
689
 
700
690
 
701
- def _analysis_input_lines(
691
+ def _initial_prompt_plan(
702
692
  manifest: Mapping[str, Any],
703
- active_context: Mapping[str, Any],
704
- ) -> list[str]:
705
- inputs = [
706
- ("Primary analysis packet", _instruction_path(manifest, active_context, "analysisPacketPath")),
707
- ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
708
- ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
709
- ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
710
- ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
711
- ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
712
- ]
713
- return _existing_input_lines(inputs)
693
+ worker_id: str,
694
+ ) -> PromptPlan:
695
+ try:
696
+ return resolve_prompt_plan_for_manifest(
697
+ manifest=manifest,
698
+ worker_id=worker_id,
699
+ dispatch_kind="initial",
700
+ )
701
+ except ValueError as exc:
702
+ raise DispatchError(str(exc)) from exc
714
703
 
715
704
 
716
705
  def _report_writer_input_lines(
@@ -733,11 +722,6 @@ def _report_writer_input_lines(
733
722
  return lines or ["- No report-writer inputs were recorded in active-run-context."]
734
723
 
735
724
 
736
- def _existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
737
- lines = [f"- {label}: `{path}`" for label, path in inputs if path]
738
- return lines or ["- No input paths were recorded in active-run-context."]
739
-
740
-
741
725
  def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
742
726
  lines = []
743
727
  workers = team_state.get("workers")
@@ -755,19 +739,6 @@ def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
755
739
  return lines
756
740
 
757
741
 
758
- def _instruction_path(
759
- manifest: Mapping[str, Any],
760
- active_context: Mapping[str, Any],
761
- key: str,
762
- ) -> str:
763
- instruction_set = active_context.get("instructionSet")
764
- if isinstance(instruction_set, Mapping):
765
- value = _string_value(instruction_set.get(key))
766
- if value:
767
- return value
768
- return _string_value(manifest.get(key))
769
-
770
-
771
742
  def _analysis_pane_role(
772
743
  manifest: Mapping[str, Any],
773
744
  active_context: Mapping[str, Any],
@@ -800,7 +771,7 @@ def _worktree_headers(
800
771
  ) -> list[str]:
801
772
  task_type = _require_string(manifest, "taskType")
802
773
  worktree = _worktree_path(manifest, active_context)
803
- if task_type not in {"implementation", "final-verification"} or not worktree:
774
+ if task_type != "implementation" or not worktree:
804
775
  return []
805
776
  headers = [f"**Worktree:** {worktree}"]
806
777
  if role == "executor":
@@ -125,6 +125,57 @@ def _installed_or_dev(installed: Path, dev_relative: str) -> Path:
125
125
  return Path(__file__).resolve().parents[2] / dev_relative
126
126
 
127
127
 
128
+ def _runtime_template(filename: str) -> Path:
129
+ return _installed_or_dev(
130
+ Path.home() / ".okstra" / "templates" / filename,
131
+ f"templates/{filename}",
132
+ )
133
+
134
+
135
+ def _header_path(prompt_path: Path | None, header: str) -> Path | None:
136
+ if prompt_path is None or not prompt_path.is_file():
137
+ return None
138
+ prefix = f"**{header}:**"
139
+ for line in prompt_path.read_text(encoding="utf-8", errors="replace").splitlines():
140
+ stripped = line.strip()
141
+ if stripped.startswith(prefix):
142
+ value = stripped[len(prefix):].strip().strip("`")
143
+ return Path(value) if value else None
144
+ return None
145
+
146
+
147
+ def _prompt_contract_paths(
148
+ prompt_path: Path | None,
149
+ default_preamble: str,
150
+ ) -> tuple[Path, Path]:
151
+ selected_preamble = _header_path(prompt_path, "Worker Preamble Path")
152
+ selected_error = _header_path(prompt_path, "Worker Error Contract Path")
153
+ preamble = (
154
+ selected_preamble
155
+ if selected_preamble and selected_preamble.is_file()
156
+ else _runtime_template(
157
+ selected_preamble.name if selected_preamble else default_preamble
158
+ )
159
+ )
160
+ error_contract = (
161
+ selected_error
162
+ if selected_error and selected_error.is_file()
163
+ else _runtime_template("worker-error-contract.md")
164
+ )
165
+ return preamble, error_contract
166
+
167
+
168
+ def _initial_prompt(run_dir: Path | None, *, report_writer: bool) -> Path | None:
169
+ if run_dir is None:
170
+ return None
171
+ candidates = sorted((run_dir / "prompts").glob("*.md"))
172
+ for path in candidates:
173
+ is_report_writer = "report-writer" in path.name
174
+ if is_report_writer == report_writer and "-reverify-r" not in path.name:
175
+ return path
176
+ return None
177
+
178
+
128
179
  def _instruction_set_metric(task_root: Path, project_root: Path) -> dict:
129
180
  instruction_set = task_root / "instruction-set"
130
181
  files = _all_files(instruction_set)
@@ -233,21 +284,31 @@ def _lead_phase1_metric(
233
284
  }
234
285
 
235
286
 
236
- def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
287
+ def _analysis_worker_metric(
288
+ task_root: Path,
289
+ project_root: Path,
290
+ run_dir: Path | None,
291
+ task_type: str,
292
+ ) -> dict:
237
293
  instruction_set = task_root / "instruction-set"
238
294
  full_contract_files = [instruction_set / name for name in INPUT_FILES]
239
- preamble = _installed_or_dev(
240
- Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md",
241
- "templates/worker-prompt-preamble.md",
295
+ default_preamble = (
296
+ "implementation-worker-preamble.md"
297
+ if task_type == "implementation"
298
+ else "worker-prompt-preamble.md"
242
299
  )
243
- full_contract_files.append(preamble)
300
+ preamble, error_contract = _prompt_contract_paths(
301
+ _initial_prompt(run_dir, report_writer=False),
302
+ default_preamble,
303
+ )
304
+ full_contract_files.extend((preamble, error_contract))
244
305
  full_file_count, full_byte_count = _count_files(full_contract_files)
245
306
 
246
307
  packet = instruction_set / "analysis-packet.md"
247
308
  legacy_packet = instruction_set / "task-packet.md"
248
309
  primary_packet = packet if packet.is_file() else legacy_packet
249
310
  has_packet = primary_packet.is_file()
250
- packet_files = [primary_packet, preamble] if has_packet else []
311
+ packet_files = [primary_packet, preamble, error_contract] if has_packet else []
251
312
  packet_file_count, packet_byte_count = _count_files(packet_files)
252
313
  mode = "analysis-packet-primary" if packet.is_file() else "full-input-contract"
253
314
  byte_count = packet_byte_count if packet.is_file() else full_byte_count
@@ -266,6 +327,8 @@ def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
266
327
  "legacyFullContractFileCount": full_file_count,
267
328
  "estimatedPacketModeBytesPerWorker": packet_byte_count,
268
329
  "estimatedReductionPercent": reduction_percent,
330
+ "promptPreamblePath": str(preamble),
331
+ "workerErrorContractPath": str(error_contract),
269
332
  "files": [project_rel(path, project_root) for path in current_files if path.is_file()],
270
333
  "legacyFullContractFiles": [
271
334
  project_rel(path, project_root)
@@ -313,6 +376,11 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
313
376
  instruction_set / "final-report-template.md",
314
377
  instruction_set / "final-report-schema.json",
315
378
  ])
379
+ preamble, error_contract = _prompt_contract_paths(
380
+ _initial_prompt(run_dir, report_writer=True),
381
+ "report-writer-prompt-preamble.md",
382
+ )
383
+ files.extend((preamble, error_contract))
316
384
  if run_dir is not None:
317
385
  files.extend(_current_seq_worker_results(run_dir))
318
386
  convergence = _latest_matching_file(
@@ -326,6 +394,8 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
326
394
  "fileCount": file_count,
327
395
  "bytes": byte_count,
328
396
  "estimatedTokens": _estimate_tokens(files),
397
+ "promptPreamblePath": str(preamble),
398
+ "workerErrorContractPath": str(error_contract),
329
399
  "files": [project_rel(path, project_root) for path in files if path.is_file()],
330
400
  }
331
401
 
@@ -357,7 +427,12 @@ def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
357
427
  },
358
428
  "instructionSet": _instruction_set_metric(task_root, project_root),
359
429
  "leadPhase1": _lead_phase1_metric(task_root, run_dir, manifest, project_root),
360
- "analysisWorker": _analysis_worker_metric(task_root, project_root),
430
+ "analysisWorker": _analysis_worker_metric(
431
+ task_root,
432
+ project_root,
433
+ run_dir,
434
+ str(manifest.get("taskType") or ""),
435
+ ),
361
436
  "reportWriter": _report_writer_metric(run_dir, task_root, project_root),
362
437
  "skillAssets": _skill_assets_metric(),
363
438
  }
@@ -0,0 +1,223 @@
1
+ """CLI orchestration for deterministic Phase 5.5 convergence state."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ from typing import Any
9
+
10
+ from .convergence_engine import (
11
+ ConvergenceContractError,
12
+ apply_round_results,
13
+ finalize_working_state,
14
+ plan_next_round,
15
+ seed_working_state,
16
+ validate_final_state,
17
+ validate_working_state,
18
+ )
19
+ from .convergence_migration import (
20
+ SeedDecision,
21
+ archive_state_bytes,
22
+ decide_seed_action,
23
+ migration_record,
24
+ )
25
+ from .convergence_store import (
26
+ load_json_object,
27
+ write_final_state_atomic,
28
+ write_json_atomic,
29
+ )
30
+
31
+
32
+ def _parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="okstra convergence",
35
+ description="Advance and validate deterministic re-verification state.",
36
+ )
37
+ subparsers = parser.add_subparsers(dest="operation", required=True)
38
+
39
+ seed = subparsers.add_parser("seed", help="create, resume, or recover working state")
40
+ seed.add_argument("--groups", type=Path, required=True)
41
+ seed.add_argument("--work-state", type=Path, required=True)
42
+ seed.add_argument("--final-state", type=Path, required=True)
43
+ seed.add_argument("--migration-dir", type=Path, required=True)
44
+ seed.add_argument("--restart-from-round0", action="store_true")
45
+
46
+ plan = subparsers.add_parser("plan-round", help="write the next dispatch plan")
47
+ plan.add_argument("--work-state", type=Path, required=True)
48
+ plan.add_argument("--plan", type=Path, required=True)
49
+
50
+ apply = subparsers.add_parser("apply-round", help="apply structured round results")
51
+ apply.add_argument("--work-state", type=Path, required=True)
52
+ apply.add_argument("--plan", type=Path, required=True)
53
+ apply.add_argument("--results", type=Path, required=True)
54
+
55
+ finalize = subparsers.add_parser("finalize", help="write the public final state")
56
+ finalize.add_argument("--work-state", type=Path, required=True)
57
+ finalize.add_argument("--output", type=Path, required=True)
58
+
59
+ validate = subparsers.add_parser("validate", help="validate a persisted state")
60
+ validate.add_argument("--state", type=Path, required=True)
61
+ validate.add_argument("--kind", choices=("working", "final"), required=True)
62
+ return parser
63
+
64
+
65
+ def _emit(operation: str, action: str, path: Path) -> None:
66
+ print(
67
+ json.dumps(
68
+ {
69
+ "ok": True,
70
+ "operation": operation,
71
+ "action": action,
72
+ "path": str(path),
73
+ },
74
+ ensure_ascii=False,
75
+ )
76
+ )
77
+
78
+
79
+ def _archive_existing_states(
80
+ *,
81
+ work_state_path: Path,
82
+ final_state_path: Path,
83
+ migration_dir: Path,
84
+ ) -> dict[str, str] | None:
85
+ records: list[dict[str, str]] = []
86
+ for path in (work_state_path, final_state_path):
87
+ if not path.exists():
88
+ continue
89
+ archive_path, digest = archive_state_bytes(
90
+ source_path=path,
91
+ migration_dir=migration_dir,
92
+ )
93
+ records.append(migration_record(path, archive_path, digest))
94
+ if not records:
95
+ return None
96
+ final_record = next(
97
+ (record for record in records if record["sourcePath"] == str(final_state_path)),
98
+ None,
99
+ )
100
+ return final_record or records[0]
101
+
102
+
103
+ def _resume_after_legacy_final(
104
+ decision: SeedDecision,
105
+ *,
106
+ work_state_path: Path,
107
+ final_state_path: Path,
108
+ migration_dir: Path,
109
+ ) -> None:
110
+ if decision.archive_path is None:
111
+ return
112
+ archive_path, digest = archive_state_bytes(
113
+ source_path=final_state_path,
114
+ migration_dir=migration_dir,
115
+ )
116
+ state = load_json_object(work_state_path)
117
+ state["migration"] = migration_record(final_state_path, archive_path, digest)
118
+ write_json_atomic(work_state_path, state)
119
+
120
+
121
+ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
122
+ grouped_input = load_json_object(args.groups)
123
+ decision = decide_seed_action(
124
+ grouped_input=grouped_input,
125
+ work_state_path=args.work_state,
126
+ final_state_path=args.final_state,
127
+ restart_from_round0=args.restart_from_round0,
128
+ )
129
+ if decision.action == "reuse-final":
130
+ return decision.action, args.final_state
131
+ if decision.action == "resume-work":
132
+ _resume_after_legacy_final(
133
+ decision,
134
+ work_state_path=args.work_state,
135
+ final_state_path=args.final_state,
136
+ migration_dir=args.migration_dir,
137
+ )
138
+ return decision.action, args.work_state
139
+
140
+ state = seed_working_state(grouped_input)
141
+ if decision.action == "restart-round0":
142
+ migration = _archive_existing_states(
143
+ work_state_path=args.work_state,
144
+ final_state_path=args.final_state,
145
+ migration_dir=args.migration_dir,
146
+ )
147
+ if migration is None:
148
+ raise ConvergenceContractError("restart-round0 has no state to archive")
149
+ state["migration"] = migration
150
+ write_json_atomic(args.work_state, state)
151
+ return decision.action, args.work_state
152
+
153
+
154
+ def _plan_round(args: argparse.Namespace) -> tuple[str, Path]:
155
+ state = load_json_object(args.work_state)
156
+ errors = validate_working_state(state)
157
+ if errors:
158
+ raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
159
+ plan = plan_next_round(state)
160
+ write_json_atomic(args.plan, plan)
161
+ return str(plan["action"]), args.plan
162
+
163
+
164
+ def _apply_round(args: argparse.Namespace) -> tuple[str, Path]:
165
+ state = load_json_object(args.work_state)
166
+ plan = load_json_object(args.plan)
167
+ results = load_json_object(args.results)
168
+ updated = apply_round_results(state, plan, results)
169
+ errors = validate_working_state(updated)
170
+ if errors:
171
+ raise ConvergenceContractError("invalid updated working state: " + "; ".join(errors))
172
+ write_json_atomic(args.work_state, updated)
173
+ return "applied", args.work_state
174
+
175
+
176
+ def _finalize(args: argparse.Namespace) -> tuple[str, Path]:
177
+ state = load_json_object(args.work_state)
178
+ final = finalize_working_state(state)
179
+ migration = state.get("migration")
180
+ write_final_state_atomic(args.output, final, migration=migration)
181
+ return "finalized", args.output
182
+
183
+
184
+ def _validate(args: argparse.Namespace) -> tuple[str, Path]:
185
+ state = load_json_object(args.state)
186
+ errors = (
187
+ validate_working_state(state)
188
+ if args.kind == "working"
189
+ else validate_final_state(state)
190
+ )
191
+ if errors:
192
+ raise ConvergenceContractError("; ".join(errors))
193
+ return "valid", args.state
194
+
195
+
196
+ def _execute(args: argparse.Namespace) -> tuple[str, Path]:
197
+ operations: dict[str, Any] = {
198
+ "seed": _seed,
199
+ "plan-round": _plan_round,
200
+ "apply-round": _apply_round,
201
+ "finalize": _finalize,
202
+ "validate": _validate,
203
+ }
204
+ return operations[args.operation](args)
205
+
206
+
207
+ def main(argv: list[str] | None = None) -> int:
208
+ args = _parser().parse_args(argv)
209
+ try:
210
+ action, path = _execute(args)
211
+ except (ConvergenceContractError, json.JSONDecodeError, ValueError) as exc:
212
+ print(f"error: {exc}", file=sys.stderr)
213
+ return 2
214
+ except OSError as exc:
215
+ print(f"error: {exc}", file=sys.stderr)
216
+ return 1
217
+ _emit(args.operation, action, path)
218
+ return 0
219
+
220
+
221
+ if __name__ == "__main__":
222
+ raise SystemExit(main())
223
+