okstra 0.77.0 → 0.78.1
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.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -125
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/cli.md +5 -4
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
- package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
- package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
- package/package.json +5 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/DO_NOT_EDIT.md +21 -0
- package/runtime/agents/SKILL.md +3 -0
- package/runtime/bin/okstra-trace-cleanup.sh +16 -12
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/release-handoff.md +1 -15
- package/runtime/python/okstra_ctl/codex_dispatch.py +70 -2
- package/runtime/python/okstra_ctl/context_cost.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
- package/runtime/python/okstra_ctl/doctor.py +292 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
- package/runtime/python/okstra_ctl/pr_template.py +4 -3
- package/runtime/python/okstra_ctl/render.py +67 -16
- package/runtime/python/okstra_ctl/run.py +20 -12
- package/runtime/python/okstra_ctl/team.py +267 -0
- package/runtime/python/okstra_ctl/tmux.py +181 -10
- package/runtime/python/okstra_ctl/workflow.py +30 -71
- package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
- package/runtime/schemas/final-report-v1.0.schema.json +3 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
- package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +65 -9
- package/runtime/validators/validate_session_conformance.py +15 -10
- package/src/cli-registry.mjs +277 -0
- package/src/doctor.mjs +93 -4
- package/src/install.mjs +6 -5
- package/src/runtime-manifest.mjs +5 -2
- package/src/team.mjs +63 -0
- 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
|
|
@@ -6,7 +6,8 @@ release-handoff 단계에서 lead 가 PR 본문을 작성할 때 사용하는
|
|
|
6
6
|
1. per-run override (okstra-run Step 6 에서 입력)
|
|
7
7
|
2. project: <project_root>/.okstra/project.json 의 ``prTemplatePath``
|
|
8
8
|
3. global: ~/.okstra/config.json 의 ``prTemplatePath``
|
|
9
|
-
4. default:
|
|
9
|
+
4. default: ``$OKSTRA_HOME/templates/prd/pr-body.template.md`` (구버전
|
|
10
|
+
``~/.claude/skills/...`` 후보는 backward-compat 로 유지)
|
|
10
11
|
|
|
11
12
|
경로는 절대경로 또는 ``~`` 시작 경로를 권장한다. 상대경로일 경우 project
|
|
12
13
|
스코프는 ``project_root`` 기준, override 는 호출자 cwd 기준으로 해석한다.
|
|
@@ -40,6 +41,7 @@ def _default_template_candidates() -> list[Path]:
|
|
|
40
41
|
env_dir = os.environ.get("OKSTRA_SKILLS_DIR", "").strip()
|
|
41
42
|
if env_dir:
|
|
42
43
|
out.append(Path(env_dir) / "okstra-run" / "templates" / _DEFAULT_FILENAME)
|
|
44
|
+
out.append(okstra_home() / "templates" / "prd" / _DEFAULT_FILENAME)
|
|
43
45
|
out.append(
|
|
44
46
|
Path.home() / ".claude" / "skills" / "okstra-run" / "templates" / _DEFAULT_FILENAME
|
|
45
47
|
)
|
|
@@ -114,9 +116,8 @@ def resolve_pr_template_path(
|
|
|
114
116
|
for cand in _default_template_candidates():
|
|
115
117
|
if cand.is_file():
|
|
116
118
|
return ResolvedPrTemplate(path=cand, source="default")
|
|
117
|
-
|
|
118
119
|
raise PrTemplateError(
|
|
119
|
-
"no PR template available: default
|
|
120
|
+
"no PR template available: default template not found. "
|
|
120
121
|
f"Searched: {', '.join(str(c) for c in _default_template_candidates())}. "
|
|
121
122
|
"Reinstall okstra (`npx okstra install`) or set prTemplatePath in "
|
|
122
123
|
"project.json / ~/.okstra/config.json."
|
|
@@ -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
|
|
79
|
+
return _lead_info(ctx).agent
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
def _lead_agent_label(ctx: dict) -> str:
|
|
78
|
-
return
|
|
83
|
+
return _lead_info(ctx).agent_label
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
def _lead_role(ctx: dict) -> str:
|
|
82
|
-
return
|
|
87
|
+
return _lead_info(ctx).role
|
|
83
88
|
|
|
84
89
|
|
|
85
90
|
def _lead_adapter(ctx: dict) -> dict:
|
|
86
|
-
|
|
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 == "
|
|
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 =
|
|
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
|
|
949
|
-
if inp.lead_runtime
|
|
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=
|
|
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
|
|
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(
|
|
2333
|
-
|
|
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`
|
|
2415
|
-
"
|
|
2416
|
-
"is
|
|
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="")
|