okstra 0.135.0 → 0.137.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 (44) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/contributor-change-matrix.md +1 -1
  3. package/docs/project-structure-overview.md +8 -3
  4. package/package.json +2 -3
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/interactive.sh +24 -67
  7. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +10 -8
  9. package/runtime/bin/okstra-claude-exec.sh +10 -8
  10. package/runtime/bin/okstra-codex-exec.sh +10 -8
  11. package/runtime/bin/okstra-spawn-followups.py +4 -9
  12. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  13. package/runtime/python/okstra_ctl/backfill.py +2 -1
  14. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  15. package/runtime/python/okstra_ctl/consumers.py +7 -5
  16. package/runtime/python/okstra_ctl/context_cost.py +6 -5
  17. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  18. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  19. package/runtime/python/okstra_ctl/error_log_core.py +3 -1
  20. package/runtime/python/okstra_ctl/error_report.py +2 -7
  21. package/runtime/python/okstra_ctl/handoff.py +2 -1
  22. package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
  23. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  24. package/runtime/python/okstra_ctl/path_hints.py +5 -3
  25. package/runtime/python/okstra_ctl/paths.py +278 -4
  26. package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
  27. package/runtime/python/okstra_ctl/recap.py +5 -12
  28. package/runtime/python/okstra_ctl/render.py +9 -15
  29. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  30. package/runtime/python/okstra_ctl/report_language.py +83 -0
  31. package/runtime/python/okstra_ctl/run.py +222 -239
  32. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  33. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  34. package/runtime/python/okstra_ctl/time_report.py +4 -9
  35. package/runtime/python/okstra_ctl/wizard.py +50 -50
  36. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  40. package/runtime/python/okstra_project/__init__.py +4 -0
  41. package/runtime/python/okstra_project/dirs.py +7 -0
  42. package/runtime/python/okstra_project/state.py +78 -8
  43. package/runtime/validators/validate-run.py +23 -52
  44. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -0,0 +1,83 @@
