okstra 0.1.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 (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,674 @@
1
+ """prepare_task_bundle — the single python entrypoint that materializes a
2
+ complete okstra task bundle on disk.
3
+
4
+ This function replaces the ~50-step wiring that previously lived in bash
5
+ `okstra.sh`. It is called by:
6
+ - `okstra.sh` (thin wrapper: argv → prepare_task_bundle → optional exec claude)
7
+ - `okstra-run` skill (collects inputs via AskUserQuestion → calls this)
8
+ - okstra-ctl rerun (passes recorded invocation argv through)
9
+
10
+ The function is read-modify-write on disk inside per-task mutex; it does not
11
+ mutate the calling process environment or rely on inherited env values for
12
+ any per-run identity. The only env vars honored are user-knob defaults
13
+ (`OKSTRA_DEFAULT_*_MODEL`, `OKSTRA_HOME`) — these are intentional config, not
14
+ state passing, and are read once at the start.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from okstra_project import upsert_project_json
28
+ from .material import (
29
+ build_analysis_material,
30
+ related_tasks_bullets,
31
+ related_tasks_inline,
32
+ resolve_related_tasks,
33
+ )
34
+ from .models import resolve_model_metadata
35
+ from .path_resolve import relative_to_project_root, resolve_user_file
36
+ from .render import (
37
+ render_latest_task_discovery,
38
+ render_reference_expectations,
39
+ render_run_manifest,
40
+ render_task_catalog_discovery,
41
+ render_task_index,
42
+ render_task_manifest,
43
+ render_team_state,
44
+ render_template_file,
45
+ render_timeline,
46
+ )
47
+ from .run_context import compute_and_write_run_context, write_run_inputs
48
+ from .seeding import (
49
+ cleanup_obsolete_generated_docs,
50
+ render_runtime_settings_file,
51
+ verify_installation,
52
+ )
53
+ from .session import generate_claude_session_id, write_claude_resume_command_file
54
+ from .workers import normalize_workers, resolve_profile_workers
55
+ from .workflow import compute_workflow_state
56
+
57
+ APPROVED_PLAN_PATTERN = re.compile(
58
+ r"^[ \t]*(APPROVED([ \t]|:|$)|\[x\][ \t]*Approved|"
59
+ r"User[ \t]+Approval[ \t]*:[ \t]*(APPROVED|granted|yes))",
60
+ re.IGNORECASE | re.MULTILINE,
61
+ )
62
+
63
+
64
+ class PrepareError(Exception):
65
+ """surface to caller — task bundle prepare failed."""
66
+
67
+
68
+ @dataclass
69
+ class PrepareInputs:
70
+ workspace_root: Path
71
+ project_root: Path
72
+ project_id: str
73
+ task_group: str
74
+ task_id: str
75
+ task_type: str
76
+ brief_path: Path # absolute, already resolved
77
+ directive: str = ""
78
+ workers_override: str = ""
79
+ lead_model: str = ""
80
+ claude_model: str = ""
81
+ codex_model: str = ""
82
+ gemini_model: str = ""
83
+ report_writer_model: str = ""
84
+ related_tasks_raw: str = ""
85
+ approved_plan_path: str = ""
86
+ clarification_response_path: str = "" # absolute or empty
87
+ render_only: bool = False
88
+ refresh_assets: bool = False
89
+
90
+
91
+ @dataclass
92
+ class PrepareOutputs:
93
+ ctx: dict
94
+ prompt_text: str
95
+ runtime_settings_path: Optional[Path]
96
+ extras: dict = field(default_factory=dict)
97
+
98
+
99
+ def _default(name: str, fallback: str) -> str:
100
+ return os.environ.get(name, "") or fallback
101
+
102
+
103
+ def _validate_approved_plan(path: str) -> None:
104
+ p = Path(path)
105
+ if not p.is_file():
106
+ raise PrepareError(f"approved plan file not found: {path}")
107
+ if not APPROVED_PLAN_PATTERN.search(p.read_text(encoding="utf-8", errors="replace")):
108
+ raise PrepareError(
109
+ f"approved plan has no recognised user-approval marker: {path}\n"
110
+ ' expected one of (case-insensitive, line-anchored): "APPROVED", '
111
+ '"[x] Approved", "User Approval: APPROVED|granted|yes"'
112
+ )
113
+
114
+
115
+ def _ensure_task_directories(ctx: dict) -> None:
116
+ for key in (
117
+ "TASK_ROOT", "INSTRUCTION_SET_DIR", "RUNS_DIR", "HISTORY_DIR",
118
+ "RUN_DIR", "RUN_MANIFESTS_DIR", "RUN_STATE_DIR", "RUN_PROMPTS_DIR",
119
+ "RUN_REPORTS_DIR", "RUN_STATUS_DIR", "RUN_SESSIONS_DIR",
120
+ "RUN_LOGS_DIR", "WORKER_RESULTS_DIR", "OKSTRA_DISCOVERY_DIR",
121
+ ):
122
+ Path(ctx[key]).mkdir(parents=True, exist_ok=True)
123
+
124
+
125
+ def _migrate_legacy_run_artifacts(ctx: dict) -> None:
126
+ """run/<task-type>/ 바로 아래에 남아 있을 수 있는 legacy 파일을 카테고리
127
+ 하위 디렉터리(`manifests/`, `state/`, ...) 로 이동한다.
128
+ """
129
+ project_root = Path(ctx["PROJECT_ROOT"])
130
+ task_root = Path(ctx["TASK_ROOT"])
131
+ run_dir = Path(ctx["RUN_DIR"])
132
+ if not run_dir.is_dir():
133
+ return
134
+ legacy = [
135
+ ("run-manifest-", ".json", Path(ctx["RUN_MANIFESTS_DIR"])),
136
+ ("team-state-", ".json", Path(ctx["RUN_STATE_DIR"])),
137
+ ("claude-execution-prompt-", ".md", Path(ctx["RUN_PROMPTS_DIR"])),
138
+ ("final-report-", ".md", Path(ctx["RUN_REPORTS_DIR"])),
139
+ ("final-", ".status", Path(ctx["RUN_STATUS_DIR"])),
140
+ ("claude-resume-", ".sh", Path(ctx["RUN_SESSIONS_DIR"])),
141
+ ]
142
+ rewrites: dict[str, str] = {}
143
+ for entry in run_dir.iterdir():
144
+ if not entry.is_file():
145
+ continue
146
+ for prefix, suffix, target_dir in legacy:
147
+ if not (entry.name.startswith(prefix) and entry.name.endswith(suffix)):
148
+ continue
149
+ target_dir.mkdir(parents=True, exist_ok=True)
150
+ dest = target_dir / entry.name
151
+ old_rel = str(entry.relative_to(project_root))
152
+ new_rel = str(dest.relative_to(project_root))
153
+ if dest.exists():
154
+ try:
155
+ if entry.read_bytes() == dest.read_bytes():
156
+ entry.unlink()
157
+ rewrites[old_rel] = new_rel
158
+ except Exception:
159
+ pass
160
+ break
161
+ shutil.move(str(entry), str(dest))
162
+ rewrites[old_rel] = new_rel
163
+ break
164
+ if not rewrites:
165
+ return
166
+ for path in task_root.rglob("*"):
167
+ if not path.is_file() or path.suffix not in {".json", ".md", ".txt", ".sh"}:
168
+ continue
169
+ try:
170
+ original = path.read_text(encoding="utf-8")
171
+ except Exception:
172
+ continue
173
+ updated = original
174
+ for o, n in rewrites.items():
175
+ updated = updated.replace(o, n)
176
+ if updated != original:
177
+ path.write_text(updated, encoding="utf-8")
178
+
179
+
180
+ def _record_start(
181
+ *,
182
+ workspace_root: Path,
183
+ ctx: dict,
184
+ initial_status: str,
185
+ canonical_argv: list[str],
186
+ cwd: str,
187
+ brief_sha256: str,
188
+ ) -> None:
189
+ """record_start hook 호출. okstra-central.sh 의 bash wrapper 와 같은 동작
190
+ 이지만 python 직접 호출이라 환경 변수 의존 없음.
191
+ """
192
+ import fcntl
193
+ import json as _json
194
+ from datetime import datetime, timezone
195
+ from okstra_ctl import record_start
196
+ from okstra_ctl.run_context import _okstra_home # type: ignore
197
+
198
+ home = _okstra_home()
199
+ home.mkdir(mode=0o700, parents=True, exist_ok=True)
200
+ os.chmod(home, 0o700)
201
+ # bootstrap (okstra_central_bootstrap 와 동등) — 디렉터리·jsonl 파일 보장.
202
+ for sub in ("archive", ".locks", "batches", "projects"):
203
+ (home / sub).mkdir(exist_ok=True)
204
+ for f in ("active.jsonl", "recent.jsonl"):
205
+ (home / f).touch(exist_ok=True)
206
+ state_file = home / "state.json"
207
+ if not state_file.exists():
208
+ state_file.write_text(_json.dumps({
209
+ "schemaVersion": "1",
210
+ "createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
211
+ "backfilledAt": None,
212
+ }, indent=2) + "\n", encoding="utf-8")
213
+ os.chmod(state_file, 0o600)
214
+ lockfile = home / ".lock"
215
+ lockfile.touch()
216
+ with lockfile.open("r+") as lock:
217
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
218
+ record_start(
219
+ home,
220
+ project_id=ctx["PROJECT_ID"],
221
+ project_root=ctx["PROJECT_ROOT"],
222
+ task_group=ctx["TASK_GROUP"],
223
+ task_id=ctx["TASK_ID"],
224
+ task_type=ctx.get("ANALYSIS_TYPE", ""),
225
+ run_seq=int(ctx["RUN_MANIFESTS_SEQ"]),
226
+ when=ctx["RUN_TIMESTAMP_ISO"],
227
+ workers=[w for w in ctx.get("SELECTED_REVIEWERS", "").split(",") if w],
228
+ lead_model=ctx.get("LEAD_MODEL_DISPLAY", ""),
229
+ run_dir_rel=ctx.get("RUN_DIR_RELATIVE_PATH", ""),
230
+ final_report_rel=ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
231
+ final_status_rel=ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
232
+ argv=canonical_argv,
233
+ cwd=cwd,
234
+ env_overrides={},
235
+ initial_status=initial_status,
236
+ brief_sha256=brief_sha256,
237
+ )
238
+
239
+
240
+ def _brief_sha256(path: Path) -> str:
241
+ import hashlib
242
+
243
+ try:
244
+ with Path(path).open("rb") as f:
245
+ return hashlib.sha256(f.read()).hexdigest()
246
+ except OSError:
247
+ return ""
248
+
249
+
250
+ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
251
+ """rerun 충실 재현을 위한 canonical argv 재구성."""
252
+ workers = inp.workers_override or ctx.get("SELECTED_REVIEWERS", "")
253
+ pairs = [
254
+ ("--task-type", inp.task_type),
255
+ ("--project-id", inp.project_id),
256
+ ("--task-group", inp.task_group),
257
+ ("--task-id", inp.task_id),
258
+ ("--task-brief", str(inp.brief_path)),
259
+ ("--directive", inp.directive),
260
+ ("--approved-plan", inp.approved_plan_path),
261
+ ("--clarification-response", inp.clarification_response_path),
262
+ ("--workers", workers),
263
+ ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL_DISPLAY", "")),
264
+ ("--claude-model", inp.claude_model or ctx.get("CLAUDE_WORKER_MODEL_DISPLAY", "")),
265
+ ("--codex-model", inp.codex_model or ctx.get("CODEX_WORKER_MODEL_DISPLAY", "")),
266
+ ("--gemini-model", inp.gemini_model or ctx.get("GEMINI_WORKER_MODEL_DISPLAY", "")),
267
+ ("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL_DISPLAY", "")),
268
+ ("--related-tasks", inp.related_tasks_raw),
269
+ ]
270
+ argv: list[str] = []
271
+ for flag, val in pairs:
272
+ if val:
273
+ argv.extend([flag, val])
274
+ if inp.render_only:
275
+ argv.append("--render-only")
276
+ if inp.refresh_assets:
277
+ argv.append("--refresh-assets")
278
+ argv.append("--yes")
279
+ return argv
280
+
281
+
282
+ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
283
+ """Produce a complete okstra task bundle on disk. See module docstring."""
284
+ workspace_root = Path(inp.workspace_root)
285
+ project_root = Path(inp.project_root)
286
+
287
+ # ---- validate inputs ----
288
+ profile_dir = workspace_root / "prompts" / "profiles"
289
+ profile_file = profile_dir / f"{inp.task_type}.md"
290
+ if not profile_file.is_file():
291
+ raise PrepareError(
292
+ f"analysis profile file not found for task-type {inp.task_type}: {profile_file}"
293
+ )
294
+ prompt_template = workspace_root / "prompts" / "launch.template.md"
295
+ if not prompt_template.is_file():
296
+ raise PrepareError(f"okstra prompt template not found: {prompt_template}")
297
+ task_index_template = (
298
+ workspace_root / "templates" / "project-docs" / "task-index.template.md"
299
+ )
300
+ final_report_template = (
301
+ workspace_root / "templates" / "reports" / "final-report.template.md"
302
+ )
303
+ source_skill = workspace_root / "agents" / "SKILL.md"
304
+ run_validator = workspace_root / "validators" / "validate-run.py"
305
+ for required in (task_index_template, final_report_template, run_validator, source_skill):
306
+ if not required.is_file():
307
+ raise PrepareError(f"required okstra template or source skill missing: {required}")
308
+ if not project_root.is_dir():
309
+ raise PrepareError(f"project root not found: {project_root}")
310
+ if not inp.brief_path.is_file():
311
+ raise PrepareError(f"task brief not found: {inp.brief_path}")
312
+ if inp.task_type == "implementation":
313
+ if not inp.approved_plan_path:
314
+ raise PrepareError(
315
+ "task-type implementation requires --approved-plan <path-to-final-report.md>"
316
+ )
317
+ _validate_approved_plan(inp.approved_plan_path)
318
+ if inp.clarification_response_path and not Path(inp.clarification_response_path).is_file():
319
+ raise PrepareError(
320
+ f"clarification response file not found: {inp.clarification_response_path}"
321
+ )
322
+
323
+ # ---- installation check ----
324
+ verify_installation(workspace_root)
325
+
326
+ # ---- project.json upsert (self-registration) ----
327
+ from okstra_project import ResolverError
328
+
329
+ try:
330
+ upsert_project_json(project_root, inp.project_id)
331
+ except ResolverError as exc:
332
+ raise PrepareError(f"project.json upsert failed: {exc}") from exc
333
+
334
+ # ---- workers resolution ----
335
+ profile_workers_csv = ",".join(resolve_profile_workers(profile_file))
336
+ workers = normalize_workers(inp.workers_override or profile_workers_csv)
337
+ if not workers:
338
+ raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
339
+ selected_reviewers = ",".join(workers)
340
+
341
+ # ---- model assignments ----
342
+ lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
343
+ claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "sonnet")
344
+ codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
345
+ gemini_default = _default("OKSTRA_DEFAULT_GEMINI_MODEL", "auto")
346
+ report_writer_default = _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default)
347
+ lead = resolve_model_metadata(
348
+ provider="claude", raw_value=inp.lead_model,
349
+ default_display=lead_default, default_execution=lead_default,
350
+ )
351
+ cw = resolve_model_metadata(
352
+ provider="claude", raw_value=inp.claude_model,
353
+ default_display=claude_default, default_execution=claude_default,
354
+ )
355
+ co = resolve_model_metadata(
356
+ provider="codex", raw_value=inp.codex_model,
357
+ default_display=codex_default, default_execution=codex_default,
358
+ )
359
+ ge = resolve_model_metadata(
360
+ provider="gemini", raw_value=inp.gemini_model,
361
+ default_display=gemini_default, default_execution=gemini_default,
362
+ )
363
+ rw = resolve_model_metadata(
364
+ provider="claude", raw_value=inp.report_writer_model,
365
+ default_display=report_writer_default, default_execution=report_writer_default,
366
+ )
367
+
368
+ # ---- paths under per-task mutex (writes run-context-*.json) ----
369
+ # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
370
+ # 한 seq 를 강제하는 user-knob 환경 변수.
371
+ raw_override = os.environ.get("OKSTRA_RUN_SEQ_OVERRIDE", "").strip()
372
+ run_seq_override = int(raw_override) if raw_override else None
373
+ ctx = compute_and_write_run_context(
374
+ workspace_root=workspace_root, project_root=project_root,
375
+ project_id=inp.project_id, task_group=inp.task_group, task_id=inp.task_id,
376
+ task_type=inp.task_type, run_seq_override=run_seq_override,
377
+ )
378
+
379
+ claude_session_id = "" if inp.render_only else generate_claude_session_id()
380
+
381
+ # ---- material + related-tasks ----
382
+ profile_content = profile_file.read_text(encoding="utf-8")
383
+ review_material = build_analysis_material(inp.brief_path, inp.directive)
384
+ related_items = resolve_related_tasks(
385
+ task_manifest_path=Path(ctx["TASK_MANIFEST_FILE"]),
386
+ raw_related=inp.related_tasks_raw,
387
+ )
388
+ related_tasks_json_str = json.dumps(related_items, ensure_ascii=False)
389
+ bullets = related_tasks_bullets(related_items)
390
+ inline = related_tasks_inline(related_items)
391
+
392
+ # ---- relative paths for brief + clarification ----
393
+ brief_relative = relative_to_project_root(inp.brief_path, project_root)
394
+ clarification_relative = (
395
+ relative_to_project_root(Path(inp.clarification_response_path), project_root)
396
+ if inp.clarification_response_path else ""
397
+ )
398
+
399
+ # ---- initial workflow state (current_run_status not yet known) ----
400
+ initial_task_status = "ready-for-claude"
401
+ initial_run_status = "not-run"
402
+ workflow_state = compute_workflow_state(
403
+ task_type=inp.task_type,
404
+ current_run_status=initial_run_status,
405
+ current_task_status=initial_task_status,
406
+ render_only=inp.render_only,
407
+ )
408
+
409
+ # ---- assemble full ctx (the values render functions expect) ----
410
+ ctx.update({
411
+ "REVIEW_PROFILE": inp.task_type,
412
+ "SELECTED_REVIEWERS": selected_reviewers,
413
+ "CLAUDE_SESSION_ID": claude_session_id,
414
+ "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
415
+ "CLARIFICATION_RESPONSE_FILE": inp.clarification_response_path,
416
+ "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
417
+ "BRIEF_FILE_PATH": str(inp.brief_path),
418
+ "BRIEF_RELATIVE_PATH": brief_relative,
419
+ "LEAD_MODEL_DISPLAY": lead.display,
420
+ "LEAD_MODEL_EXECUTION_VALUE": lead.execution,
421
+ "CLAUDE_WORKER_MODEL_DISPLAY": cw.display,
422
+ "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
423
+ "CODEX_WORKER_MODEL_DISPLAY": co.display,
424
+ "CODEX_WORKER_MODEL_EXECUTION_VALUE": co.execution,
425
+ "GEMINI_WORKER_MODEL_DISPLAY": ge.display,
426
+ "GEMINI_WORKER_MODEL_EXECUTION_VALUE": ge.execution,
427
+ "REPORT_WRITER_MODEL_DISPLAY": rw.display,
428
+ "REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
429
+ "RELATED_TASKS_JSON": related_tasks_json_str,
430
+ "RELATED_TASKS_BULLETS": bullets,
431
+ "RELATED_TASKS_INLINE": inline,
432
+ "CURRENT_TASK_STATUS": initial_task_status,
433
+ "CURRENT_RUN_STATUS": initial_run_status,
434
+ "VALIDATION_STATUS": "not-run",
435
+ "VALIDATION_UPDATED_AT": "",
436
+ "VALIDATION_FAILURES_JSON": "[]",
437
+ "LATEST_REPORT_PATH": "",
438
+ "LATEST_REPORT_RELATIVE_PATH": "",
439
+ "RENDER_ONLY": "true" if inp.render_only else "false",
440
+ **workflow_state,
441
+ })
442
+
443
+ # ---- prepare directories + cleanup ----
444
+ _ensure_task_directories(ctx)
445
+ _migrate_legacy_run_artifacts(ctx)
446
+ cleanup_obsolete_generated_docs(
447
+ project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_DIR"]),
448
+ )
449
+ if not inp.render_only:
450
+ write_claude_resume_command_file(
451
+ resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_FILE"]),
452
+ project_root=project_root, claude_session_id=claude_session_id,
453
+ )
454
+
455
+ # ---- write instruction-set scaffolding ----
456
+ instruction_set = Path(ctx["INSTRUCTION_SET_DIR"])
457
+ instruction_set.mkdir(parents=True, exist_ok=True)
458
+ (instruction_set / "analysis-profile.md").write_text(profile_content, encoding="utf-8")
459
+ (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
460
+ shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
461
+ if inp.clarification_response_path:
462
+ shutil.copyfile(
463
+ inp.clarification_response_path,
464
+ instruction_set / "clarification-response.md",
465
+ )
466
+ if inp.directive:
467
+ (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
468
+ render_reference_expectations(
469
+ str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
470
+ )
471
+ render_template_file(
472
+ str(final_report_template), ctx["FINAL_REPORT_TEMPLATE_FILE"], ctx,
473
+ )
474
+ render_template_file(
475
+ str(prompt_template), str(instruction_set / "claude-execution-prompt.md"), ctx,
476
+ )
477
+ prompt_text = (instruction_set / "claude-execution-prompt.md").read_text(encoding="utf-8")
478
+ Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).parent.mkdir(parents=True, exist_ok=True)
479
+ Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).write_text(prompt_text, encoding="utf-8")
480
+
481
+ # ---- run-inputs persistence ----
482
+ write_run_inputs(
483
+ project_root=project_root,
484
+ run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
485
+ task_type_segment=ctx["TASK_TYPE_SEGMENT"],
486
+ seq=ctx["RUN_MANIFESTS_SEQ"],
487
+ inputs={
488
+ "taskBriefPath": brief_relative,
489
+ "taskBriefAbsolutePath": str(inp.brief_path),
490
+ "directive": inp.directive,
491
+ "workers": selected_reviewers,
492
+ "leadModel": lead.display,
493
+ "claudeModel": cw.display,
494
+ "codexModel": co.display,
495
+ "geminiModel": ge.display,
496
+ "reportWriterModel": rw.display,
497
+ "relatedTasks": inp.related_tasks_raw,
498
+ "approvedPlanPath": inp.approved_plan_path,
499
+ "clarificationResponsePath": inp.clarification_response_path,
500
+ "renderOnly": inp.render_only,
501
+ "refreshAssets": inp.refresh_assets,
502
+ },
503
+ )
504
+
505
+ # ---- final status before manifest writes ----
506
+ if inp.render_only:
507
+ ctx["CURRENT_TASK_STATUS"] = "instruction-set-generated"
508
+ ctx["CURRENT_RUN_STATUS"] = "prepared"
509
+ ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_FILE"]
510
+ ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
511
+ else:
512
+ ctx["CURRENT_TASK_STATUS"] = "claude-session-started"
513
+ ctx["CURRENT_RUN_STATUS"] = "in-progress"
514
+ ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_FILE"]
515
+ ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
516
+ ctx.update(compute_workflow_state(
517
+ task_type=inp.task_type,
518
+ current_run_status=ctx["CURRENT_RUN_STATUS"],
519
+ current_task_status=ctx["CURRENT_TASK_STATUS"],
520
+ render_only=inp.render_only,
521
+ ))
522
+ render_team_state(ctx["TEAM_STATE_FILE"], ctx)
523
+ render_task_manifest(ctx["TASK_MANIFEST_FILE"], ctx)
524
+ render_task_index(str(task_index_template), ctx["TASK_INDEX_FILE"], ctx)
525
+ render_run_manifest(ctx["RUN_MANIFEST_FILE"], ctx)
526
+ render_timeline(ctx["TIMELINE_FILE"], ctx)
527
+ render_task_catalog_discovery(ctx["OKSTRA_TASK_CATALOG_FILE"], ctx)
528
+ render_latest_task_discovery(ctx["OKSTRA_LATEST_TASK_FILE"], ctx)
529
+
530
+ # ---- central index ----
531
+ initial_status = "prepared" if inp.render_only else "running"
532
+ canonical_argv = _canonical_argv(inp, ctx)
533
+ try:
534
+ _record_start(
535
+ workspace_root=workspace_root,
536
+ ctx=ctx,
537
+ initial_status=initial_status,
538
+ canonical_argv=canonical_argv,
539
+ cwd=os.getcwd(),
540
+ brief_sha256=_brief_sha256(inp.brief_path),
541
+ )
542
+ except Exception as exc:
543
+ print(
544
+ f"okstra-central: record_start failed; central index will be incomplete ({exc})",
545
+ file=__import__("sys").stderr,
546
+ )
547
+
548
+ runtime_settings_path = None
549
+ if not inp.render_only:
550
+ runtime_settings_path = render_runtime_settings_file(
551
+ workspace_root=workspace_root, run_dir=Path(ctx["RUN_DIR"]),
552
+ )
553
+
554
+ return PrepareOutputs(
555
+ ctx=ctx,
556
+ prompt_text=prompt_text,
557
+ runtime_settings_path=runtime_settings_path,
558
+ extras={"profile_content": profile_content},
559
+ )
560
+
561
+
562
+ def claude_is_available() -> bool:
563
+ """`claude` CLI 가 PATH 에 있는지 확인."""
564
+ return shutil.which("claude") is not None
565
+
566
+
567
+ def main(argv: list[str]) -> int:
568
+ """CLI dispatcher for bash thin-wrapper use. Parses a flat list of argv
569
+ (the same flags `okstra.sh` accepts), runs prepare_task_bundle, prints
570
+ summary lines + prompt to stdout."""
571
+ import argparse
572
+
573
+ p = argparse.ArgumentParser()
574
+ p.add_argument("--workspace-root", required=True)
575
+ p.add_argument("--project-root", required=True)
576
+ p.add_argument("--project-id", required=True)
577
+ p.add_argument("--task-group", required=True)
578
+ p.add_argument("--task-id", required=True)
579
+ p.add_argument("--task-type", required=True)
580
+ p.add_argument("--task-brief", required=True, dest="task_brief")
581
+ p.add_argument("--directive", default="")
582
+ p.add_argument("--workers", default="", dest="workers_override")
583
+ p.add_argument("--lead-model", default="")
584
+ p.add_argument("--claude-model", default="")
585
+ p.add_argument("--codex-model", default="")
586
+ p.add_argument("--gemini-model", default="")
587
+ p.add_argument("--report-writer-model", default="")
588
+ p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
589
+ p.add_argument("--approved-plan", default="", dest="approved_plan_path")
590
+ p.add_argument("--clarification-response", default="", dest="clarification_response_path")
591
+ p.add_argument("--render-only", action="store_true", dest="render_only")
592
+ p.add_argument("--refresh-assets", action="store_true", dest="refresh_assets")
593
+ args = p.parse_args(argv)
594
+
595
+ project_root = Path(args.project_root).expanduser().resolve()
596
+ brief_abs = resolve_user_file(args.task_brief, project_root)
597
+ if brief_abs is None:
598
+ print(f"task brief not found: {args.task_brief}", file=__import__("sys").stderr)
599
+ return 1
600
+ clarification_abs = ""
601
+ if args.clarification_response_path:
602
+ cr = resolve_user_file(args.clarification_response_path, project_root)
603
+ if cr is None:
604
+ print(
605
+ f"clarification response file not found: {args.clarification_response_path}",
606
+ file=__import__("sys").stderr,
607
+ )
608
+ return 1
609
+ clarification_abs = str(cr)
610
+
611
+ inputs = PrepareInputs(
612
+ workspace_root=Path(args.workspace_root).resolve(),
613
+ project_root=project_root,
614
+ project_id=args.project_id,
615
+ task_group=args.task_group,
616
+ task_id=args.task_id,
617
+ task_type=args.task_type,
618
+ brief_path=brief_abs,
619
+ directive=args.directive,
620
+ workers_override=args.workers_override,
621
+ lead_model=args.lead_model,
622
+ claude_model=args.claude_model,
623
+ codex_model=args.codex_model,
624
+ gemini_model=args.gemini_model,
625
+ report_writer_model=args.report_writer_model,
626
+ related_tasks_raw=args.related_tasks_raw,
627
+ approved_plan_path=args.approved_plan_path,
628
+ clarification_response_path=clarification_abs,
629
+ render_only=args.render_only,
630
+ refresh_assets=args.refresh_assets,
631
+ )
632
+ try:
633
+ out = prepare_task_bundle(inputs)
634
+ except PrepareError as exc:
635
+ print(str(exc), file=__import__("sys").stderr)
636
+ return 1
637
+
638
+ ctx = out.ctx
639
+ # summary block — bash wrapper consumes (and may pipe to user).
640
+ print(f"okstra task key: {ctx['TASK_KEY']}")
641
+ print(f"okstra task root: {ctx['TASK_ROOT']}")
642
+ print(f"okstra latest task discovery file: {ctx['OKSTRA_LATEST_TASK_FILE']}")
643
+ print(f"okstra task catalog file: {ctx['OKSTRA_TASK_CATALOG_FILE']}")
644
+ print(f"okstra instruction-set: {ctx['INSTRUCTION_SET_DIR']}")
645
+ print(f"okstra reference expectations: {ctx['REFERENCE_EXPECTATIONS_FILE']}")
646
+ print(f"okstra final report template: {ctx['FINAL_REPORT_TEMPLATE_FILE']}")
647
+ if inputs.render_only:
648
+ print()
649
+ print(out.prompt_text, end="")
650
+ else:
651
+ print(f"okstra current run dir: {ctx['RUN_DIR']}")
652
+ print(f"final report path: {ctx['FINAL_REPORT_FILE']}")
653
+ print(f"lead model: {ctx['LEAD_MODEL_DISPLAY']}")
654
+ print(f"claude session id: {ctx['CLAUDE_SESSION_ID']}")
655
+ print(f"resume command file: {ctx['CLAUDE_RESUME_COMMAND_FILE']}")
656
+ print("launch mode: interactive Claude handoff")
657
+ print(f"claude working directory: {ctx['PROJECT_ROOT']}")
658
+ print()
659
+ # In non-render-only mode emit a small JSON the bash wrapper can parse
660
+ # to build the `claude` exec command. Wrapper exec's; we don't.
661
+ machine = {
662
+ "claudeSessionId": ctx["CLAUDE_SESSION_ID"],
663
+ "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
664
+ "projectRoot": ctx["PROJECT_ROOT"],
665
+ "runtimeSettingsFile": str(out.runtime_settings_path) if out.runtime_settings_path else "",
666
+ "promptFile": str(Path(ctx["INSTRUCTION_SET_DIR"]) / "claude-execution-prompt.md"),
667
+ }
668
+ print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
669
+ return 0
670
+
671
+
672
+ if __name__ == "__main__":
673
+ import sys
674
+ raise SystemExit(main(sys.argv[1:]))