okstra 0.207.1 → 0.209.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 (133) hide show
  1. package/README.md +3 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/render-bundle.mjs +1 -1
  5. package/dist/commands/lifecycle/doctor.mjs +1 -1
  6. package/dist/lib/skill-catalog.mjs +1 -0
  7. package/dist/lib/skill-catalog.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +14 -0
  9. package/docs/architecture.md +30 -9
  10. package/docs/cli.md +26 -22
  11. package/docs/contributor-change-matrix.md +1 -1
  12. package/docs/project-structure-overview.md +15 -8
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/operations/explain-flow.json +6 -0
  16. package/runtime/bin/lib/okstra/cli.sh +1 -5
  17. package/runtime/bin/lib/okstra/globals.sh +0 -2
  18. package/runtime/bin/lib/okstra/usage.sh +5 -3
  19. package/runtime/bin/okstra.sh +0 -2
  20. package/runtime/prompts/duties/business-flow-investigator.json +14 -0
  21. package/runtime/prompts/lead/context-loader.md +1 -1
  22. package/runtime/prompts/lead/convergence.md +22 -7
  23. package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
  24. package/runtime/prompts/lead/report-writer.md +1 -1
  25. package/runtime/prompts/lead/team-contract.md +12 -17
  26. package/runtime/prompts/profiles/_common-contract.md +3 -3
  27. package/runtime/prompts/wizard/prompts.ko.json +0 -91
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +6 -0
  29. package/runtime/python/okstra_ctl/agent/invocation.py +1 -1
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +1 -0
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +1 -0
  32. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +11 -125
  33. package/runtime/python/okstra_ctl/agent/standalone.py +183 -0
  34. package/runtime/python/okstra_ctl/analysis_packet.py +39 -8
  35. package/runtime/python/okstra_ctl/assignment_resolver.py +7 -1
  36. package/runtime/python/okstra_ctl/brief_frontmatter.py +10 -0
  37. package/runtime/python/okstra_ctl/business_flow/__init__.py +4 -0
  38. package/runtime/python/okstra_ctl/business_flow/cli.py +134 -0
  39. package/runtime/python/okstra_ctl/business_flow/contracts.py +268 -0
  40. package/runtime/python/okstra_ctl/business_flow/engine.py +518 -0
  41. package/runtime/python/okstra_ctl/business_flow/hooks.py +221 -0
  42. package/runtime/python/okstra_ctl/business_flow/invocation.py +170 -0
  43. package/runtime/python/okstra_ctl/business_flow/report.py +49 -0
  44. package/runtime/python/okstra_ctl/business_flow/source.py +206 -0
  45. package/runtime/python/okstra_ctl/business_flow/store.py +388 -0
  46. package/runtime/python/okstra_ctl/cmux.py +21 -16
  47. package/runtime/python/okstra_ctl/convergence.py +173 -2
  48. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
  49. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
  50. package/runtime/python/okstra_ctl/coverage_census.py +603 -0
  51. package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
  52. package/runtime/python/okstra_ctl/direct_work.py +1 -1
  53. package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
  54. package/runtime/python/okstra_ctl/doctor.py +12 -6
  55. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  56. package/runtime/python/okstra_ctl/group_context.py +5 -4
  57. package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
  58. package/runtime/python/okstra_ctl/manager_split.py +4 -1
  59. package/runtime/python/okstra_ctl/model_io/lines.py +1 -24
  60. package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
  61. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
  62. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
  63. package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
  64. package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
  65. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
  66. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
  67. package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
  68. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
  69. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
  70. package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
  71. package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
  72. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
  73. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
  74. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
  75. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
  77. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
  78. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
  79. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
  80. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
  81. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
  82. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
  83. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
  84. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
  85. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
  86. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
  87. package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
  88. package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
  89. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
  90. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  91. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
  92. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +14 -8
  93. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
  94. package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
  95. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
  96. package/runtime/python/okstra_ctl/plan_items.py +1 -1
  97. package/runtime/python/okstra_ctl/render.py +10 -43
  98. package/runtime/python/okstra_ctl/render_final_report.py +3 -0
  99. package/runtime/python/okstra_ctl/report_assembly.py +15 -1
  100. package/runtime/python/okstra_ctl/report_finalize.py +50 -0
  101. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  102. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
  103. package/runtime/python/okstra_ctl/run.py +78 -409
  104. package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
  105. package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
  106. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
  107. package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
  108. package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
  109. package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
  110. package/runtime/python/okstra_ctl/wizard/render.py +2 -2
  111. package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
  112. package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
  113. package/runtime/python/okstra_ctl/wizard/state.py +36 -145
  114. package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
  115. package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
  116. package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
  117. package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
  118. package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
  119. package/runtime/schemas/business-flow-v1.schema.json +847 -0
  120. package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
  121. package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
  122. package/runtime/skills/okstra-inspect/facets/history.md +5 -5
  123. package/runtime/skills/okstra-run/SKILL.md +2 -2
  124. package/runtime/templates/reports/business-flow.template.md +106 -0
  125. package/runtime/templates/reports/html/base.template.html +14 -1
  126. package/runtime/templates/reports/html/business-flow.template.html +31 -0
  127. package/runtime/templates/reports/html/i18n/en.json +1 -0
  128. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  129. package/runtime/templates/worker-prompt-preamble.md +11 -2
  130. package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
  131. package/runtime/validators/validate-run.py +70 -21
  132. package/runtime/validators/validate_analysis_report.py +21 -21
  133. package/runtime/python/okstra_ctl/workers.py +0 -133
