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,267 @@
|
|
|
1
|
+
"""Neutral okstra team CLI for external tmux-pane worker dispatch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from . import tmux
|
|
11
|
+
from .dispatch_core import (
|
|
12
|
+
BACKEND_TMUX_PANE,
|
|
13
|
+
DispatchError,
|
|
14
|
+
DispatchPlan,
|
|
15
|
+
await_dispatches,
|
|
16
|
+
build_dispatch_plan,
|
|
17
|
+
dispatch_plan,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_SUPPORTED_WRAPPERS = {
|
|
21
|
+
"claude": "okstra-claude-exec.sh",
|
|
22
|
+
"codex": "okstra-codex-exec.sh",
|
|
23
|
+
"gemini": "okstra-gemini-exec.sh",
|
|
24
|
+
"report-writer": "okstra-claude-exec.sh",
|
|
25
|
+
}
|
|
26
|
+
_TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
30
|
+
parser = _parser()
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
try:
|
|
33
|
+
if args.command == "dispatch":
|
|
34
|
+
return _dispatch(args)
|
|
35
|
+
if args.command == "await":
|
|
36
|
+
return _await(args)
|
|
37
|
+
if args.command == "teardown":
|
|
38
|
+
return _teardown(args)
|
|
39
|
+
except DispatchError as exc:
|
|
40
|
+
print(f"okstra team: {exc}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
parser.print_help()
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(prog="okstra team")
|
|
48
|
+
parser.add_argument("--workspace-root", required=True)
|
|
49
|
+
parser.add_argument("--okstra-bin", required=True)
|
|
50
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
51
|
+
_add_dispatch_parser(sub)
|
|
52
|
+
_add_await_parser(sub)
|
|
53
|
+
_add_teardown_parser(sub)
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _add_dispatch_parser(sub) -> None:
|
|
58
|
+
parser = sub.add_parser("dispatch", help="dispatch tmux-pane workers")
|
|
59
|
+
_add_run_args(parser)
|
|
60
|
+
parser.add_argument("--workers", default="")
|
|
61
|
+
parser.add_argument("--jobs-file", default="")
|
|
62
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
63
|
+
parser.add_argument("--idle-timeout-seconds", type=int, default=600)
|
|
64
|
+
parser.add_argument("--dispatch-kind", default="initial")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _add_await_parser(sub) -> None:
|
|
68
|
+
parser = sub.add_parser("await", help="wait for tmux-pane workers")
|
|
69
|
+
_add_run_args(parser)
|
|
70
|
+
parser.add_argument("--poll-interval-seconds", type=int, default=5)
|
|
71
|
+
parser.add_argument("--timeout-seconds", type=int, default=None)
|
|
72
|
+
parser.add_argument("--heartbeat-seconds", type=int, default=30)
|
|
73
|
+
parser.add_argument("--json", action="store_true")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _add_teardown_parser(sub) -> None:
|
|
77
|
+
parser = sub.add_parser("teardown", help="kill tmux-pane workers")
|
|
78
|
+
_add_run_args(parser)
|
|
79
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
80
|
+
parser.add_argument("--json", action="store_true")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _add_run_args(parser) -> None:
|
|
84
|
+
parser.add_argument("--project-root", required=True)
|
|
85
|
+
parser.add_argument("--run-manifest", required=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _dispatch(args) -> int:
|
|
89
|
+
if args.jobs_file and args.workers:
|
|
90
|
+
raise DispatchError("--jobs-file and --workers cannot be combined")
|
|
91
|
+
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
92
|
+
_validate_external_manifest(manifest)
|
|
93
|
+
requested = _parse_workers(args.workers)
|
|
94
|
+
plan = build_dispatch_plan(
|
|
95
|
+
project_root=Path(args.project_root),
|
|
96
|
+
run_manifest_path=Path(args.run_manifest),
|
|
97
|
+
workspace_root=Path(args.workspace_root),
|
|
98
|
+
okstra_bin=Path(args.okstra_bin),
|
|
99
|
+
requested_workers=requested,
|
|
100
|
+
idle_timeout_seconds=args.idle_timeout_seconds,
|
|
101
|
+
enable_report_writer="report-writer" in requested,
|
|
102
|
+
required_lead_runtime="external",
|
|
103
|
+
default_backend=BACKEND_TMUX_PANE,
|
|
104
|
+
supported_worker_wrappers=_SUPPORTED_WRAPPERS,
|
|
105
|
+
unsupported_worker_label="external lead",
|
|
106
|
+
dispatch_kind=args.dispatch_kind,
|
|
107
|
+
jobs_file=Path(args.jobs_file) if args.jobs_file else None,
|
|
108
|
+
)
|
|
109
|
+
if args.dry_run:
|
|
110
|
+
_print_json(plan.to_payload(dry_run=True))
|
|
111
|
+
return 0
|
|
112
|
+
result = dispatch_plan(plan, wait=False)
|
|
113
|
+
_print_json(plan.to_payload(dry_run=False))
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _await(args) -> int:
|
|
118
|
+
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
119
|
+
_validate_external_manifest(manifest)
|
|
120
|
+
plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
|
|
121
|
+
code = await_dispatches(
|
|
122
|
+
plan,
|
|
123
|
+
poll_interval_seconds=args.poll_interval_seconds,
|
|
124
|
+
timeout_seconds=args.timeout_seconds,
|
|
125
|
+
heartbeat_seconds=0 if args.json else args.heartbeat_seconds,
|
|
126
|
+
)
|
|
127
|
+
if args.json:
|
|
128
|
+
_print_json(_await_payload(plan, code == 0))
|
|
129
|
+
else:
|
|
130
|
+
print("ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
|
|
131
|
+
return code
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _teardown(args) -> int:
|
|
135
|
+
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
136
|
+
_validate_external_manifest(manifest)
|
|
137
|
+
project_root = Path(args.project_root).resolve()
|
|
138
|
+
team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
|
|
139
|
+
team_state = _load_json(team_state_path, "team-state")
|
|
140
|
+
run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
|
|
141
|
+
lead_pane = tmux.resolve_caller_pane()
|
|
142
|
+
panes = _teardown_panes(team_state, run_dir, lead_pane)
|
|
143
|
+
if args.dry_run:
|
|
144
|
+
_emit_teardown(args.json, panes)
|
|
145
|
+
return 0
|
|
146
|
+
for pane in panes:
|
|
147
|
+
tmux.kill_pane(pane["paneId"])
|
|
148
|
+
_mark_teardown_errors(team_state_path)
|
|
149
|
+
_emit_teardown(args.json, panes)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _plan_for_existing(
|
|
154
|
+
project_root: Path, workspace_root: Path, run_manifest_path: Path, manifest: Mapping[str, Any]
|
|
155
|
+
) -> DispatchPlan:
|
|
156
|
+
root = project_root.resolve()
|
|
157
|
+
manifest_path = _resolve_project_path(root, str(run_manifest_path))
|
|
158
|
+
return DispatchPlan(
|
|
159
|
+
project_root=root,
|
|
160
|
+
workspace_root=workspace_root.resolve(),
|
|
161
|
+
manifest_path=manifest_path,
|
|
162
|
+
team_state_path=_resolve_project_path(root, _require_string(manifest, "teamStatePath")),
|
|
163
|
+
lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
|
|
164
|
+
manifest=manifest,
|
|
165
|
+
jobs=(),
|
|
166
|
+
default_backend=BACKEND_TMUX_PANE,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
171
|
+
team_state = _load_json(plan.team_state_path, "team-state")
|
|
172
|
+
dispatches = [d for d in team_state.get("workerDispatches", []) if isinstance(d, dict)]
|
|
173
|
+
timed_out = [d for d in dispatches if d.get("status") == "timeout"]
|
|
174
|
+
return {
|
|
175
|
+
"completed": completed,
|
|
176
|
+
"timedOut": len(timed_out),
|
|
177
|
+
"workers": dispatches,
|
|
178
|
+
"teamStatePath": str(plan.team_state_path),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _teardown_panes(team_state: Mapping[str, Any], run_dir: Path, lead_pane: str) -> list[dict[str, str]]:
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
panes: list[dict[str, str]] = []
|
|
185
|
+
for record in team_state.get("workerDispatches", []):
|
|
186
|
+
if isinstance(record, dict):
|
|
187
|
+
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
188
|
+
for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
|
|
189
|
+
_append_pane(panes, seen, pane.pane_id, pane.kind)
|
|
190
|
+
return panes
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _append_pane(panes: list[dict[str, str]], seen: set[str], pane_id: str, kind: str) -> None:
|
|
194
|
+
if pane_id and pane_id not in seen:
|
|
195
|
+
panes.append({"paneId": pane_id, "kind": kind})
|
|
196
|
+
seen.add(pane_id)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
200
|
+
payload = _load_json(team_state_path, "team-state")
|
|
201
|
+
for record in payload.get("workerDispatches", []):
|
|
202
|
+
if isinstance(record, dict) and record.get("status") not in _TERMINAL_STATUSES:
|
|
203
|
+
record["status"] = "error"
|
|
204
|
+
record["reason"] = "teardown before terminal status"
|
|
205
|
+
_write_json(team_state_path, payload)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
|
|
209
|
+
if as_json:
|
|
210
|
+
_print_json({"panes": panes})
|
|
211
|
+
return
|
|
212
|
+
for pane in panes:
|
|
213
|
+
print(f"{pane['paneId']}\t{pane['kind']}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
|
|
217
|
+
runtime = manifest.get("leadRuntime")
|
|
218
|
+
if runtime == "external":
|
|
219
|
+
return
|
|
220
|
+
if runtime == "codex":
|
|
221
|
+
raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
|
|
222
|
+
if runtime == "claude-code":
|
|
223
|
+
raise DispatchError("use Claude Code TeamCreate/Agent flow for leadRuntime=claude-code")
|
|
224
|
+
raise DispatchError(f"unsupported leadRuntime for okstra team: {runtime}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_manifest(project_root: str, run_manifest: str) -> dict[str, Any]:
|
|
228
|
+
root = Path(project_root).resolve()
|
|
229
|
+
path = _resolve_project_path(root, run_manifest)
|
|
230
|
+
return _load_json(path, "run manifest")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _load_json(path: Path, label: str) -> dict[str, Any]:
|
|
234
|
+
if not path.is_file():
|
|
235
|
+
raise DispatchError(f"{label} not found: {path}")
|
|
236
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
237
|
+
if not isinstance(payload, dict):
|
|
238
|
+
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
239
|
+
return payload
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
243
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
|
|
247
|
+
path = Path(value)
|
|
248
|
+
return path if path.is_absolute() else project_root / path
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
252
|
+
value = payload.get(key)
|
|
253
|
+
if not isinstance(value, str) or not value.strip():
|
|
254
|
+
raise DispatchError(f"required string missing: {key}")
|
|
255
|
+
return value.strip()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_workers(raw: str) -> list[str]:
|
|
259
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _print_json(payload: Mapping[str, Any]) -> None:
|
|
263
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -1,27 +1,198 @@
|
|
|
1
|
-
"""tmux
|
|
1
|
+
"""tmux command helpers for okstra-owned panes."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TmuxPane:
|
|
15
|
+
pane_id: str
|
|
16
|
+
title: str
|
|
17
|
+
run_dir: str
|
|
18
|
+
kind: str
|
|
5
19
|
|
|
6
20
|
|
|
7
21
|
def _shell_quote(s: str) -> str:
|
|
8
|
-
"""POSIX shell 안전 인용. shlex.quote 가 모든
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
"""POSIX shell 안전 인용. shlex.quote 가 모든 메타문자를 처리한다."""
|
|
23
|
+
return shlex.quote(s)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical_run_dir(run_dir: Path) -> str:
|
|
27
|
+
return str(run_dir.resolve())
|
|
11
28
|
|
|
12
29
|
|
|
13
30
|
def build_tmux_command(*, session_name: str, cwd: str, run_seq: int,
|
|
14
31
|
argv: list, okstra_script: str,
|
|
15
32
|
extra_env: Optional[dict] = None) -> list:
|
|
16
|
-
"""tmux new-session 명령을 list 형태로 반환.
|
|
17
|
-
extra_env 의 키는 inner 명령 앞에 KEY=VAL 형태로 prepend 된다 — tmux 세션은
|
|
18
|
-
호출 셸의 환경을 자동 상속하지 않으므로 OKSTRA_HOME 등은 명시 전달해야 한다.
|
|
19
|
-
"""
|
|
33
|
+
"""tmux new-session 명령을 list 형태로 반환."""
|
|
20
34
|
env_prefix = f"OKSTRA_RUN_SEQ_OVERRIDE={run_seq}"
|
|
21
35
|
if extra_env:
|
|
22
36
|
for k, v in extra_env.items():
|
|
23
37
|
env_prefix += f" {k}={_shell_quote(str(v))}"
|
|
24
|
-
# 스크립트 경로에 공백/메타문자가 있어도 안전하도록 동일 방식으로 인용.
|
|
25
38
|
inner = (f"{env_prefix} {_shell_quote(okstra_script)} "
|
|
26
39
|
+ " ".join(_shell_quote(a) for a in argv))
|
|
27
40
|
return ["tmux", "new-session", "-d", "-s", session_name, "-c", cwd, inner]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tmux_available() -> bool:
|
|
44
|
+
if shutil.which("tmux") is None:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
result = run_tmux(["display-message", "-p", "#{version}"], timeout=3)
|
|
48
|
+
except (OSError, subprocess.SubprocessError):
|
|
49
|
+
return False
|
|
50
|
+
return result.returncode == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_tmux(
|
|
54
|
+
args: Sequence[str], *, timeout: int = 10
|
|
55
|
+
) -> subprocess.CompletedProcess[str]:
|
|
56
|
+
return subprocess.run(
|
|
57
|
+
["tmux", *args],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
check=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_caller_pane(start_pid: int | None = None) -> str:
|
|
66
|
+
try:
|
|
67
|
+
panes = run_tmux(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"])
|
|
68
|
+
except (OSError, subprocess.SubprocessError):
|
|
69
|
+
return ""
|
|
70
|
+
if panes.returncode != 0 or not panes.stdout.strip():
|
|
71
|
+
return ""
|
|
72
|
+
pane_by_pid = _pane_ids_by_pid(panes.stdout)
|
|
73
|
+
pid = str(start_pid or os.getpid())
|
|
74
|
+
for _ in range(16):
|
|
75
|
+
if not pid or pid == "0":
|
|
76
|
+
return ""
|
|
77
|
+
if pid in pane_by_pid:
|
|
78
|
+
return pane_by_pid[pid]
|
|
79
|
+
pid = _parent_pid(pid)
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pane_ids_by_pid(output: str) -> dict[str, str]:
|
|
84
|
+
result: dict[str, str] = {}
|
|
85
|
+
for line in output.splitlines():
|
|
86
|
+
parts = line.split(maxsplit=1)
|
|
87
|
+
if len(parts) == 2:
|
|
88
|
+
result[parts[0]] = parts[1]
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parent_pid(pid: str) -> str:
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["ps", "-o", "ppid=", "-p", pid],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=3,
|
|
99
|
+
check=False,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.SubprocessError):
|
|
102
|
+
return ""
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
return ""
|
|
105
|
+
return result.stdout.strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def split_worker_pane(
|
|
109
|
+
*,
|
|
110
|
+
target_pane: str,
|
|
111
|
+
cwd: Path,
|
|
112
|
+
command: Sequence[str],
|
|
113
|
+
title: str,
|
|
114
|
+
run_dir: Path,
|
|
115
|
+
) -> str:
|
|
116
|
+
result = run_tmux(
|
|
117
|
+
[
|
|
118
|
+
"split-window",
|
|
119
|
+
"-h",
|
|
120
|
+
"-P",
|
|
121
|
+
"-F",
|
|
122
|
+
"#{pane_id}",
|
|
123
|
+
"-c",
|
|
124
|
+
str(cwd),
|
|
125
|
+
"-t",
|
|
126
|
+
target_pane,
|
|
127
|
+
shlex.join(command),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
raise RuntimeError(result.stderr.strip() or "tmux split-window failed")
|
|
132
|
+
pane_id = result.stdout.strip()
|
|
133
|
+
set_pane_title(pane_id, title)
|
|
134
|
+
tag_pane(pane_id, run_dir)
|
|
135
|
+
return pane_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_pane_title(pane_id: str, title: str) -> None:
|
|
139
|
+
result = run_tmux(["select-pane", "-t", pane_id, "-T", title])
|
|
140
|
+
if result.returncode != 0:
|
|
141
|
+
raise RuntimeError(result.stderr.strip() or "tmux select-pane failed")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tag_pane(
|
|
145
|
+
pane_id: str, run_dir: Path, *, option: str = "@okstra_worker_run"
|
|
146
|
+
) -> None:
|
|
147
|
+
result = run_tmux(
|
|
148
|
+
["set-option", "-p", "-t", pane_id, option, _canonical_run_dir(run_dir)]
|
|
149
|
+
)
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
raise RuntimeError(result.stderr.strip() or "tmux set-option failed")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def capture_pane(pane_id: str, *, last_lines: int = 200) -> str:
|
|
155
|
+
result = run_tmux(
|
|
156
|
+
["capture-pane", "-p", "-t", pane_id, "-S", f"-{last_lines}"]
|
|
157
|
+
)
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return ""
|
|
160
|
+
return result.stdout
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_run_panes(run_dir: Path, *, lead_pane: str = "") -> list[TmuxPane]:
|
|
164
|
+
canonical = _canonical_run_dir(run_dir)
|
|
165
|
+
try:
|
|
166
|
+
result = run_tmux(
|
|
167
|
+
[
|
|
168
|
+
"list-panes",
|
|
169
|
+
"-a",
|
|
170
|
+
"-F",
|
|
171
|
+
"#{pane_id}\t#{pane_title}\t#{@okstra_worker_run}\t#{@okstra_trace_run}",
|
|
172
|
+
]
|
|
173
|
+
)
|
|
174
|
+
except (OSError, subprocess.SubprocessError):
|
|
175
|
+
return []
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
return []
|
|
178
|
+
return _parse_run_panes(result.stdout, canonical, lead_pane)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_run_panes(output: str, canonical: str, lead_pane: str) -> list[TmuxPane]:
|
|
182
|
+
panes: list[TmuxPane] = []
|
|
183
|
+
for line in output.splitlines():
|
|
184
|
+
pane_id, title, worker_run, trace_run = (line.split("\t") + [""] * 4)[:4]
|
|
185
|
+
if not pane_id or pane_id == lead_pane:
|
|
186
|
+
continue
|
|
187
|
+
if worker_run == canonical:
|
|
188
|
+
panes.append(TmuxPane(pane_id, title, worker_run, "worker"))
|
|
189
|
+
elif trace_run == canonical:
|
|
190
|
+
panes.append(TmuxPane(pane_id, title, trace_run, "trace"))
|
|
191
|
+
return panes
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def kill_pane(pane_id: str) -> None:
|
|
195
|
+
try:
|
|
196
|
+
run_tmux(["kill-pane", "-t", pane_id])
|
|
197
|
+
except (OSError, subprocess.SubprocessError):
|
|
198
|
+
return
|
|
@@ -4,11 +4,14 @@ bash `compute_workflow_render_context` + `default_next_phase_for_task_type` 의
|
|
|
4
4
|
python 구현. (task-type, current task/run status, render-only flag) 만 받아서
|
|
5
5
|
WORKFLOW_* 필드 dict 를 돌려준다.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
task-type 별 PHASE_ALLOWED_OUTPUTS 본문 매핑은 모듈 상수(`PHASE_RULES`)로 보유하고,
|
|
8
|
+
PHASE_FORBIDDEN_ACTIONS 는 `prompts/profiles/forbidden-actions.json` SSOT 에서 로드한다.
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
12
15
|
PHASE_SEQUENCE = [
|
|
13
16
|
"requirements-discovery",
|
|
14
17
|
"error-analysis",
|
|
@@ -31,8 +34,9 @@ DEFAULT_NEXT_PHASE = {
|
|
|
31
34
|
"release-handoff": "done-or-follow-up",
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
# Phase 별 allowed outputs
|
|
35
|
-
#
|
|
37
|
+
# Phase 별 allowed outputs. bash heredoc 원문 그대로 옮긴 값 — 들여쓰기와 백틱은
|
|
38
|
+
# prompt template 에 그대로 박혀 lead 가 읽는다. forbidden actions 는 이 dict 가
|
|
39
|
+
# 아니라 prompts/profiles/forbidden-actions.json (load_phase_forbidden) 이 SSOT.
|
|
36
40
|
PHASE_RULES: dict[str, dict[str, str]] = {
|
|
37
41
|
"requirements-discovery": {
|
|
38
42
|
"allowed": (
|
|
@@ -41,12 +45,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
41
45
|
" - missing-input list and clarification requests\n"
|
|
42
46
|
" - approval / confirmation checkpoints recorded for the next phase"
|
|
43
47
|
),
|
|
44
|
-
"forbidden": (
|
|
45
|
-
" - source code edits of any kind\n"
|
|
46
|
-
" - implementation planning or detailed design beyond what is required to choose the next phase\n"
|
|
47
|
-
" - executing builds, migrations, deployments, or any state-mutating command\n"
|
|
48
|
-
" - starting `error-analysis`, `implementation-planning`, or `implementation` inside this run (each must be a separate run, and `implementation` additionally requires an approved `implementation-planning` deliverable)"
|
|
49
|
-
),
|
|
50
48
|
},
|
|
51
49
|
"improvement-discovery": {
|
|
52
50
|
"allowed": (
|
|
@@ -56,15 +54,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
56
54
|
" - Phase 1.5 reflect-back grilling log at `runs/improvement-discovery/<seq>/state/phase-1.5-grilling.md`\n"
|
|
57
55
|
" - Phase 5.5 consensus classification (full / partial / contested / worker-unique)"
|
|
58
56
|
),
|
|
59
|
-
"forbidden": (
|
|
60
|
-
" - source code edits of any kind\n"
|
|
61
|
-
" - implementation planning, root-cause analysis, builds, migrations, or deployments\n"
|
|
62
|
-
" - starting `implementation-planning`, `implementation`, `error-analysis`, or any other lifecycle phase inside this run\n"
|
|
63
|
-
" - generating candidates outside the lens whitelist (Lens enum violation rejects the report)\n"
|
|
64
|
-
" - exceeding the candidate cap (absolute cap 12)\n"
|
|
65
|
-
" - free external data fetch beyond the brief's Source Material or Phase 1.5 resolved scope\n"
|
|
66
|
-
" - interpreting user phrases like `다음 단계 진행해` as authorisation to enter another phase"
|
|
67
|
-
),
|
|
68
57
|
},
|
|
69
58
|
"error-analysis": {
|
|
70
59
|
"allowed": (
|
|
@@ -73,12 +62,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
73
62
|
" - reproduction gaps and missing observability\n"
|
|
74
63
|
" - validation paths to confirm or refute hypotheses"
|
|
75
64
|
),
|
|
76
|
-
"forbidden": (
|
|
77
|
-
" - source code edits, refactors, or fix attempts\n"
|
|
78
|
-
" - implementation design or planning artifacts\n"
|
|
79
|
-
" - executing builds, migrations, deployments, or any state-mutating command\n"
|
|
80
|
-
" - starting `implementation-planning` or `implementation` inside this run (each must be a separate run, and `implementation` additionally requires an approved `implementation-planning` deliverable)"
|
|
81
|
-
),
|
|
82
65
|
},
|
|
83
66
|
"implementation-planning": {
|
|
84
67
|
"allowed": (
|
|
@@ -91,15 +74,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
91
74
|
" - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
|
|
92
75
|
" - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)"
|
|
93
76
|
),
|
|
94
|
-
"forbidden": (
|
|
95
|
-
" - source code edits of any kind (Edit/Write on project source files is forbidden)\n"
|
|
96
|
-
" - file writes outside the run`s artifact directories (`reports/`, `prompts/`, `state/`, `manifests/`, `worker-results/`, `status/`, `sessions/`); in particular, do not write to `docs/superpowers/specs/` or `docs/superpowers/plans/`\n"
|
|
97
|
-
" - executing builds, migrations, deployments, or any state-mutating command\n"
|
|
98
|
-
' - starting `implementation` inside this run (must be a separate run authorised by an approved deliverable from this phase), even if the user says "다음 단계 진행해"\n'
|
|
99
|
-
" - dispatching parallel sub-agents beyond the required worker roster (okstra owns worker fan-out)\n"
|
|
100
|
-
' - leaving placeholders such as TBD / TODO / "handle edge cases" / "similar to Option N" in the report\n'
|
|
101
|
-
" - delegating the self-review pass to a generic subagent — `Claude lead` must run it"
|
|
102
|
-
),
|
|
103
77
|
},
|
|
104
78
|
"implementation": {
|
|
105
79
|
"allowed": (
|
|
@@ -113,21 +87,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
113
87
|
" - rollback verification (revert SHA reachable, feature flag toggle works, migration down step valid; dry-run preferred)\n"
|
|
114
88
|
" - routing recommendation for `final-verification` (ready / needs new error-analysis or planning loop)"
|
|
115
89
|
),
|
|
116
|
-
"forbidden": (
|
|
117
|
-
" - any Edit/Write or state-mutating Bash before the pre-implementation gate passes (gate requires --approved-plan pointing to a final-report.md whose frontmatter has `approved: true`)\n"
|
|
118
|
-
" - `git push` of any kind, `npm publish` / `cargo publish` / `pip publish`, `gh release`, `docker push`\n"
|
|
119
|
-
" - real database migrations, schema changes against shared environments, or writes to non-local datastores\n"
|
|
120
|
-
" - production credentials, deploy commands, infra mutation (`terraform apply`, `kubectl apply` against non-local cluster, etc.)\n"
|
|
121
|
-
" - external API write calls (POST/PUT/PATCH/DELETE) to third-party services other than localhost test fixtures\n"
|
|
122
|
-
" - source edits or Bash mutations performed by any verifier role (`Gemini verifier`, `Codex verifier`, `Claude verifier` are read-only — recommend, do not apply)\n"
|
|
123
|
-
" - dispatching parallel sub-agents beyond the required worker roster\n"
|
|
124
|
-
" - silent scope expansion: every file edited outside the approved plan list MUST appear in the `Out-of-plan edits` block with rationale\n"
|
|
125
|
-
' - leaving placeholders such as TBD / TODO / "implement later" / "handle edge cases" in newly-added lines of this run (check via `git diff <base>..HEAD | grep -E \'^\\+[^+].*\\b(TBD|TODO|FIXME|XXX|implement later|handle edge cases|similar to|placeholder)\\b\'`; pre-existing strings in untouched regions are out of scope)\n'
|
|
126
|
-
" - lead substituting its own verdict when every required verifier (`Gemini verifier`, `Codex verifier`, `Claude verifier`) returned a non-result terminal status (`timeout`/`error`/`not-run`); in that case the run MUST end as `blocked` with routing recommendation back to `error-analysis`, never with a lead-only verdict\n"
|
|
127
|
-
" - claiming rollback verification on a schema migration, config-format change, or any persisted-state mutation without a recorded dry-run of the rollback step and its captured exit code\n"
|
|
128
|
-
' - declaring overall task acceptance — that is `final-verification` ownership; this phase reports only "ready for final-verification" or "needs new planning loop"\n'
|
|
129
|
-
" - delegating the self-review pass to a generic subagent — `Claude lead` must run it"
|
|
130
|
-
),
|
|
131
90
|
},
|
|
132
91
|
"final-verification": {
|
|
133
92
|
"allowed": (
|
|
@@ -135,11 +94,6 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
135
94
|
" - residual risk and regression notes\n"
|
|
136
95
|
" - recommended follow-up routing (`error-analysis` / `implementation-planning` / `release-handoff`) for any defects detected"
|
|
137
96
|
),
|
|
138
|
-
"forbidden": (
|
|
139
|
-
" - source code edits, follow-up bug fixes, or scope expansion\n"
|
|
140
|
-
" - state-mutating commands; only read-only execution of pre-existing test or validation commands is permitted\n"
|
|
141
|
-
" - starting any follow-up phase inside this run; record findings and end the run"
|
|
142
|
-
),
|
|
143
97
|
},
|
|
144
98
|
"release-handoff": {
|
|
145
99
|
"allowed": (
|
|
@@ -152,30 +106,32 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
152
106
|
" - creating a pull request via `gh pr create --base <chosen-base> --head <current-branch>`; if a PR with the same head already exists, surface its URL and skip creation\n"
|
|
153
107
|
" - the lead writes the final report directly (no `Report writer worker` dispatch); the report still conforms to the standard final-report template"
|
|
154
108
|
),
|
|
155
|
-
"forbidden": (
|
|
156
|
-
" - entering this phase when the cited final-verification `Verdict Token` is `conditional-accept` or `blocked`, or when no final-verification report is cited\n"
|
|
157
|
-
" - any source-code edit, refactor, or scope expansion beyond what is strictly needed to author PR descriptions (the changes themselves are inherited from prior `implementation` runs)\n"
|
|
158
|
-
" - local commit commands (`git add`, `git commit`, `git stash`, `git restore --staged`); commits are produced by `implementation`, not release-handoff\n"
|
|
159
|
-
" - `git push --force`, `git push --force-with-lease`, or any rewriting of remote history\n"
|
|
160
|
-
" - pushing directly to a base branch (`main`, `master`, `prod`, `preprod`, `staging`, `dev`, or any branch the user named as the PR base)\n"
|
|
161
|
-
" - bypassing git hooks (`--no-verify`, `-n`), bypassing GPG signing, or otherwise disabling repo-configured safeguards\n"
|
|
162
|
-
" - release-publishing commands: `gh release`, `npm publish`, `cargo publish`, `pip publish`, `docker push`, `terraform apply`, `kubectl apply` against non-local clusters\n"
|
|
163
|
-
" - executing any command the user did NOT select (e.g. if the user picked `local only`, opening a PR is forbidden; if the user picked `skip`, the run ends without git commands)\n"
|
|
164
|
-
" - `TeamCreate`, `Agent(...)` worker dispatch, parallel sub-agent fan-out, or any team-mode orchestration — this phase runs single-lead\n"
|
|
165
|
-
" - silently retrying a failed git/gh command with weaker flags (e.g. retrying `git push` with `--force` after a non-fast-forward rejection)"
|
|
166
|
-
),
|
|
167
109
|
},
|
|
168
110
|
}
|
|
169
111
|
|
|
170
112
|
PHASE_RULES_UNKNOWN = {
|
|
171
113
|
"allowed": " - outputs defined by the active task type",
|
|
172
|
-
"forbidden": (
|
|
173
|
-
" - any action that belongs to a different lifecycle phase\n"
|
|
174
|
-
" - source code edits or state-mutating commands unless this task type explicitly authorises them"
|
|
175
|
-
),
|
|
176
114
|
}
|
|
177
115
|
|
|
178
116
|
|
|
117
|
+
def render_forbidden(items: list[str]) -> str:
|
|
118
|
+
"""Render a forbidden-action list to the ` - item` bullet block injected
|
|
119
|
+
into the launch prompt boundary and the profile body."""
|
|
120
|
+
return "\n".join(f" - {item}" for item in items)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_phase_forbidden(workspace_root: Path) -> dict[str, str]:
|
|
124
|
+
"""Load the forbidden-actions SSOT and render each phase to its bullet block.
|
|
125
|
+
|
|
126
|
+
`workspace_root` is the resolved okstra asset root (repo or runtime); the
|
|
127
|
+
JSON lives beside the profile `.md` files at
|
|
128
|
+
`prompts/profiles/forbidden-actions.json`.
|
|
129
|
+
"""
|
|
130
|
+
path = Path(workspace_root) / "prompts" / "profiles" / "forbidden-actions.json"
|
|
131
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
return {phase: render_forbidden(items) for phase, items in data.items()}
|
|
133
|
+
|
|
134
|
+
|
|
179
135
|
def default_next_phase_for(task_type: str) -> str:
|
|
180
136
|
return DEFAULT_NEXT_PHASE.get(task_type, "unknown")
|
|
181
137
|
|
|
@@ -186,6 +142,7 @@ def compute_workflow_state(
|
|
|
186
142
|
current_run_status: str,
|
|
187
143
|
current_task_status: str,
|
|
188
144
|
render_only: bool,
|
|
145
|
+
forbidden_by_phase: dict[str, str],
|
|
189
146
|
work_category: str = "",
|
|
190
147
|
) -> dict:
|
|
191
148
|
"""WORKFLOW_* + PHASE_* 값을 dict 로 돌려준다."""
|
|
@@ -231,5 +188,7 @@ def compute_workflow_state(
|
|
|
231
188
|
"WORKFLOW_ROUTING_STATUS": routing_status,
|
|
232
189
|
"WORKFLOW_LAST_SAFE_CHECKPOINT_LABEL": checkpoint_label,
|
|
233
190
|
"PHASE_ALLOWED_OUTPUTS": rules["allowed"],
|
|
234
|
-
"PHASE_FORBIDDEN_ACTIONS":
|
|
191
|
+
"PHASE_FORBIDDEN_ACTIONS": forbidden_by_phase.get(
|
|
192
|
+
task_type, forbidden_by_phase["unknown"]
|
|
193
|
+
),
|
|
235
194
|
}
|