okstra 0.77.0 → 0.78.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 (41) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -125
  4. package/docs/contributor-change-matrix.md +12 -0
  5. package/docs/kr/cli.md +5 -4
  6. package/docs/project-structure-overview.md +1 -1
  7. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
  8. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
  9. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
  10. package/package.json +5 -2
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/DO_NOT_EDIT.md +21 -0
  13. package/runtime/agents/SKILL.md +3 -0
  14. package/runtime/bin/okstra-trace-cleanup.sh +16 -12
  15. package/runtime/prompts/profiles/forbidden-actions.json +69 -0
  16. package/runtime/prompts/profiles/implementation.md +1 -8
  17. package/runtime/prompts/profiles/release-handoff.md +1 -15
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +70 -2
  19. package/runtime/python/okstra_ctl/context_cost.py +1 -1
  20. package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
  21. package/runtime/python/okstra_ctl/doctor.py +292 -0
  22. package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
  23. package/runtime/python/okstra_ctl/render.py +67 -16
  24. package/runtime/python/okstra_ctl/run.py +20 -12
  25. package/runtime/python/okstra_ctl/team.py +267 -0
  26. package/runtime/python/okstra_ctl/tmux.py +181 -10
  27. package/runtime/python/okstra_ctl/workflow.py +30 -71
  28. package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
  29. package/runtime/schemas/final-report-v1.0.schema.json +3 -1
  30. package/runtime/skills/okstra-convergence/SKILL.md +3 -0
  31. package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
  32. package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
  33. package/runtime/validators/forbidden_actions.py +135 -0
  34. package/runtime/validators/validate-run.py +65 -9
  35. package/runtime/validators/validate_session_conformance.py +15 -10
  36. package/src/cli-registry.mjs +277 -0
  37. package/src/doctor.mjs +93 -4
  38. package/src/install.mjs +6 -5
  39. package/src/runtime-manifest.mjs +5 -2
  40. package/src/team.mjs +63 -0
  41. package/src/uninstall.mjs +1 -0
