okstra 0.76.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.
- package/README.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +1 -117
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +22 -5
- package/docs/pr-template-usage.md +3 -3
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -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/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra-trace-cleanup.sh +16 -12
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -17
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
- 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_events.py +129 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +211 -29
- package/runtime/python/okstra_ctl/run.py +89 -18
- 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/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- 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-setup/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +112 -22
- package/runtime/validators/validate_session_conformance.py +127 -5
- package/src/cli-registry.mjs +277 -0
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +130 -5
- package/src/install.mjs +123 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +29 -0
- package/src/team.mjs +63 -0
- package/src/uninstall.mjs +27 -4
- /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -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,129 @@
|
|
|
1
|
+
"""Structured JSONL events emitted by non-Claude lead runtimes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
REQUIRED_FIELDS = (
|
|
11
|
+
"eventType",
|
|
12
|
+
"leadRuntime",
|
|
13
|
+
"taskKey",
|
|
14
|
+
"taskType",
|
|
15
|
+
"runSeq",
|
|
16
|
+
"timestamp",
|
|
17
|
+
"details",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LeadEventParseError(ValueError):
|
|
22
|
+
"""Raised when a lead-events JSONL file contains an invalid row."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path, line_number: int, message: str) -> None:
|
|
25
|
+
super().__init__(f"{path}:{line_number}: {message}")
|
|
26
|
+
self.path = path
|
|
27
|
+
self.line_number = line_number
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class LeadEvent:
|
|
32
|
+
event_type: str
|
|
33
|
+
lead_runtime: str
|
|
34
|
+
task_key: str
|
|
35
|
+
task_type: str
|
|
36
|
+
run_seq: str
|
|
37
|
+
timestamp: str
|
|
38
|
+
details: Mapping[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def to_record(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"eventType": self.event_type,
|
|
43
|
+
"leadRuntime": self.lead_runtime,
|
|
44
|
+
"taskKey": self.task_key,
|
|
45
|
+
"taskType": self.task_type,
|
|
46
|
+
"runSeq": self.run_seq,
|
|
47
|
+
"timestamp": self.timestamp,
|
|
48
|
+
"details": _stable_json_value(dict(self.details)),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_record(cls, record: Mapping[str, Any]) -> "LeadEvent":
|
|
53
|
+
missing = [field_name for field_name in REQUIRED_FIELDS
|
|
54
|
+
if field_name not in record]
|
|
55
|
+
if missing:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"missing required lead event field(s): " + ", ".join(missing)
|
|
58
|
+
)
|
|
59
|
+
details = record["details"]
|
|
60
|
+
if not isinstance(details, Mapping):
|
|
61
|
+
raise ValueError("lead event details must be a JSON object")
|
|
62
|
+
return cls(
|
|
63
|
+
event_type=_require_string(record, "eventType"),
|
|
64
|
+
lead_runtime=_require_string(record, "leadRuntime"),
|
|
65
|
+
task_key=_require_string(record, "taskKey"),
|
|
66
|
+
task_type=_require_string(record, "taskType"),
|
|
67
|
+
run_seq=_require_string(record, "runSeq"),
|
|
68
|
+
timestamp=_require_string(record, "timestamp"),
|
|
69
|
+
details=dict(details),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def append_lead_event(path: Path, event: LeadEvent) -> None:
|
|
74
|
+
"""Append one lead event as compact JSONL, creating parent directories."""
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
with path.open("a", encoding="utf-8") as f:
|
|
77
|
+
f.write(_event_json(event) + "\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_lead_events(path: Path) -> list[LeadEvent]:
|
|
81
|
+
"""Parse a lead-events JSONL file.
|
|
82
|
+
|
|
83
|
+
Missing files read as an empty log. Blank lines are ignored. Malformed JSON
|
|
84
|
+
and malformed event objects fail loudly with path and line number context.
|
|
85
|
+
"""
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return []
|
|
88
|
+
events: list[LeadEvent] = []
|
|
89
|
+
for line_number, line in enumerate(
|
|
90
|
+
path.read_text(encoding="utf-8").splitlines(), start=1
|
|
91
|
+
):
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
if not stripped:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
record = json.loads(stripped)
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
raise LeadEventParseError(path, line_number, "invalid JSON") from exc
|
|
99
|
+
if not isinstance(record, Mapping):
|
|
100
|
+
raise LeadEventParseError(
|
|
101
|
+
path, line_number, "lead event row must be a JSON object"
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
events.append(LeadEvent.from_record(record))
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
raise LeadEventParseError(path, line_number, str(exc)) from exc
|
|
107
|
+
return events
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _event_json(event: LeadEvent) -> str:
|
|
111
|
+
return json.dumps(event.to_record(), ensure_ascii=False, separators=(",", ":"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _require_string(record: Mapping[str, Any], field_name: str) -> str:
|
|
115
|
+
value = record[field_name]
|
|
116
|
+
if not isinstance(value, str) or not value:
|
|
117
|
+
raise ValueError(f"lead event {field_name} must be a non-empty string")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _stable_json_value(value: Any) -> Any:
|
|
122
|
+
if isinstance(value, Mapping):
|
|
123
|
+
return {
|
|
124
|
+
key: _stable_json_value(value[key])
|
|
125
|
+
for key in sorted(value)
|
|
126
|
+
}
|
|
127
|
+
if isinstance(value, list):
|
|
128
|
+
return [_stable_json_value(item) for item in value]
|
|
129
|
+
return value
|
|
@@ -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
|
|
@@ -175,6 +175,7 @@ def compute_run_paths(
|
|
|
175
175
|
final_status = run_status / f"final{suffixes['status']}.status"
|
|
176
176
|
team_state = run_state / f"team-state{suffixes['state']}.json"
|
|
177
177
|
active_run_context = run_state / f"active-run-context{suffixes['state']}.json"
|
|
178
|
+
lead_events = run_state / f"lead-events-{task_type_segment}-{seqs['state']}.jsonl"
|
|
178
179
|
final_report_template = instruction_set / "final-report-template.md"
|
|
179
180
|
final_report_schema = instruction_set / "final-report-schema.json"
|
|
180
181
|
reference_expectations = instruction_set / "reference-expectations.md"
|
|
@@ -241,6 +242,7 @@ def compute_run_paths(
|
|
|
241
242
|
"FINAL_STATUS_PATH": str(final_status),
|
|
242
243
|
"TEAM_STATE_PATH": str(team_state),
|
|
243
244
|
"ACTIVE_RUN_CONTEXT_PATH": str(active_run_context),
|
|
245
|
+
"LEAD_EVENTS_PATH": str(lead_events),
|
|
244
246
|
"FINAL_REPORT_TEMPLATE_PATH": str(final_report_template),
|
|
245
247
|
"FINAL_REPORT_SCHEMA_PATH": str(final_report_schema),
|
|
246
248
|
"REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
|
|
@@ -301,6 +303,7 @@ def compute_run_paths(
|
|
|
301
303
|
("FINAL_STATUS_RELATIVE_PATH", final_status),
|
|
302
304
|
("TEAM_STATE_RELATIVE_PATH", team_state),
|
|
303
305
|
("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", active_run_context),
|
|
306
|
+
("LEAD_EVENTS_RELATIVE_PATH", lead_events),
|
|
304
307
|
("WORKER_RESULTS_RELATIVE_PATH", worker_results),
|
|
305
308
|
("RUN_CARRY_RELATIVE_PATH", run_carry),
|
|
306
309
|
("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
|
|
@@ -6,7 +6,7 @@ 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: 스킬 설치 디렉터리의 ``templates/prd/pr-body.template.md``
|
|
10
10
|
|
|
11
11
|
경로는 절대경로 또는 ``~`` 시작 경로를 권장한다. 상대경로일 경우 project
|
|
12
12
|
스코프는 ``project_root`` 기준, override 는 호출자 cwd 기준으로 해석한다.
|