1
+ """Report-language resolution for the report-writer prompt.
2
+
3
+ Both dispatchers stamp `**Report Language:**` into the report-writer prompt, so
4
+ the precedence (project config → global config → inferred from the task brief)
5
+ lives here rather than in whichever dispatcher happened to need it first.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+ from .worker_prompt_body import instruction_path
15
+
16
+ REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
17
+
18
+ # Below this many Hangul syllables a brief reads as incidental Korean in an
19
+ # otherwise English document.
20
+ _HANGUL_THRESHOLD = 10
21
+ _BRIEF_SCAN_CHARS = 16000
22
+
23
+
24
+ class ReportLanguageError(ValueError):
25
+ """A config file declares a reportLanguage outside the allowed values."""
26
+
27
+
28
+ def resolve_report_language(
29
+ project_root: Path,
30
+ manifest: Mapping[str, Any],
31
+ active_context: Mapping[str, Any],
32
+ ) -> str:
33
+ for value in _report_language_candidates(project_root):
34
+ if value == "auto":
35
+ return _infer_report_language(project_root, manifest, active_context)
36
+ if value:
37
+ return value
38
+ return _infer_report_language(project_root, manifest, active_context)
39
+
40
+
41
+ def _report_language_candidates(project_root: Path) -> list[str]:
42
+ okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
43
+ return [
44
+ _read_report_language(project_root / ".okstra" / "project.json", "project"),
45
+ _read_report_language(okstra_home / "config.json", "global"),
46
+ ]
47
+
48
+
49
+ def _read_report_language(path: Path, label: str) -> str:
50
+ if not path.is_file():
51
+ return ""
52
+ try:
53
+ payload = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError) as exc:
55
+ raise ReportLanguageError(f"{label} config is unreadable: {path}") from exc
56
+ if not isinstance(payload, Mapping):
57
+ raise ReportLanguageError(f"{label} config must be a JSON object: {path}")
58
+ value = payload.get("reportLanguage")
59
+ if value in (None, ""):
60
+ return ""
61
+ if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
62
+ return value
63
+ raise ReportLanguageError(
64
+ f"{label} config reportLanguage must be en, ko, or auto: {path}"
65
+ )
66
+
67
+
68
+ def _infer_report_language(
69
+ project_root: Path,
70
+ manifest: Mapping[str, Any],
71
+ active_context: Mapping[str, Any],
72
+ ) -> str:
73
+ task_brief = instruction_path(manifest, active_context, "taskBriefPath")
74
+ if not task_brief:
75
+ return "en"
76
+ path = Path(task_brief)
77
+ if not path.is_absolute():
78
+ path = project_root / path
79
+ if not path.is_file():
80
+ return "en"
81
+ text = path.read_text(encoding="utf-8", errors="replace")[:_BRIEF_SCAN_CHARS]
82
+ hangul = sum(1 for char in text if "가" <= char <= "힣")
83
+ return "ko" if hangul >= _HANGUL_THRESHOLD else "en"
@@ -444,85 +444,8 @@ def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> Prepare
444
444
  return PrepareError(str(exc))
445
445
 
446
446
 
447
- def _resolve_effective_stages(
448
- stages: list,
449
- done_stages: set,
450
- requested: str,
451
- budget: int = RUN_STEP_BUDGET,
452
- started_stages: set = None,
453
- reserved_stages: set = None,
454
- ) -> list:
455
- """Compatibility wrapper for the public stage-target policy module."""
456
- try:
457
- return _stage_targets.resolve_effective_stages(
458
- stages,
459
- done_stages,
460
- requested,
461
- budget=budget,
462
- started_stages=started_stages,
463
- reserved_stages=reserved_stages,
464
- )
465
- except _stage_targets.StageTargetError as exc:
466
- raise _stage_target_prepare_error(exc) from exc
467
-
468
-
469
- def _check_multi_dep_merged(project_root, plan_run_root, latest,
470
- pred_commits: dict, candidate_base: str,
471
- stage_n: int) -> None:
472
- """Compatibility wrapper for the stage-target dependency gate."""
473
- try:
474
- _stage_targets.check_multi_dep_merged(
475
- project_root,
476
- plan_run_root,
477
- latest,
478
- pred_commits,
479
- candidate_base,
480
- stage_n,
481
- )
482
- except _stage_targets.StageTargetError as exc:
483
- raise _stage_target_prepare_error(exc) from exc
484
-
485
-
486
- def _resolve_stage_base_commit(
487
- stage: dict,
488
- consumer_done_rows: list,
489
- anchor_base_commit: str,
490
- candidate_base: str = "",
491
- project_root=None,
492
- plan_run_root=None,
493
- ) -> str:
494
- """Compatibility wrapper for stage worktree base selection."""
495
- try:
496
- return _stage_targets.resolve_stage_base_commit(
497
- stage,
498
- consumer_done_rows,
499
- anchor_base_commit,
500
- candidate_base=candidate_base,
501
- project_root=project_root,
502
- plan_run_root=plan_run_root,
503
- )
504
- except _stage_targets.StageTargetError as exc:
505
- raise _stage_target_prepare_error(exc) from exc
506
447
 
507
448
 
508
- def _resolve_whole_task_target(
509
- *, stage_map: list, done_rows: list, anchor_base: str,
510
- task_worktree_path: str, task_head: str, task_dirty: bool,
511
- merged: dict,
512
- ) -> "_FVTarget":
513
- """Compatibility wrapper for whole-task verification target selection."""
514
- try:
515
- return _stage_targets.resolve_whole_task_target(
516
- stage_map=stage_map,
517
- done_rows=done_rows,
518
- anchor_base=anchor_base,
519
- task_worktree_path=task_worktree_path,
520
- task_head=task_head,
521
- task_dirty=task_dirty,
522
- merged=merged,
523
- )
524
- except _stage_targets.StageTargetError as exc:
525
- raise _stage_target_prepare_error(exc) from exc
526
449
 
527
450
 
528
451
  def _resolve_single_stage_target(
@@ -1513,10 +1436,6 @@ def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> No
1513
1436
  _stage_auto_reconcile_best_effort(inp, plan_run_root)
1514
1437
 
1515
1438
 
1516
- def _is_ancestor(cwd, commit, head) -> bool:
1517
- from .worktree import is_ancestor
1518
- return is_ancestor(cwd, commit, head)
1519
-
1520
1439
 
1521
1440
  def _is_dirty_excluding_okstra(cwd) -> bool:
1522
1441
  from .worktree import is_dirty_excluding_okstra
@@ -1969,6 +1888,215 @@ def _provision_settings_symlink(inp: PrepareInputs) -> None:
1969
1888
  )
1970
1889
 
1971
1890
 
1891
+ @dataclass
1892
+ class _ProvisionedTask:
1893
+ """What one task-key's provisioning critical section hands to run-path compute."""
1894
+
1895
+ worktree: WorktreeProvision
1896
+ stage_run_claim: object | None
1897
+ stage_arg: int | None
1898
+
1899
+
1900
+ def _write_bundle_artifacts(
1901
+ inp: PrepareInputs,
1902
+ ctx: dict,
1903
+ project_root: Path,
1904
+ lead_runtime: str,
1905
+ claude_session_id: str,
1906
+ ) -> None:
1907
+ """Create the task/run directories and the artifacts the lead reads at launch.
1908
+
1909
+ Order is contractual. The fix-cycle ledger runs LAST here but still *before*
1910
+ the instruction-set build in the caller, so a cycle opened by this run shows
1911
+ up in the analysis packet's Fix History; finalize rewrites the manifest after
1912
+ that, which keeps the lazy-close invariant that the on-disk manifest holds
1913
+ pre-rewrite values when the cycle is judged.
1914
+ """
1915
+ _ensure_task_directories(ctx)
1916
+ migrate_legacy_run_artifacts(ctx)
1917
+ cleanup_obsolete_generated_docs(
1918
+ project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
1919
+ )
1920
+ if lead_runtime == "claude-code":
1921
+ # Always materialise the resume command script for Claude Code. Even in
1922
+ # --render-only preparation flows the user (or a later non-interactive
1923
+ # runner) may invoke it manually; deferring its creation until
1924
+ # interactive launch leaves runs/<phase>/sessions/ empty and the
1925
+ # manifest pointing at a path that does not exist. Codex runs resume via
1926
+ # artifacts/checkpoints instead, so they intentionally leave this empty.
1927
+ write_claude_resume_command_file(
1928
+ resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
1929
+ project_root=project_root,
1930
+ claude_session_id=claude_session_id,
1931
+ task_key=ctx["TASK_KEY"],
1932
+ task_type=ctx["TASK_TYPE"],
1933
+ phase_state=ctx["CURRENT_RUN_STATUS"],
1934
+ worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
1935
+ prompt_seq=ctx["RUN_PROMPTS_SEQ"],
1936
+ )
1937
+ ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
1938
+
1939
+
1940
+ def _record_run_in_central_index(
1941
+ inp: PrepareInputs,
1942
+ ctx: dict,
1943
+ workspace_root: Path,
1944
+ run_seq_override: int | None,
1945
+ ) -> None:
1946
+ """Publish this run to ~/.okstra/{recent,active}.jsonl. Never fatal.
1947
+
1948
+ A failure here leaves the central index incomplete but the bundle on disk is
1949
+ already usable, so prepare reports and continues. The rerun path pre-stamps a
1950
+ 'reserving' row before spawning (OKSTRA_RUN_SEQ_OVERRIDE forces that seq); if
1951
+ the promotion to 'running' fails, that row would sit in active.jsonl forever
1952
+ and inflate activeCount, so it is cleaned up whenever the reserved seq is known.
1953
+ """
1954
+ try:
1955
+ _record_start(
1956
+ workspace_root=workspace_root,
1957
+ ctx=ctx,
1958
+ initial_status="prepared" if inp.render_only else "running",
1959
+ canonical_argv=_canonical_argv(inp, ctx),
1960
+ cwd=os.getcwd(),
1961
+ brief_sha256=_brief_sha256(inp.brief_path),
1962
+ )
1963
+ except Exception as exc:
1964
+ print(
1965
+ f"okstra-central: record_start failed; central index will be incomplete ({exc})",
1966
+ file=sys.stderr,
1967
+ )
1968
+ if run_seq_override is not None:
1969
+ _remove_leaked_reservation(ctx, run_seq_override)
1970
+
1971
+
1972
+ def _initial_workflow_ctx(
1973
+ inp: PrepareInputs, forbidden_by_phase: dict
1974
+ ) -> dict[str, str]:
1975
+ """Workflow tokens for a run that has been prepared but not started.
1976
+
1977
+ The run status is not known yet, so both statuses are the pre-launch
1978
+ constants; `_finalize_status_and_render_manifests` recomputes the block once
1979
+ the real status is settled.
1980
+ """
1981
+ task_status, run_status = "ready-for-claude", "not-run"
1982
+ return {
1983
+ "CURRENT_TASK_STATUS": task_status,
1984
+ "CURRENT_RUN_STATUS": run_status,
1985
+ **compute_workflow_state(
1986
+ task_type=inp.task_type,
1987
+ current_run_status=run_status,
1988
+ current_task_status=task_status,
1989
+ render_only=inp.render_only,
1990
+ forbidden_by_phase=forbidden_by_phase,
1991
+ ),
1992
+ }
1993
+
1994
+
1995
+ def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
1996
+ """Render tokens for the tasks this run declares a relation to."""
1997
+ items = resolve_related_tasks(
1998
+ task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
1999
+ raw_related=inp.related_tasks_raw,
2000
+ )
2001
+ return {
2002
+ "RELATED_TASKS_JSON": json.dumps(items, ensure_ascii=False),
2003
+ "RELATED_TASKS_BULLETS": related_tasks_bullets(items),
2004
+ "RELATED_TASKS_INLINE": related_tasks_inline(items),
2005
+ }
2006
+
2007
+
2008
+ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2009
+ """Render tokens for every model binding this run resolved."""
2010
+ return {
2011
+ "LEAD_MODEL": models.lead.display,
2012
+ "LEAD_MODEL_EXECUTION_VALUE": models.lead.execution,
2013
+ "CLAUDE_WORKER_MODEL": models.cw.display,
2014
+ "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": models.cw.execution,
2015
+ "CODEX_WORKER_MODEL": models.co.display,
2016
+ "CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
2017
+ "ANTIGRAVITY_WORKER_MODEL": models.ge.display,
2018
+ "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
2019
+ "REPORT_WRITER_MODEL": models.rw.display,
2020
+ "REPORT_WRITER_MODEL_EXECUTION_VALUE": models.rw.execution,
2021
+ "EXECUTOR_PROVIDER": models.executor_provider,
2022
+ "EXECUTOR_DISPLAY_NAME": models.executor_display_name,
2023
+ "EXECUTOR_WORKER_AGENT": models.executor_worker_agent,
2024
+ "EXECUTOR_MODEL_DISPLAY": models.executor_model_meta.display,
2025
+ "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
2026
+ "CRITIC_CHOICE": models.critic_choice,
2027
+ }
2028
+
2029
+
2030
+ def _provision_task_and_stage(
2031
+ inp: PrepareInputs,
2032
+ project_root: Path,
2033
+ ctx_stage_map: list,
2034
+ task_group_segment: str,
2035
+ task_id_segment: str,
2036
+ task_key: str,
2037
+ ) -> _ProvisionedTask:
2038
+ """Reserve this run's worktree and, for implementation, its Stage Map stage.
2039
+
2040
+ One worktree per task-key: requirements-discovery, error-analysis,
2041
+ implementation-planning and implementation phases of the same task all share
2042
+ this directory and branch. Runs BEFORE run-path compute: the worktree's
2043
+ degrade status (skipped-*) feeds the implementation Stage Run Claim, and the
2044
+ resolved stage namespaces the run path.
2045
+
2046
+ The whole check→select→`git worktree add`→reserve sequence (task worktree AND
2047
+ implementation stage) is one critical section per task-key: the registry lock
2048
+ alone only covers the reserve row, so without this mutex two concurrent runs
2049
+ can pick the same stage or race the same path/branch at the git level (TOCTOU).
2050
+ """
2051
+ with worktree_provision_mutex(
2052
+ okstra_home(), inp.project_id, task_group_segment, task_id_segment,
2053
+ ):
2054
+ if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
2055
+ worktree = _single_stage_final_verification_worktree(inp)
2056
+ # Single-stage final-verification namespaces its run path under
2057
+ # runs/final-verification/stage-<N> (same isolation as
2058
+ # implementation) so concurrent per-stage verifications never
2059
+ # share state/reports/worker-results.
2060
+ fv_stage_arg = int(inp.stage)
2061
+ else:
2062
+ fv_stage_arg = None
2063
+ try:
2064
+ worktree = provision_task_worktree(
2065
+ task_type=inp.task_type,
2066
+ project_root=project_root,
2067
+ project_id=inp.project_id,
2068
+ task_group_segment=task_group_segment,
2069
+ task_id_segment=task_id_segment,
2070
+ work_category=inp.work_category,
2071
+ base_ref=inp.base_ref,
2072
+ require_base_ref=True,
2073
+ )
2074
+ except RuntimeError as exc:
2075
+ raise PrepareError(
2076
+ f"task worktree provisioning failed: {exc}"
2077
+ ) from exc
2078
+
2079
+ # ---- implementation Stage Run Claim (path-independent) ----
2080
+ # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
2081
+ # lands in runs/implementation/stage-<N>. The registry stage-key is
2082
+ # reserved exactly once here (inside provision_stage_worktree), and
2083
+ # the surrounding mutex makes the registry read in the claim
2084
+ # and that reserve atomic. Other task-types skip this claim;
2085
+ # single-stage final-verification threads its explicit stage via
2086
+ # fv_stage_arg, everything else keeps stage_arg=None (flat paths).
2087
+ if inp.task_type == "implementation":
2088
+ stage_run_claim = _claim_implementation_stage_run(
2089
+ inp, ctx_stage_map, task_group_segment, task_id_segment,
2090
+ task_key, worktree.status,
2091
+ )
2092
+ # Drop any stale waiver on this stage so the run actually verifies
2093
+ # conformance (kept inside the per-task-key mutex so concurrent
2094
+ # same-task runs don't race the manifest write).
2095
+ _clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
2096
+ return _ProvisionedTask(worktree, stage_run_claim, stage_run_claim.stage)
2097
+ return _ProvisionedTask(worktree, None, fv_stage_arg)
2098
+
2099
+
1972
2100
  def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1973