@@ -0,0 +1,292 @@
1
+ """Phase-aware diagnostics for ``okstra doctor --phase``."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from okstra_project import ResolverError, project_json_path, resolve_project_root
10
+
11
+ from . import improvement_lenses, worktree_registry
12
+ from .workers import resolve_profile_workers
13
+ from .worktree import is_git_work_tree, main_worktree_path
14
+
15
+ SUPPORTED_PHASES: tuple[str, ...] = (
16
+ "implementation",
17
+ "final-verification",
18
+ "release-handoff",
19
+ "improvement-discovery",
20
+ )
21
+
22
+ _BASE_BRANCHES = {"main", "master", "prod", "preprod", "staging", "dev"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DoctorCheck:
27
+ name: str
28
+ ok: bool
29
+ detail: str
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return asdict(self)
33
+
34
+
35
+ def phase_diagnostics(
36
+ phase: str,
37
+ *,
38
+ cwd: str | Path,
39
+ workspace_root: str | Path,
40
+ home: str | Path,
41
+ ) -> dict[str, object]:
42
+ """Return JSON-serialisable readiness checks for one lifecycle phase."""
43
+ if phase not in SUPPORTED_PHASES:
44
+ return {
45
+ "ok": False,
46
+ "usageError": True,
47
+ "reason": _unknown_phase_message(phase),
48
+ "supportedPhases": list(SUPPORTED_PHASES),
49
+ "checks": [],
50
+ }
51
+
52
+ workspace = Path(workspace_root)
53
+ home_path = Path(home)
54
+ project_root, checks = _project_checks(Path(cwd))
55
+ checks.append(_profile_check(workspace, phase))
56
+
57
+ if project_root is not None:
58
+ checks.extend(_phase_checks(phase, project_root, workspace, home_path))
59
+
60
+ return {
61
+ "ok": all(check.ok for check in checks),
62
+ "usageError": False,
63
+ "phase": phase,
64
+ "projectRoot": str(project_root) if project_root else "",
65
+ "checks": [check.to_dict() for check in checks],
66
+ }
67
+
68
+
69
+ def _unknown_phase_message(phase: str) -> str:
70
+ supported = ", ".join(SUPPORTED_PHASES)
71
+ return f"unknown phase '{phase}' (expected one of: {supported})"
72
+
73
+
74
+ def _project_checks(cwd: Path) -> tuple[Path | None, list[DoctorCheck]]:
75
+ try:
76
+ project_root = resolve_project_root(explicit_root="", cwd=str(cwd))
77
+ except ResolverError as exc:
78
+ return None, [_fail("project root", str(exc))]
79
+
80
+ checks = [_ok("project root", str(project_root))]
81
+ project_json = project_json_path(project_root)
82
+ if project_json.is_file():
83
+ checks.append(_ok("project registration", str(project_json)))
84
+ else:
85
+ checks.append(
86
+ _fail(
87
+ "project registration",
88
+ f"{project_json} not found — run okstra setup in this project first",
89
+ )
90
+ )
91
+ return project_root, checks
92
+
93
+
94
+ def _profile_check(workspace: Path, phase: str) -> DoctorCheck:
95
+ profile = _profile_path(workspace, phase)
96
+ if profile.is_file():
97
+ return _ok("profile", str(profile))
98
+ return _fail("profile", f"not found: {profile}")
99
+
100
+
101
+ def _phase_checks(
102
+ phase: str,
103
+ project_root: Path,
104
+ workspace: Path,
105
+ home: Path,
106
+ ) -> list[DoctorCheck]:
107
+ if phase == "implementation":
108
+ return _implementation_checks(project_root, workspace, home)
109
+ if phase == "final-verification":
110
+ return _final_verification_checks(workspace)
111
+ if phase == "release-handoff":
112
+ return _release_handoff_checks(project_root)
113
+ if phase == "improvement-discovery":
114
+ return _improvement_discovery_checks(workspace, home)
115
+ return []
116
+
117
+
118
+ def _implementation_checks(
119
+ project_root: Path,
120
+ workspace: Path,
121
+ home: Path,
122
+ ) -> list[DoctorCheck]:
123
+ return [
124
+ _git_work_tree_check(project_root),
125
+ _git_worktree_support_check(project_root),
126
+ _approved_plan_validator_check(),
127
+ _worker_agents_check(home, workspace, "implementation"),
128
+ ]
129
+
130
+
131
+ def _final_verification_checks(workspace: Path) -> list[DoctorCheck]:
132
+ return [
133
+ _worktree_registry_check(),
134
+ _file_check("validation command", workspace / "validators" / "validate-run.py"),
135
+ _file_check(
136
+ "final report template",
137
+ workspace / "templates" / "reports" / "final-report.template.md",
138
+ ),
139
+ _file_check(
140
+ "final report schema",
141
+ workspace / "schemas" / "final-report-v1.0.schema.json",
142
+ ),
143
+ ]
144
+
145
+
146
+ def _release_handoff_checks(project_root: Path) -> list[DoctorCheck]:
147
+ return [
148
+ _gh_auth_check(project_root),
149
+ _git_clean_check(project_root),
150
+ _git_remote_check(project_root),
151
+ _feature_branch_check(project_root),
152
+ ]
153
+
154
+
155
+ def _improvement_discovery_checks(workspace: Path, home: Path) -> list[DoctorCheck]:
156
+ return [
157
+ _worker_agents_check(home, workspace, "improvement-discovery"),
158
+ _file_check("gemini worker agent", _agent_path(home, "gemini")),
159
+ _lens_whitelist_check(),
160
+ ]
161
+
162
+
163
+ def _git_work_tree_check(project_root: Path) -> DoctorCheck:
164
+ if is_git_work_tree(project_root):
165
+ return _ok("git work tree", str(main_worktree_path(project_root)))
166
+ return _fail("git work tree", f"{project_root} is not inside a git work tree")
167
+
168
+
169
+ def _git_worktree_support_check(project_root: Path) -> DoctorCheck:
170
+ result = _run(["git", "-C", str(project_root), "worktree", "list", "--porcelain"])
171
+ if result.returncode == 0:
172
+ return _ok("git worktree support", "git worktree list succeeded")
173
+ return _fail("git worktree support", _command_detail(result))
174
+
175
+
176
+ def _approved_plan_validator_check() -> DoctorCheck:
177
+ try:
178
+ from .run import _validate_approved_plan # noqa: F401
179
+ except Exception as exc:
180
+ return _fail("approved-plan validator", str(exc))
181
+ return _ok("approved-plan validator", "okstra plan-validate is available")
182
+
183
+
184
+ def _worker_agents_check(home: Path, workspace: Path, phase: str) -> DoctorCheck:
185
+ workers = resolve_profile_workers(_profile_path(workspace, phase))
186
+ if not workers:
187
+ return _ok("worker agents", "no worker agents required")
188
+ missing = [worker for worker in workers if not _agent_path(home, worker).is_file()]
189
+ roster = ",".join(workers)
190
+ if missing:
191
+ return _fail(
192
+ "worker agents",
193
+ f"missing {','.join(missing)}; profile requires {roster}",
194
+ )
195
+ return _ok("worker agents", f"installed: {roster}")
196
+
197
+
198
+ def _worktree_registry_check() -> DoctorCheck:
199
+ if callable(worktree_registry.lookup) and callable(worktree_registry.task_key):
200
+ return _ok(
201
+ "worktree lookup helper",
202
+ "lookup helper available; target-specific lookup requires a task key",
203
+ )
204
+ return _fail("worktree lookup helper", "lookup helper unavailable")
205
+
206
+
207
+ def _gh_auth_check(project_root: Path) -> DoctorCheck:
208
+ result = _run(["gh", "auth", "status"], cwd=project_root)
209
+ if result.returncode == 0:
210
+ return _ok("gh auth", "gh auth status succeeded")
211
+ return _fail("gh auth", _command_detail(result))
212
+
213
+
214
+ def _git_clean_check(project_root: Path) -> DoctorCheck:
215
+ result = _run(["git", "-C", str(project_root), "status", "--short"])
216
+ if result.returncode != 0:
217
+ return _fail("git clean tree", _command_detail(result))
218
+ if result.stdout.strip():
219
+ return _fail("git clean tree", result.stdout.strip().splitlines()[0])
220
+ return _ok("git clean tree", "working tree clean")
221
+
222
+
223
+ def _git_remote_check(project_root: Path) -> DoctorCheck:
224
+ result = _run(["git", "-C", str(project_root), "remote", "get-url", "origin"])
225
+ if result.returncode == 0 and result.stdout.strip():
226
+ return _ok("git remote", result.stdout.strip())
227
+ return _fail("git remote", _command_detail(result))
228
+
229
+
230
+ def _feature_branch_check(project_root: Path) -> DoctorCheck:
231
+ result = _run(["git", "-C", str(project_root), "rev-parse", "--abbrev-ref", "HEAD"])
232
+ branch = result.stdout.strip()
233
+ if result.returncode != 0 or not branch:
234
+ return _fail("feature branch", _command_detail(result))
235
+ if branch == "HEAD":
236
+ return _fail("feature branch", "detached HEAD")
237
+ if branch in _BASE_BRANCHES:
238
+ return _fail("feature branch", f"current branch is base branch: {branch}")
239
+ return _ok("feature branch", branch)
240
+
241
+
242
+ def _lens_whitelist_check() -> DoctorCheck:
243
+ lenses = improvement_lenses.LENSES
244
+ if lenses and all(improvement_lenses.is_valid_lens(lens) for lens in lenses):
245
+ return _ok("lens whitelist", ",".join(lenses))
246
+ return _fail("lens whitelist", "invalid or empty lens whitelist")
247
+
248
+
249
+ def _file_check(name: str, path: Path) -> DoctorCheck:
250
+ if path.is_file():
251
+ return _ok(name, str(path))
252
+ return _fail(name, f"not found: {path}")
253
+
254
+
255
+ def _agent_path(home: Path, worker: str) -> Path:
256
+ return home / ".claude" / "agents" / f"{worker}-worker.md"
257
+
258
+
259
+ def _profile_path(workspace: Path, phase: str) -> Path:
260
+ return workspace / "prompts" / "profiles" / f"{phase}.md"
261
+
262
+
263
+ def _run(
264
+ args: Iterable[str],
265
+ *,
266
+ cwd: Path | None = None,
267
+ ) -> subprocess.CompletedProcess[str]:
268
+ try:
269
+ return subprocess.run(
270
+ list(args),
271
+ cwd=str(cwd) if cwd else None,
272
+ capture_output=True,
273
+ text=True,
274
+ check=False,
275
+ )
276
+ except (OSError, FileNotFoundError) as exc:
277
+ return subprocess.CompletedProcess(list(args), 127, "", str(exc))
278
+
279
+
280
+ def _command_detail(result: subprocess.CompletedProcess[str]) -> str:
281
+ output = (result.stderr or result.stdout).strip()
282
+ if output:
283
+ return output.splitlines()[-1]
284
+ return f"command exited {result.returncode}"
285
+
286
+
287
+ def _ok(name: str, detail: str) -> DoctorCheck:
288
+ return DoctorCheck(name=name, ok=True, detail=detail)
289
+
290
+
291
+ def _fail(name: str, detail: str) -> DoctorCheck:
292
+ return DoctorCheck(name=name, ok=False, detail=detail)
@@ -0,0 +1,72 @@
1
+ """Lead runtime metadata shared by render and prepare paths."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LeadRuntimeInfo:
9
+ runtime: str
10
+ agent: str
11
+ agent_label: str
12
+ role: str
13
+ adapter_name: str
14
+ adapter_dispatch_mode: str
15
+ session_accounting: str
16
+ has_claude_session: bool
17
+
18
+ def adapter_payload(self) -> dict[str, str]:
19
+ return {
20
+ "name": self.adapter_name,
21
+ "dispatchMode": self.adapter_dispatch_mode,
22
+ "sessionAccounting": self.session_accounting,
23
+ }
24
+
25
+
26
+ ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex", "external")
27
+ ARTIFACT_ONLY_LEAD_RUNTIMES = frozenset({"codex", "external"})
28
+
29
+ _LEAD_RUNTIMES = {
30
+ "claude-code": LeadRuntimeInfo(
31
+ runtime="claude-code",
32
+ agent="claude",
33
+ agent_label="Claude Code",
34
+ role="Claude lead",
35
+ adapter_name="claude-code",
36
+ adapter_dispatch_mode="team",
37
+ session_accounting="claude-jsonl",
38
+ has_claude_session=True,
39
+ ),
40
+ "codex": LeadRuntimeInfo(
41
+ runtime="codex",
42
+ agent="codex",
43
+ agent_label="Codex CLI",
44
+ role="Codex lead",
45
+ adapter_name="codex",
46
+ adapter_dispatch_mode="render-only",
47
+ session_accounting="artifact-only",
48
+ has_claude_session=False,
49
+ ),
50
+ "external": LeadRuntimeInfo(
51
+ runtime="external",
52
+ agent="external",
53
+ agent_label="External harness",
54
+ role="Okstra lead",
55
+ adapter_name="external",
56
+ adapter_dispatch_mode="render-only",
57
+ session_accounting="artifact-only",
58
+ has_claude_session=False,
59
+ ),
60
+ }
61
+
62
+
63
+ def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
64
+ try:
65
+ return _LEAD_RUNTIMES[runtime]
66
+ except KeyError as exc:
67
+ allowed = ", ".join(ALLOWED_LEAD_RUNTIMES)
68
+ raise ValueError(f"unsupported lead runtime: {runtime} (allowed: {allowed})") from exc
69
+
70
+
71
+ def is_artifact_only_runtime(runtime: str) -> bool:
72
+ return runtime in ARTIFACT_ONLY_LEAD_RUNTIMES
@@ -28,6 +28,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
28
28
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
29
29
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
30
30
  from . import fix_cycles