@@ -1,4 +1,4 @@
1
- """위저드 상태 — `WizardState`·`Prompt`·`Option`·`Step` 데이터 타입, v1 provider 선택의 v2 변환, 상태 술어."""
1
+ """위저드 상태 — `WizardState`·`Prompt`·`Option`·`Step` 데이터 타입, 직전 run 입력의 역할 선택 변환, 상태 술어."""
2
2
  from __future__ import annotations
3
3
 
4
4
  from dataclasses import asdict, dataclass, field
@@ -30,8 +30,7 @@ from .ids import (
30
30
 
31
31
  @dataclass
32
32
  class WizardState:
33
- # execution identity v2: role-first canonical model selections
34
- execution_identity_version: int = 1
33
+ # role-first canonical model selections
35
34
  role_counts: dict[str, int] = field(default_factory=dict)
36
35
  role_models: dict[str, list[str]] = field(default_factory=dict)
37
36
  role_selection_order: list[str] = field(default_factory=list)
@@ -61,8 +60,6 @@ class WizardState:
61
60
 
62
61
  # task-type + dependents
63
62
  task_type: str = ""
64
- profile_workers: list[str] = field(default_factory=list)
65
- profile_optional_workers: list[str] = field(default_factory=list)
66
63
 
67
64
  # brief
68
65
  keep_existing_brief: Optional[bool] = None
@@ -100,9 +97,6 @@ class WizardState:
100
97
  design_prep_notes: str = ""
101
98
  selected_stage: str = "auto"
102
99
  selected_stages: str = "" # implementation 다중선택 위상정렬 CSV
103
- executor: str = ""
104
- critic: str = ""
105
- critic_pending_text: bool = False
106
100
 
107
101
  # release-handoff: PR 을 열 stage 들. stage 하나가 PR 하나다.
108
102
  handoff_stages: str = "" # csv ("2,3")
@@ -111,18 +105,6 @@ class WizardState:
111
105
  reuse_previous: Optional[bool] = None
112
106
 
113
107
  # customize
114
- use_defaults: Optional[bool] = None
115
- workers_override: str = ""
116
- workers_custom_pending: bool = False
117
- lead_provider: str = ""
118
- lead_model: str = ""
119
- claude_model: str = ""
120
- codex_model: str = ""
121
- antigravity_model: str = ""
122
- grok_model: str = ""
123
- kimi_model: str = ""
124
- report_writer_provider: str = ""
125
- report_writer_model: str = ""
126
108
  directive: str = ""
127
109
  directive_pending_text: bool = False
128
110
  last_directive_cached: str = ""
@@ -159,13 +141,10 @@ class WizardState:
159
141
 
160
142
  def to_json(self) -> dict[str, Any]:
161
143
  payload = asdict(self)
162
- payload.pop("execution_identity_version")
163
- payload["executionIdentityVersion"] = 2
144
+ payload["executionIdentityVersion"] = WIZARD_STATE_VERSION
164
145
  payload["roleCounts"] = payload.pop("role_counts")
165
146
  payload["roleModels"] = payload.pop("role_models")
166
147
  payload["roleSelectionOrder"] = payload.pop("role_selection_order")
167
- for field_name in _V1_PROVIDER_STATE_FIELDS:
168
- payload.pop(field_name, None)
169
148
  return payload
170
149
 
171
150
  @classmethod
@@ -314,6 +293,9 @@ def _profile_path(workspace_root: Path, task_type: str) -> Path:
314
293
  raise WizardError(str(exc)) from exc
315
294
 
316
295
 
296
+ WIZARD_STATE_VERSION = 2
297
+
298
+
317
299
  _V2_STATE_FIELDS = (
318
300
  "roleCounts",
319
301
  "roleModels",
@@ -321,37 +303,6 @@ _V2_STATE_FIELDS = (
321
303
  )
322
304
 
323
305
 
324
- _V1_PROVIDER_STATE_FIELDS = (
325
- "workers_override",
326
- "lead_provider",
327
- "lead_model",
328
- "claude_model",
329
- "codex_model",
330
- "antigravity_model",
331
- "grok_model",
332
- "kimi_model",
333
- "report_writer_provider",
334
- "report_writer_model",
335
- "executor",
336
- "critic",
337
- )
338
-
339
-
340
- _V1_PROVIDER_STATE_JSON_FIELDS = frozenset({
341
- *_V1_PROVIDER_STATE_FIELDS,
342
- "workers",
343
- "leadProvider",
344
- "leadModel",
345
- "claudeModel",
346
- "codexModel",
347
- "antigravityModel",
348
- "grokModel",
349
- "kimiModel",
350
- "reportWriterProvider",
351
- "reportWriterModel",
352
- })
353
-
354
-
355
306
  def _load_role_profile_for_state(state: WizardState) -> RoleProfile:
356
307
  try:
357
308
  return load_role_profile(
@@ -361,22 +312,12 @@ def _load_role_profile_for_state(state: WizardState) -> RoleProfile:
361
312
  raise WizardError(str(exc)) from exc
362
313
 
363
314
 
364
- def _role_selection_enabled(state: WizardState) -> bool:
365
- if (
366
- state.execution_identity_version != 2
367
- or not state.workspace_root
368
- or not state.task_type
369
- ):
370
- return False
371
- try:
372
- load_role_profile(_profile_path(Path(state.workspace_root), state.task_type))
373
- except (OSError, RoleProfileError, WizardError):
374
- return False
375
- return True
376
-
315
+ def _role_selection_ready(state: WizardState) -> bool:
316
+ """역할 화면을 만들 수 있을 만큼 task 정체성이 정해졌는가.
377
317
 
378
- def _legacy_value(payload: dict[str, Any], snake: str, camel: str) -> Any:
379
- return payload.get(snake, payload.get(camel, ""))
318
+ 프로필 로드 실패는 여기서 삼키지 않는다 — 로드하는 쪽이 WizardError 로 낸다.
319
+ """
320
+ return bool(state.workspace_root and state.task_type)
380
321
 
381
322
 
382
323
  def _legacy_model_ref(
@@ -404,7 +345,7 @@ def _legacy_model_ref(
404
345
 
405
346
 
406
347
  def _legacy_worker_providers(payload: dict[str, Any]) -> list[str]:
407
- raw = _legacy_value(payload, "workers_override", "workers")
348
+ raw = payload.get("workers", "")
408
349
  if isinstance(raw, list):
409
350
  values = raw
410
351
  elif isinstance(raw, str):
@@ -436,12 +377,11 @@ def _initial_cross_verification_requirement(
436
377
  ), None)
437
378
 
438
379
 
439
- def _convert_v1_provider_selections(
380
+ def _role_selections_from_run_inputs(
440
381
  state: WizardState,
441
382
  payload: dict[str, Any],
442
383
  ) -> None:
443
- if not _role_selection_enabled(state):
444
- return
384
+ """직전 run-inputs 의 provider·모델 기록을 역할별 모델 선택으로 옮긴다."""
445
385
  profile = _load_role_profile_for_state(state)
446
386
  pool = ModelPool.from_registry(default_provider_registry())
447
387
  cross_requirement = _initial_cross_verification_requirement(profile)
@@ -461,11 +401,7 @@ def _convert_v1_provider_selections(
461
401
  for model in pool.default_candidates(cross_requirement.role)
462
402
  ]
463
403
  for ordinal, provider in enumerate(providers[:selected_count], start=1):
464
- raw_model = _legacy_value(
465
- payload,
466
- f"{provider}_model",
467
- f"{provider}Model",
468
- )
404
+ raw_model = payload.get(f"{provider}Model", "")
469
405
  model_ref = _legacy_model_ref(
470
406
  pool,
471
407
  provider,
@@ -479,43 +415,17 @@ def _convert_v1_provider_selections(
479
415
  # 여러 인스턴스 역할의 답은 체크박스 한 장이다.
480
416
  state.role_selection_order.append(f"role-models:{cross_requirement.role}")
481
417
 
418
+ executor = payload.get("executor") or "claude"
419
+ critic = payload.get("critic") or ""
420
+ # 리더는 호스트가 정하므로 옮기지 않는다(`_discard_implicit_leader_selection`).
482
421
  provider_rows = (
483
- (
484
- "leader",
485
- _legacy_value(payload, "lead_provider", "leadProvider") or "claude",
486
- _legacy_value(payload, "lead_model", "leadModel"),
487
- ),
488
422
  (
489
423
  "report-writer",
490
- _legacy_value(
491
- payload,
492
- "report_writer_provider",
493
- "reportWriterProvider",
494
- ) or "claude",
495
- _legacy_value(
496
- payload,
497
- "report_writer_model",
498
- "reportWriterModel",
499
- ),
500
- ),
501
- (
502
- "implementer",
503
- _legacy_value(payload, "executor", "executor") or "claude",
504
- _legacy_value(
505
- payload,
506
- f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}_model",
507
- f"{_legacy_value(payload, 'executor', 'executor') or 'claude'}Model",
508
- ),
509
- ),
510
- (
511
- "critic",
512
- _legacy_value(payload, "critic", "critic"),
513
- _legacy_value(
514
- payload,
515
- f"{_legacy_value(payload, 'critic', 'critic')}_model",
516
- f"{_legacy_value(payload, 'critic', 'critic')}Model",
517
- ),
424
+ payload.get("reportWriterProvider") or "claude",
425
+ payload.get("reportWriterModel", ""),
518
426
  ),
427
+ ("implementer", executor, payload.get(f"{executor}Model", "")),
428
+ ("critic", critic, payload.get(f"{critic}Model", "")),
519
429
  )
520
430
  static_roles = {
521
431
  requirement.role
@@ -532,34 +442,25 @@ def _convert_v1_provider_selections(
532
442
  state.role_selection_order.append(f"role-model:{role}:1")
533
443
 
534
444
 
445
+ class StaleWizardStateError(WizardError):
446
+ """저장된 상태가 현재 형식(`WIZARD_STATE_VERSION`)이 아니다 — 이어갈 수 없다."""
447
+
448
+
535
449
  def _wizard_state_from_json(payload: dict[str, Any]) -> WizardState:
536
450
  if not isinstance(payload, dict):
537
451
  raise WizardError("wizard state must be a JSON object")
538
452
  data = dict(payload)
539
453
  version = data.pop("executionIdentityVersion", None)
540
- has_v2_fields = any(field_name in data for field_name in _V2_STATE_FIELDS)
541
- has_v1_fields = any(
542
- field_name in data for field_name in _V1_PROVIDER_STATE_JSON_FIELDS
543
- )
544
- if version == 2:
545
- if has_v1_fields or not all(field_name in data for field_name in _V2_STATE_FIELDS):
546
- raise WizardError("mixed wizard state versions are not allowed")
547
- data["role_counts"] = data.pop("roleCounts")
548
- data["role_models"] = data.pop("roleModels")
549
- data["role_selection_order"] = data.pop("roleSelectionOrder")
550
- data["execution_identity_version"] = 2
551
- _validate_v2_state_fields(data)
552
- elif version in (None, 1):
553
- if has_v2_fields:
554
- raise WizardError("mixed wizard state versions are not allowed")
555
- data.pop("execution_identity_version", None)
556
- else:
557
- raise WizardError(f"unsupported wizard state version: {version!r}")
454
+ if version != WIZARD_STATE_VERSION:
455
+ raise StaleWizardStateError(f"unsupported wizard state version: {version!r}")
456
+ if not all(field_name in data for field_name in _V2_STATE_FIELDS):
457
+ raise WizardError("wizard state is missing role selection fields")
458
+ data["role_counts"] = data.pop("roleCounts")
459
+ data["role_models"] = data.pop("roleModels")
460
+ data["role_selection_order"] = data.pop("roleSelectionOrder")
461
+ _validate_v2_state_fields(data)
558
462
  allowed = WizardState.__dataclass_fields__.keys()
559
463
  state = WizardState(**{key: value for key, value in data.items() if key in allowed})
560
- if version != 2:
561
- state.execution_identity_version = 2
562
- _convert_v1_provider_selections(state, payload)
563
464
  _discard_implicit_leader_selection(state)
564
465
  return state
565
466
 
@@ -632,7 +533,7 @@ class Step:
632
533
 
633
534
 
634
535
  def _identity_ready(s: WizardState) -> bool:
635
- """All identity questions (task pick → executor for impl) answered."""
536
+ """All identity questions (task pick → stage/handoff picks) answered."""
636
537
  if not s.task_type:
637
538
  return False
638
539
  # release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
@@ -645,9 +546,6 @@ def _identity_ready(s: WizardState) -> bool:
645
546
  if s.task_type in _STAGE_SCOPED_TASK_TYPES:
646
547
  if not s.approved_plan_path:
647
548
  return False
648
- if s.task_type == "implementation" and not _role_selection_enabled(s):
649
- if not s.executor:
650
- return False
651
549
  if (s.task_type == "release-handoff"
652
550
  and S_HANDOFF_STAGE_PICK not in s.answered):
653
551
  return False
@@ -659,7 +557,6 @@ _FIELD_DEFAULTS: dict[str, Any] = {
659
557
  "existing_brief_path": "", "task_type": "",
660
558
  "task_group_suggestion": "", "task_id_suggestion": "",
661
559
  "task_group_pending_text": False, "task_id_pending_text": False,
662
- "profile_workers": [], "profile_optional_workers": [],
663
560
  "keep_existing_brief": None,
664
561
  "brief_path": "", "brief_path_pending_text": False,
665
562
  "project_evidence_path": "", "project_evidence_pending_text": False,
@@ -675,15 +572,9 @@ _FIELD_DEFAULTS: dict[str, Any] = {
675
572
  "selected_stage": "auto",
676
573
  "selected_stages": "",
677
574
  "handoff_stages": "",
678
- "executor": "", "critic": "", "critic_pending_text": False,
679
- "execution_identity_version": 1,
680
575
  "role_counts": {}, "role_models": {}, "role_selection_order": [],
681
576
  "reuse_previous": None,
682
- "use_defaults": None, "workers_override": "",
683
- "workers_custom_pending": False,
684
- "lead_provider": "", "lead_model": "", "claude_model": "", "codex_model": "",
685
- "antigravity_model": "", "grok_model": "", "kimi_model": "",
686
- "report_writer_provider": "", "report_writer_model": "", "directive": "",
577
+ "directive": "",
687
578
  "directive_pending_text": False,
688
579
  "related_tasks_raw": "", "related_tasks_pending_text": False,
689
580
  "clarification_response_path": "", "clarification_pending_text": False,
@@ -1,144 +1,43 @@
1
- """위저드 상태 파일 — `load_state_file`·`save_state_file` 과 v2 companion 파일 탐색."""
1
+ """위저드 상태 파일 — `load_state_file`·`save_state_file`."""
2
2
  from __future__ import annotations
3
3
 
4
- import hashlib
5
4
  from pathlib import Path
6
- from typing import Any
7
5
 
8
6
  from okstra_ctl.convergence_store import write_json_atomic
9
- from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
7
+ from okstra_ctl.json_boundary import load_owned_object
10
8
 
11
- from .state import WizardError, WizardState
9
+ from .state import StaleWizardStateError, WizardState
12
10
 
13
11
 
14
- # ---- File I/O helpers (used by CLI) -------------------------------------
12
+ # 형식이 바뀐 상태 파일에서도 살리는 값 — 위저드를 처음부터 다시 여는 데 필요한 부트스트랩뿐이다.
13
+ _BOOTSTRAP_FIELDS = (
14
+ "workspace_root",
15
+ "project_root",
16
+ "project_id",
17
+ "preferred_task_key",
18
+ "host_runtime",
19
+ "host_entry_mode",
20
+ "available_functions",
21
+ )
15
22
 
16
- _LEGACY_SOURCE_FIELD = "legacySource"
17
23
 
18
-
19
- def _legacy_v2_companion_path(path: Path) -> Path:
20
- return path.with_name(f"{path.name}.v2.json")
21
-
22
-
23
- def _content_addressed_v2_companion_path(
24
- path: Path,
25
- source_digest: str,
26
- suffix: int | None = None,
27
- ) -> Path:
28
- numbered = f".{suffix}" if suffix is not None else ""
29
- return path.with_name(f"{path.name}.v2.{source_digest}{numbered}.json")
30
-
31
-
32
- def _content_addressed_v2_companion_paths(
33
- path: Path,
34
- source_digest: str,
35
- ) -> tuple[Path, ...]:
36
- canonical = _content_addressed_v2_companion_path(path, source_digest)
37
- numbered_prefix = f"{path.name}.v2.{source_digest}."
38
- numbered = tuple(sorted(
39
- candidate
40
- for candidate in path.parent.iterdir()
41
- if candidate.is_file()
42
- and candidate.name.startswith(numbered_prefix)
43
- and candidate.name.endswith(".json")
44
- ))
45
- return (canonical, *numbered)
46
-
47
-
48
- def _available_v2_companion_path(path: Path, source_digest: str) -> Path:
49
- candidate = _content_addressed_v2_companion_path(path, source_digest)
50
- suffix = 1
51
- while candidate.exists():
52
- candidate = _content_addressed_v2_companion_path(
53
- path,
54
- source_digest,
55
- suffix,
56
- )
57
- suffix += 1
58
- return candidate
59
-
60
-
61
- def _legacy_source_metadata(path: Path, source: bytes) -> dict[str, str]:
62
- return {
63
- "path": str(path.resolve()),
64
- "sha256": hashlib.sha256(source).hexdigest(),
65
- }
66
-
67
-
68
- def _matching_v2_companion(
69
- path: Path,
70
- source_metadata: dict[str, str],
71
- ) -> dict[str, Any] | None:
72
- try:
73
- payload = load_owned_object(path, artifact="wizard state")
74
- except (OSError, UnicodeError, JsonBoundaryError):
75
- return None
76
- if not isinstance(payload, dict):
77
- return None
78
- if payload.get("executionIdentityVersion") != 2:
79
- return None
80
- if payload.get(_LEGACY_SOURCE_FIELD) != source_metadata:
81
- return None
82
- return payload
83
-
84
-
85
- def _load_matching_v2_state(
86
- paths: tuple[Path, ...],
87
- source_metadata: dict[str, str],
88
- ) -> tuple[WizardState | None, Path | None]:
89
- for companion_path in paths:
90
- payload = _matching_v2_companion(companion_path, source_metadata)
91
- if payload is None:
92
- continue
93
- try:
94
- return WizardState.from_json(payload), companion_path
95
- except WizardError:
96
- continue
97
- return None, None
24
+ def _fresh_state_from_stale(payload: dict) -> WizardState:
25
+ defaults = WizardState()
26
+ kwargs = {}
27
+ for name in _BOOTSTRAP_FIELDS:
28
+ value = payload.get(name)
29
+ if isinstance(value, type(getattr(defaults, name))):
30
+ kwargs[name] = value
31
+ return WizardState(**kwargs)
98
32
 
99
33
 
100
34
  def load_state_file(path: Path) -> WizardState:
101
- source_path = Path(path)
102
- source = source_path.read_bytes()
103
- data = load_owned_object(source_path, artifact="wizard state")
104
- source_version = data.get("executionIdentityVersion", 1)
105
- source_metadata = _legacy_source_metadata(source_path, source)
106
- source_digest = source_metadata["sha256"]
107
- resumed_state = None
108
- companion_path = None
109
- if source_version in (None, 1):
110
- content_paths = _content_addressed_v2_companion_paths(
111
- source_path,
112
- source_digest,
113
- )
114
- resumed_state, companion_path = _load_matching_v2_state(
115
- content_paths,
116
- source_metadata,
117
- )
118
- if resumed_state is None:
119
- resumed_state, _ = _load_matching_v2_state(
120
- (_legacy_v2_companion_path(source_path),),
121
- source_metadata,
122
- )
123
- state = resumed_state or WizardState.from_json(data)
124
- state._source_execution_identity_version = source_version
125
- if source_version in (None, 1):
126
- state._legacy_source_metadata = source_metadata
127
- state._v2_companion_path = companion_path or _available_v2_companion_path(
128
- source_path,
129
- source_digest,
130
- )
131
- return state
132
-
133
-
134
- def _save_resumed_state_file(path: Path, state: WizardState) -> None:
135
- """Persist native v2 state while keeping legacy resume sources read-only."""
136
- if getattr(state, "_source_execution_identity_version", 2) in (None, 1):
137
- payload = state.to_json()
138
- payload[_LEGACY_SOURCE_FIELD] = state._legacy_source_metadata
139
- write_json_atomic(state._v2_companion_path, payload)
140
- return
141
- save_state_file(path, state)
35
+ data = load_owned_object(Path(path), artifact="wizard state")
36
+ try:
37
+ return WizardState.from_json(data)
38
+ except StaleWizardStateError:
39
+ # 이전 형식의 진행분은 옮기지 않는다 — 같은 프로젝트로 새 세션을 시작한다.
40
+ return _fresh_state_from_stale(data)
142
41
 
143
42
 
144
43
  def save_state_file(path: Path, state: WizardState) -> None:
@@ -63,8 +63,6 @@ from .sources import (
63
63
  _file_recency,
64
64
  _git_main_worktree,
65
65
  _latest_revision_requested_analysis_type,
66
- _load_profile_optional_workers,
67
- _load_profile_workers,
68
66
  _parse_iso_timestamp,
69
67
  _phase_after,
70
68
  _project_relative_path,
@@ -671,12 +669,6 @@ def _apply_task_type(state: WizardState, value: str) -> str:
671
669
  # 퍼지해야 downstream 재선택 시 carry 단계가 다시 나온다.
672
670
  state.answered = [a for a in state.answered if a != S_BRIEF_CARRY]
673
671
  carried = _carry_in_existing_brief(state)
674
- state.profile_workers = _load_profile_workers(
675
- Path(state.workspace_root), value
676
- )
677
- state.profile_optional_workers = _load_profile_optional_workers(
678
- Path(state.workspace_root), value
679
- )
680
672
  # Reuse-worktree is decided once identity is final. Recompute here so
681
673
  # subsequent base-ref step knows whether to apply.
682
674
  state.reuse_worktree = _resolve_reuse_worktree(state)
@@ -839,11 +831,31 @@ def _build_brief_carry(state: WizardState) -> Prompt:
839
831
  )
840
832
 
841
833
 
834
+ def _checked_out_canonical_ref(project_root: Path) -> str:
835
+ """The main worktree's branch when it is one of the listed base refs, else ''.
836
+
837
+ Projects differ in their integration branch (main vs preprod), and nothing in
838
+ okstra records it; the branch the user keeps checked out is the observed hint.
839
+ """
840
+ try:
841
+ main_wt = _git_main_worktree(project_root)
842
+ except WizardError:
843
+ return ""
844
+ result = subprocess.run(
845
+ ["git", "-C", str(main_wt), "symbolic-ref", "--quiet", "--short", "HEAD"],
846
+ capture_output=True, text=True,
847
+ )
848
+ branch = result.stdout.strip() if result.returncode == 0 else ""
849
+ return branch if branch in CANONICAL_BASE_REFS else ""
850
+
851
+
842
852
  def _build_base_ref_pick(state: WizardState) -> Prompt:
843
853
  t = _p(state.workspace_root, "base_ref_pick")
844
854
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
845
- options = [_opt(r, f"main{recommended_suffix}" if r == "main" else r)
846
- for r in CANONICAL_BASE_REFS]
855
+ recommended = _checked_out_canonical_ref(Path(state.project_root))
856
+ refs = sorted(CANONICAL_BASE_REFS, key=lambda r: r != recommended)
857
+ options = [_opt(r, f"{r}{recommended_suffix}", recommended=True) if r == recommended
858
+ else _opt(r) for r in refs]
847
859
  for value, label in _static_options(t):
848
860
  options.append(_opt(value=value, label=label))
849
861
  return Prompt(
@@ -131,7 +131,7 @@ def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpe
131
131
  if spec.recommend_note:
132
132
  label += spec.recommend_note(state, suggestion)
133
133
  options.append(_opt(spec.recommend_token, label, recommended=True))
134
- setattr(state, spec.cache_attr, suggestion)
134
+ setattr(state, spec.cache_attr, suggestion)
135
135
  options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
136
136
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
137
137
  return Prompt(
@@ -151,8 +151,8 @@ def _submit_optional_cached_pick(
151
151
  setattr(state, attr, "")
152
152
  setattr(state, spec.pending_attr, False)
153
153
  return t["echo_suffixes"]["skip"]
154
- if value == spec.recommend_token:
155
- cached = getattr(state, spec.cache_attr)
154
+ cached = getattr(state, spec.cache_attr)
155
+ if value == spec.recommend_token and cached:
156
156
  setattr(state, spec.target_attr, cached)
157
157
  setattr(state, spec.pending_attr, False)
158
158
  echo = t["echo_suffixes"][spec.echo_suffix_key].format(value=cached)
@@ -162,9 +162,10 @@ def _submit_optional_cached_pick(
162
162
  if value == PICK_TYPE_CUSTOM:
163
163
  setattr(state, spec.pending_attr, True)
164
164
  return None
165
+ offered = ([spec.recommend_token] if cached else []) + [PICK_SKIP, PICK_TYPE_CUSTOM]
165
166
  raise WizardError(
166
167
  f"unexpected {spec.prompt_key} value: {value!r} "
167
- f"(expected {PICK_SKIP!r}, {spec.recommend_token!r}, or {PICK_TYPE_CUSTOM!r})"
168
+ f"(expected {', '.join(map(repr, offered[:-1]))}, or {offered[-1]!r})"
168
169
  )
169
170
 
170
171