2101
  """Produce a complete okstra task bundle on disk. See module docstring."""
1974
2102
  workspace_root = Path(inp.workspace_root)
@@ -2011,12 +2139,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2011
2139
  pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
2012
2140
 
2013
2141
  models = _resolve_model_bindings(inp, workers)
2014
- lead, cw, co, ge, rw = models.lead, models.cw, models.co, models.ge, models.rw
2015
- critic_choice = models.critic_choice
2016
- executor_provider = models.executor_provider
2017
- executor_display_name = models.executor_display_name
2018
- executor_worker_agent = models.executor_worker_agent
2019
- executor_model_meta = models.executor_model_meta
2020
2142
 
2021
2143
  # ---- paths under per-task mutex (writes run-context-*.json) ----
2022
2144
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
@@ -2043,67 +2165,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2043
2165
  task_id=inp.task_id,
2044
2166
  )
2045
2167
 
2046
- # ---- task worktree provisioning (every phase, every task-type) ----
2047
- # One worktree per task-key: requirements-discovery, error-analysis,
2048
- # implementation-planning and implementation phases of the same task
2049
- # all share this directory and branch. Runs BEFORE run-path compute: its
2050
- # degrade status (skipped-*) feeds implementation Stage Run Claim, and
2051
- # the resolved stage namespaces the run path.
2052
- #
2053
- # The whole check→select→`git worktree add`→reserve sequence (task
2054
- # worktree AND implementation stage) is one critical section per
2055
- # task-key: the registry lock alone only covers the reserve row, so
2056
- # without this mutex two concurrent runs can pick the same stage or
2057
- # race the same path/branch at the git level (TOCTOU).
2058
- with worktree_provision_mutex(
2059
- okstra_home(), inp.project_id, task_group_segment, task_id_segment,
2060
- ):
2061
- if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
2062
- worktree = _single_stage_final_verification_worktree(inp)
2063
- # Single-stage final-verification namespaces its run path under
2064
- # runs/final-verification/stage-<N> (same isolation as
2065
- # implementation) so concurrent per-stage verifications never
2066
- # share state/reports/worker-results.
2067
- fv_stage_arg = int(inp.stage)
2068
- else:
2069
- fv_stage_arg = None
2070
- try:
2071
- worktree = provision_task_worktree(
2072
- task_type=inp.task_type,
2073
- project_root=project_root,
2074
- project_id=inp.project_id,
2075
- task_group_segment=task_group_segment,
2076
- task_id_segment=task_id_segment,
2077
- work_category=inp.work_category,
2078
- base_ref=inp.base_ref,
2079
- require_base_ref=True,
2080
- )
2081
- except RuntimeError as exc:
2082
- raise PrepareError(
2083
- f"task worktree provisioning failed: {exc}"
2084
- ) from exc
2085
-
2086
- # ---- implementation Stage Run Claim (path-independent) ----
2087
- # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
2088
- # lands in runs/implementation/stage-<N>. The registry stage-key is
2089
- # reserved exactly once here (inside provision_stage_worktree), and
2090
- # the surrounding mutex makes the registry read in the claim
2091
- # and that reserve atomic. Other task-types skip this claim;
2092
- # single-stage final-verification threads its explicit stage via
2093
- # fv_stage_arg, everything else keeps stage_arg=None (flat paths).
2094
- if inp.task_type == "implementation":
2095
- stage_run_claim = _claim_implementation_stage_run(
2096
- inp, ctx_stage_map, task_group_segment, task_id_segment,
2097
- task_key, worktree.status,
2098
- )
2099
- stage_arg = stage_run_claim.stage
2100
- # Drop any stale waiver on this stage so the run actually verifies
2101
- # conformance (kept inside the per-task-key mutex so concurrent
2102
- # same-task runs don't race the manifest write).
2103
- _clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
2104
- else:
2105
- stage_run_claim = None
2106
- stage_arg = fv_stage_arg
2168
+ provisioned = _provision_task_and_stage(
2169
+ inp, project_root, ctx_stage_map,
2170
+ task_group_segment, task_id_segment, task_key,
2171
+ )
2172
+ worktree = provisioned.worktree
2173
+ stage_run_claim = provisioned.stage_run_claim
2174
+ stage_arg = provisioned.stage_arg
2107
2175
 