31
+ from .lead_runtime import lead_runtime_info
31
32
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
32
33
 
33
34
 
@@ -70,31 +71,24 @@ def _lead_runtime(ctx: dict) -> str:
70
71
  return ctx.get("LEAD_RUNTIME", "") or "claude-code"
71
72
 
72
73
 
74
+ def _lead_info(ctx: dict):
75
+ return lead_runtime_info(_lead_runtime(ctx))
76
+
77
+
73
78
  def _lead_agent(ctx: dict) -> str:
74
- return "codex" if _lead_runtime(ctx) == "codex" else "claude"
79
+ return _lead_info(ctx).agent
75
80
 
76
81
 
77
82
  def _lead_agent_label(ctx: dict) -> str:
78
- return "Codex CLI" if _lead_runtime(ctx) == "codex" else "Claude Code"
83
+ return _lead_info(ctx).agent_label
79
84
 
80
85
 
81
86
  def _lead_role(ctx: dict) -> str:
82
- return "Codex lead" if _lead_runtime(ctx) == "codex" else "Claude lead"
87
+ return _lead_info(ctx).role
83
88
 
84
89
 
85
90
  def _lead_adapter(ctx: dict) -> dict:
86
- runtime = _lead_runtime(ctx)
87
- if runtime == "codex":
88
- return {
89
- "name": "codex",
90
- "dispatchMode": "render-only",
91
- "sessionAccounting": "artifact-only",
92
- }
93
- return {
94
- "name": "claude-code",
95
- "dispatchMode": "team",
96
- "sessionAccounting": "claude-jsonl",
97
- }
91
+ return _lead_info(ctx).adapter_payload()
98
92
 
99
93
 
100
94
  _PHASE_BLOCK_RE = re.compile(
@@ -1734,6 +1728,16 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1734
1728
  f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1735
1729
  f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1736
1730
  )
1731
+ team_dispatch_command = (
1732
+ "okstra team dispatch "
1733
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1734
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1735
+ )
1736
+ team_await_command = (
1737
+ "okstra team await "
1738
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1739
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1740
+ )
1737
1741
  if task_type == "release-handoff" or not selected:
1738
1742
  team_creation_gate_block = (
1739
1743
  "## Single-Lead Phase (no team creation)\n"
@@ -1773,6 +1777,27 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1773
1777
  "worker prompt histories when missing, persists worker results, and\n"
1774
1778
  "runs post-report validation for the Codex report-writer path."
1775
1779
  )
1780
+ elif lead_runtime == "external":
1781
+ team_creation_gate_block = (
1782
+ "## Tmux Worker Dispatch Gate (BLOCKING)\n"
1783
+ "\n"
1784
+ "This run was prepared for `leadRuntime=external`. Do NOT call "
1785
+ "`TeamCreate`, do NOT call Claude Code `Agent(...)`, and do NOT call "
1786
+ "`okstra codex-dispatch`. This adapter dispatches workers through "
1787
+ "okstra-owned tmux panes.\n"
1788
+ "\n"
1789
+ "Required actions, in order:\n"
1790
+ "1. Inspect the run manifest and team-state paths listed below.\n"
1791
+ "2. Emit `PROGRESS: phase-3-team-create skipped (tmux-pane)`; there is no Claude Teams surface in this adapter.\n"
1792
+ "3. Plan worker execution with:\n"
1793
+ f" `{team_dispatch_command} --dry-run`\n"
1794
+ "4. Dispatch workers with:\n"
1795
+ f" `{team_dispatch_command}`\n"
1796
+ "5. Arm one background wait using this harness's async shell primitive:\n"
1797
+ f" `{team_await_command}`\n"
1798
+ "6. On wake, read team-state. Worker completion is valid only from "
1799
+ "`workerDispatches[]` plus result files; pane creation alone is not completion."
1800
+ )
1776
1801
  elif task_type == "implementation" and concurrent_stages:
1777
1802
  team_creation_gate_block = (
1778
1803
  "## Concurrent-run: no-team background (BLOCKING)\n"
@@ -1852,7 +1877,33 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1852
1877
  "response is to go back to step 2 — NOT to strip `team_name` and retry."
1853
1878
  )
1854
1879
 
1855
- if lead_runtime == "codex":
1880
+ if lead_runtime == "external":
1881
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1882
+ lead_bootstrap_instruction = (
1883
+ "Read the manifests below for all task metadata, paths, model "
1884
+ "assignments, and worker roster. Use the tmux worker dispatch gate "
1885
+ "in this prompt; do not invoke Claude Code skills."
1886
+ )
1887
+ lead_session_block = (
1888
+ "## Session\n"
1889
+ "\n"
1890
+ "- Lead runtime: `external`\n"
1891
+ "- Lead session accounting: artifact-only; this harness does not expose Claude Code jsonl session IDs."
1892
+ )
1893
+ worker_dispatch_guidance = (
1894
+ "## Tmux Worker Dispatch\n"
1895
+ "\n"
1896
+ "- Worker prompt paths in `task-manifest.json` are assigned history "
1897
+ "locations. `okstra team dispatch` materializes missing prompt files "
1898
+ "from the run manifest and active-run-context; existing prompt files "
1899
+ "are never overwritten.\n"
1900
+ "- Do not call `TeamCreate`, `Agent(...)`, or `okstra codex-dispatch`. "
1901
+ "Use `okstra team dispatch` and `okstra team await` from the gate "
1902
+ "above for worker execution.\n"
1903
+ "- File presence is not a signal to skip. Worker selection and skipped "
1904
+ "statuses come from `team-state`, never from the prompts directory."
1905
+ )
1906
+ elif lead_runtime == "codex":
1856
1907
  lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1857