2108
2176
  ctx = compute_and_write_run_context(
2109
2177
  workspace_root=workspace_root, project_root=project_root,
@@ -2154,13 +2222,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2154
2222
  # ---- material + related-tasks ----
2155
2223
  profile_content = _expand_profile_includes(profile_file)
2156
2224
  review_material = build_analysis_material(inp.brief_path, inp.directive)
2157
- related_items = resolve_related_tasks(
2158
- task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
2159
- raw_related=inp.related_tasks_raw,
2160
- )
2161
- related_tasks_json_str = json.dumps(related_items, ensure_ascii=False)
2162
- bullets = related_tasks_bullets(related_items)
2163
- inline = related_tasks_inline(related_items)
2164
2225
 
2165
2226
  # ---- relative paths for brief + clarification ----
2166
2227
  brief_relative = relative_to_project_root(inp.brief_path, project_root)
@@ -2169,17 +2230,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2169
2230
  if inp.clarification_response_path else ""
2170
2231
  )
2171
2232
 
2172
- # ---- initial workflow state (current_run_status not yet known) ----
2173
- initial_task_status = "ready-for-claude"
2174
- initial_run_status = "not-run"
2175
2233
  forbidden_by_phase = load_phase_forbidden(workspace_root)
2176
- workflow_state = compute_workflow_state(
2177
- task_type=inp.task_type,
2178
- current_run_status=initial_run_status,
2179
- current_task_status=initial_task_status,
2180
- render_only=inp.render_only,
2181
- forbidden_by_phase=forbidden_by_phase,
2182
- )
2183
2234
 
2184
2235
  # ---- assemble full ctx (the values render functions expect) ----
2185
2236
  ctx.update({
@@ -2196,27 +2247,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2196
2247
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
2197
2248
  "BRIEF_FILE_PATH": str(inp.brief_path),
2198
2249
  "BRIEF_RELATIVE_PATH": brief_relative,
2199
- "LEAD_MODEL": lead.display,
2200
- "LEAD_MODEL_EXECUTION_VALUE": lead.execution,
2201
- "CLAUDE_WORKER_MODEL": cw.display,
2202
- "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
2203
- "CODEX_WORKER_MODEL": co.display,
2204
- "CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
2205
- "ANTIGRAVITY_WORKER_MODEL": ge.display,
2206
- "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
2207
- "REPORT_WRITER_MODEL": rw.display,
2208
- "REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
2209
- "EXECUTOR_PROVIDER": executor_provider,
2210
- "EXECUTOR_DISPLAY_NAME": executor_display_name,
2211
- "EXECUTOR_WORKER_AGENT": executor_worker_agent,
2212
- "EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
2213
- "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
2214
- "CRITIC_CHOICE": critic_choice,
2215
- "RELATED_TASKS_JSON": related_tasks_json_str,
2216
- "RELATED_TASKS_BULLETS": bullets,
2217
- "RELATED_TASKS_INLINE": inline,
2218
- "CURRENT_TASK_STATUS": initial_task_status,
2219
- "CURRENT_RUN_STATUS": initial_run_status,
2250
+ **_model_ctx(models),
2251
+ **_related_tasks_ctx(ctx, inp),
2252
+ **_initial_workflow_ctx(inp, forbidden_by_phase),
2220
2253
  "VALIDATION_STATUS": "not-run",
2221
2254
  "VALIDATION_UPDATED_AT": "",
2222
2255
  "VALIDATION_FAILURES_JSON": "[]",
@@ -2224,7 +2257,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2224
2257
  "LATEST_REPORT_RELATIVE_PATH": "",
2225
2258
  "RENDER_ONLY": "true" if inp.render_only else "false",
2226
2259
  "OKSTRA_VERSION": installed_version(),
2227
- **workflow_state,
2228
2260
  })