1908
  lead_bootstrap_instruction = (
1858
1909
  "Read the manifests below for all task metadata, paths, model "
@@ -40,6 +40,7 @@ from .material import (
40
40
  )
41
41
  from .final_report_schema import load_schema
42
42
  from .lead_events import LeadEvent, append_lead_event
43
+ from .lead_runtime import ALLOWED_LEAD_RUNTIMES
43
44
  from .models import ModelAssignment, resolve_model_metadata
44
45
  from .schema_excerpt import build_schema_excerpt
45
46
  from .path_resolve import relative_to_project_root, resolve_user_file
@@ -81,7 +82,7 @@ from .workers import (
81
82
  resolve_profile_workers,
82
83
  validate_workers_against_profile,
83
84
  )
84
- from .workflow import compute_workflow_state
85
+ from .workflow import compute_workflow_state, load_phase_forbidden
85
86
  from .locks import worktree_provision_mutex
86
87
  from .worktree import (
87
88
  WorktreeProvision,
@@ -267,7 +268,7 @@ class PrepareError(Exception):
267
268
  """surface to caller — task bundle prepare failed."""
268
269
 
269
270
 
270
- _ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex")
271
+ _ALLOWED_LEAD_RUNTIMES = ALLOWED_LEAD_RUNTIMES
271
272
 
272
273
 
273
274
  def _normalize_lead_runtime(value: str) -> str:
@@ -945,14 +946,14 @@ def _brief_sha256(path: Path) -> str:
945
946
  return ""
946
947
 
947
948
 
948
- def _record_codex_render_only_event(inp: PrepareInputs, ctx: dict) -> None:
949
- if inp.lead_runtime != "codex" or not inp.render_only:
949
+ def _record_artifact_runtime_render_only_event(inp: PrepareInputs, ctx: dict) -> None:
950
+ if inp.lead_runtime not in ("codex", "external") or not inp.render_only:
950
951
  return
951
952
  append_lead_event(
952
953
  Path(ctx["LEAD_EVENTS_PATH"]),
953
954
  LeadEvent(
954
955
  event_type="bundle-prepared",
955
- lead_runtime="codex",
956
+ lead_runtime=inp.lead_runtime,
956
957
  task_key=ctx["TASK_KEY"],
957
958
  task_type=ctx["TASK_TYPE"],
958
959
  run_seq=ctx["RUN_MANIFESTS_SEQ"],
@@ -1807,6 +1808,7 @@ def _write_instruction_set_sources(
1807
1808
  "EXECUTOR_WORKTREE_BASE_REF",
1808
1809
  "EXECUTOR_WORKTREE_STATUS",
1809
1810
  "EXECUTOR_WORKTREE_NOTE",
1811
+ "PHASE_FORBIDDEN_ACTIONS",
1810
1812
  ):
1811
1813
  profile_rendered = profile_rendered.replace("{{" + key + "}}", ctx.get(key, ""))
1812
1814
  (instruction_set / "analysis-profile.md").write_text(profile_rendered, encoding="utf-8")
@@ -1997,7 +1999,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
1997
1999
 
1998
2000
 
1999
2001
  def _finalize_status_and_render_manifests(
2000
- inp: PrepareInputs, ctx: dict, task_index_template: Path
2002
+ inp: PrepareInputs, ctx: dict, task_index_template: Path,
2003
+ forbidden_by_phase: dict[str, str],
2001
2004
  ) -> None:
2002
2005
  """최종 task/run status 를 확정하고 workflow state 를 재계산한 뒤 team-state,
2003
2006
  task-manifest, task-index, run-manifest, timeline, discovery 산출물을 렌더한다."""
@@ -2014,6 +2017,7 @@ def _finalize_status_and_render_manifests(
2014
2017
  current_run_status=ctx["CURRENT_RUN_STATUS"],
2015
2018
  current_task_status=ctx["CURRENT_TASK_STATUS"],
2016
2019
  render_only=inp.render_only,
2020
+ forbidden_by_phase=forbidden_by_phase,
2017
2021
  work_category=inp.work_category,
2018
2022
  ))
2019
2023
  render_team_state(ctx["TEAM_STATE_PATH"], ctx)
@@ -2054,7 +2058,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2054
2058
  if lead_runtime != "claude-code" and not inp.render_only:
2055
2059
  raise PrepareError(
2056
2060
  f"lead runtime `{lead_runtime}` is currently render-only; "
2057
- "use --render-only until the Codex dispatch adapter is implemented."
2061
+ "use --render-only until a dispatch backend is enabled for this lead runtime."
2058
2062
  )
2059
2063
 
2060
2064
  # ---- input validation + asset resolution + roster/model 해소 ----
@@ -2229,11 +2233,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2229
2233
  # ---- initial workflow state (current_run_status not yet known) ----
2230
2234
  initial_task_status = "ready-for-claude"
2231
2235
  initial_run_status = "not-run"
2236
+ forbidden_by_phase = load_phase_forbidden(workspace_root)
2232
2237
  workflow_state = compute_workflow_state(
2233
2238
  task_type=inp.task_type,
2234
2239
  current_run_status=initial_run_status,
2235
2240
  current_task_status=initial_task_status,
2236
2241
  render_only=inp.render_only,
2242
+ forbidden_by_phase=forbidden_by_phase,
2237
2243
  )
2238
2244
 
2239
2245
  # ---- assemble full ctx (the values render functions expect) ----
@@ -2329,8 +2335,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2329
2335
  _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
2330
2336
 
2331
2337
  # ---- final status + manifest/discovery renders ----
2332
- _finalize_status_and_render_manifests(inp, ctx, task_index_template)
2333
- _record_codex_render_only_event(inp, ctx)
2338
+ _finalize_status_and_render_manifests(
2339
+ inp, ctx, task_index_template, forbidden_by_phase
2340
+ )
2341
+ _record_artifact_runtime_render_only_event(inp, ctx)
2334
2342
 
2335
2343
  # ---- central index ----
2336
2344
  initial_status = "prepared" if inp.render_only else "running"
@@ -2411,9 +2419,9 @@ def main(argv: list[str]) -> int:
2411
2419
  choices=list(_ALLOWED_LEAD_RUNTIMES),
2412
2420
  dest="lead_runtime",
2413
2421
  help=(
2414
- "Lead runtime adapter. Default: claude-code. `codex` is accepted "
2415
- "for render-only artifact preparation while the dispatch adapter "
2416
- "is implemented."
2422
+ "Lead runtime adapter. Default: claude-code. `codex` renders "
2423
+ "Codex CLI artifact bundles; `external` renders neutral bootstrap "
2424
+ "bundles for manifest inspection until a dispatch backend is enabled."
2417
2425
  ),
2418
2426
  )
2419
2427
  p.add_argument("--executor", default="")