2229
2261
  if lead_runtime == "codex":
2230
2262
  ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
@@ -2233,36 +2265,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2233
2265
  if inp.task_type == "final-verification":
2234
2266
  _reserve_final_verification_target(inp, ctx, ctx_stage_map)
2235
2267
 
2236
- # ---- prepare directories + cleanup ----
2237
- _ensure_task_directories(ctx)
2238
- migrate_legacy_run_artifacts(ctx)
2239
- cleanup_obsolete_generated_docs(
2240
- project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
2268
+ _write_bundle_artifacts(
2269
+ inp, ctx, project_root, lead_runtime, claude_session_id,
2241
2270
  )
2242
- if lead_runtime == "claude-code":
2243
- # Always materialise the resume command script for Claude Code. Even in
2244
- # --render-only preparation flows the user (or a later non-interactive
2245
- # runner) may invoke it manually; deferring its creation until
2246
- # interactive launch leaves runs/<phase>/sessions/ empty and the
2247
- # manifest pointing at a path that does not exist. Codex runs resume via
2248
- # artifacts/checkpoints instead, so they intentionally leave this empty.
2249
- write_claude_resume_command_file(
2250
- resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
2251
- project_root=project_root,
2252
- claude_session_id=claude_session_id,
2253
- task_key=ctx["TASK_KEY"],
2254
- task_type=ctx["TASK_TYPE"],
2255
- phase_state=ctx["CURRENT_RUN_STATUS"],
2256
- worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
2257
- prompt_seq=ctx["RUN_PROMPTS_SEQ"],
2258
- )
2259
-
2260
- # ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
2261
- # instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
2262
- # cycle 도 analysis-packet 의 Fix History 에 보이도록 한다. finalize 가
2263
- # 여전히 이후에 manifest 를 재작성하므로 lazy-close 의 "디스크 manifest 는
2264
- # prepare 재작성 전 값" 불변식은 유지된다.
2265
- ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
2266
2271
 
2267
2272
  # ---- write instruction-set scaffolding + lead prompt ----
2268
2273
  instruction_set = _write_instruction_set_sources(
@@ -2281,29 +2286,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2281
2286
  )
2282
2287
  _record_artifact_runtime_render_only_event(inp, ctx)
2283
2288
 
2284
- # ---- central index ----
2285
- initial_status = "prepared" if inp.render_only else "running"
2286
- canonical_argv = _canonical_argv(inp, ctx)
2287
- try:
2288
- _record_start(
2289
- workspace_root=workspace_root,
2290
- ctx=ctx,
2291
- initial_status=initial_status,
2292
- canonical_argv=canonical_argv,
2293
- cwd=os.getcwd(),
2294
- brief_sha256=_brief_sha256(inp.brief_path),
2295
- )
2296
- except Exception as exc:
2297
- print(
2298
- f"okstra-central: record_start failed; central index will be incomplete ({exc})",
2299
- file=sys.stderr,
2300
- )
2301
- # rerun 경로는 spawn 전에 'reserving' row 를 미리 박아 둔다
2302
- # (OKSTRA_RUN_SEQ_OVERRIDE 가 그 seq 를 강제한다). record_start 가
2303
- # 'reserving' → 'running' 승격에 실패하면 그 row 가 active.jsonl 에
2304
- # 영구히 남아 activeCount 를 부풀린다. 예약 seq 를 알 때만 정리한다.
2305
- if run_seq_override is not None:
2306
- _remove_leaked_reservation(ctx, run_seq_override)
2289
+ _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
2307
2290
 
2308
2291
  if not inp.render_only:
2309
2292
  _provision_settings_symlink(inp)
@@ -15,6 +15,7 @@ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
17
  from okstra_ctl.ids import slugify_task_segment
18
+ from okstra_ctl.paths import task_dir, task_manifest_file
18
19
  from okstra_project import (
19
20
  ResolverError,
20
21
  StateError,
@@ -40,7 +41,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
40
41
  id_seg = entry.get("taskIdPathSegment") or slugify_task_segment(
41
42
  entry.get("taskId", "")
42
43
  )
43
- return project_root / ".okstra" / "tasks" / group_seg / id_seg / "task-manifest.json"
44
+ return task_manifest_file(task_dir(project_root, group_seg, id_seg))
44
45
 
45
46
 
46
47
  def main(argv: list[str] | None = None) -> int: