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,216 @@
1
+ """active/projects 인덱스 행 치환·기록."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .ids import build_run_id
9
+ from .invocation import invocation_path, save_invocation
10
+ from .jsonl import append_jsonl, read_jsonl, remove_jsonl_row, rotate_recent_if_needed
11
+ from .project_meta import upsert_project_meta
12
+
13
+
14
+ def _replace_or_append_active_row(home: Path, run_id: str, row: dict) -> None:
15
+ active = home / "active.jsonl"
16
+ rows = read_jsonl(active)
17
+ replaced = False
18
+ for i, r in enumerate(rows):
19
+ if r.get("runId") == run_id:
20
+ rows[i] = row
21
+ replaced = True
22
+ break
23
+ if not replaced:
24
+ append_jsonl(active, row)
25
+ return
26
+ tmp = active.with_suffix(".jsonl.tmp")
27
+ with tmp.open("w") as f:
28
+ for r in rows:
29
+ f.write(json.dumps(r, separators=(",", ":")) + "\n")
30
+ os.replace(tmp, active)
31
+
32
+
33
+ def _replace_or_append_project_row(home: Path, project_id: str,
34
+ run_id: str, row: dict) -> None:
35
+ proj_index = home / "projects" / project_id / "index.jsonl"
36
+ rows = read_jsonl(proj_index)
37
+ replaced = False
38
+ for i, r in enumerate(rows):
39
+ if r.get("runId") == run_id:
40
+ rows[i] = row
41
+ replaced = True
42
+ break
43
+ if not replaced:
44
+ append_jsonl(proj_index, row)
45
+ return
46
+ proj_index.parent.mkdir(parents=True, exist_ok=True)
47
+ tmp = proj_index.with_suffix(".jsonl.tmp")
48
+ with tmp.open("w") as f:
49
+ for r in rows:
50
+ f.write(json.dumps(r, separators=(",", ":")) + "\n")
51
+ os.replace(tmp, proj_index)
52
+
53
+
54
+ def record_start(home: Path, *, project_id: str, project_root: str,
55
+ task_group: str, task_id: str, task_type: str,
56
+ run_seq: int, when: str, workers: list, lead_model: str,
57
+ run_dir_rel: str, final_report_rel: str,
58
+ argv: list, cwd: str,
59
+ env_overrides: dict,
60
+ brief_sha256: str = "",
61
+ initial_status: str = "running",
62
+ final_status_rel: str = "") -> str:
63
+ """run 시작 시점의 인덱스/invocation/meta 를 1 트랜잭션으로 기록.
64
+ 반환값: 생성된 runId.
65
+ """
66
+ run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
67
+ inv_path_abs = invocation_path(home, project_id, task_group, task_id,
68
+ task_type, run_seq)
69
+ inv_path_rel = inv_path_abs.relative_to(home)
70
+ # 종결 상태(prepared/aborted/etc) 로 시작하는 경우 finishedAt 도 같이 채워
71
+ # 인덱스 위치를 active 가 아닌 recent 로 라우팅한다.
72
+ is_terminal = initial_status != "running"
73
+ row = {
74
+ "runId": run_id,
75
+ "projectId": project_id,
76
+ "projectRoot": project_root,
77
+ "taskGroup": task_group,
78
+ "taskId": task_id,
79
+ "taskKey": f"{task_group}/{task_id}",
80
+ "taskType": task_type,
81
+ "runSeq": run_seq,
82
+ "status": initial_status,
83
+ "startedAt": when,
84
+ "finishedAt": when if is_terminal else None,
85
+ "workers": workers,
86
+ "leadModel": lead_model,
87
+ "validation": "not-run",
88
+ "finalReportRel": final_report_rel,
89
+ # FINAL_STATUS_FILE 의 카운터(RUN_STATUS_SEQ) 는 RUN_MANIFESTS_SEQ
90
+ # 와 별개로 advance 한다(render-only/실패 prep 은 manifest 만 증가).
91
+ # 따라서 status 파일을 추적할 때는 manifest seq 를 추정해 glob 으로
92
+ # 찾지 말고 run 시작 시점의 정식 경로를 row 에 박아 두고 사용한다.
93
+ "finalStatusRel": final_status_rel,
94
+ "runDirRel": run_dir_rel,
95
+ "invocationFile": str(inv_path_rel),
96
+ }
97
+ # okstra-ctl 가 미리 'reserving' 예약 row 를 작성했을 수 있다(P1-2). 같은
98
+ # runId 의 예약 row 가 있으면 update; 없으면 append.
99
+ existing_rows = read_jsonl(home / "active.jsonl")
100
+ had_reservation = any(r.get("runId") == run_id for r in existing_rows)
101
+ if is_terminal:
102
+ # active 에 들어가지 않도록 — 이미 있던 reservation 이 있으면 제거하고 recent 로.
103
+ if had_reservation:
104
+ remove_jsonl_row(home / "active.jsonl",
105
+ lambda r: r.get("runId") == run_id)
106
+ # 동일 runId 가 이미 recent.jsonl 에 있으면(예: 매니페스트 직후 사용자가
107
+ # 처음 okstra-ctl 을 실행해 자동 backfill 이 같은 terminal run 을 미리
108
+ # ingest 한 경우) 중복 append 를 방지하기 위해 기존 row 를 제거하고 새로
109
+ # 쓴다(replace). 그렇지 않으면 동일 runId 가 두 줄로 남아 prefix 해석이
110
+ # 모호해지고 runCount 도 재증가한다.
111
+ had_recent = bool(remove_jsonl_row(
112
+ home / "recent.jsonl", lambda r: r.get("runId") == run_id))
113
+ append_jsonl(home / "recent.jsonl", row)
114
+ # --render-only 나 launch-failure 같은 terminal 시작은 reconcile 의
115
+ # promote 경로를 거치지 않으므로 별도 rotation hook 이 필요하다.
116
+ # 그렇지 않으면 그런 run 만 누적된 환경에서 recent.jsonl 이 임계를
117
+ # 넘어도 archive 로 이동되지 않는다.
118
+ rotate_recent_if_needed(home)
119
+ else:
120
+ _replace_or_append_active_row(home, run_id, row)
121
+ had_recent = False
122
+ _replace_or_append_project_row(home, project_id, run_id, row)
123
+ save_invocation(home, project_id, task_group, task_id, task_type, run_seq, {
124
+ "runId": run_id,
125
+ "okstraVersion": os.environ.get("OKSTRA_SCRIPT_VERSION", ""),
126
+ "invokedAt": when,
127
+ "cwd": cwd,
128
+ "argv": argv,
129
+ "envOverrides": env_overrides,
130
+ "briefSha256": brief_sha256,
131
+ "backfilled": False,
132
+ })
133
+ # 예약이 이미 있었다면 runCount/activeCount 는 reserve 단계에서 이미 증가시켰다.
134
+ # 종결 상태로 시작했다면(terminal) activeCount 를 즉시 되돌린다.
135
+ # 또한 backfill 이 같은 runId 를 recent 에 미리 ingest 했었다면(had_recent)
136
+ # runCount 는 _apply_backfill_meta 에서 이미 가산되었고 activeCount 는
137
+ # 증가된 적이 없으므로, 여기서는 started/finished 모두 건너뛴다.
138
+ if had_recent:
139
+ upsert_project_meta(home, project_id, project_root=project_root,
140
+ when=when, started=False, finished=False)
141
+ else:
142
+ upsert_project_meta(home, project_id, project_root=project_root,
143
+ when=when, started=not had_reservation,
144
+ finished=is_terminal)
145
+ return run_id
146
+
147
+
148
+ def reserve_run_in_active(home: Path, *,
149
+ project_id: str, project_root: str,
150
+ task_group: str, task_id: str, task_type: str,
151
+ run_seq: int, when: str,
152
+ run_dir_rel: str, final_report_rel: str,
153
+ final_status_rel: str = "") -> None:
154
+ """예약 row 를 active.jsonl 에 즉시 기록한다. 중앙 락 보호 필수.
155
+ record_start 가 나중에 같은 (project, group, task, task_type, seq) 를 만나면
156
+ 이 row 를 update 한다.
157
+ """
158
+ run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
159
+ inv_rel = invocation_path(home, project_id, task_group, task_id,
160
+ task_type, run_seq).relative_to(home)
161
+ row = {
162
+ "runId": run_id,
163
+ "projectId": project_id,
164
+ "projectRoot": project_root,
165
+ "taskGroup": task_group,
166
+ "taskId": task_id,
167
+ "taskKey": f"{task_group}/{task_id}",
168
+ "taskType": task_type,
169
+ "runSeq": run_seq,
170
+ "status": "reserving",
171
+ "startedAt": when,
172
+ "finishedAt": None,
173
+ "workers": [],
174
+ "leadModel": "",
175
+ "validation": "not-run",
176
+ "finalReportRel": final_report_rel,
177
+ # 예약 시점 best-effort: 정상 run 은 RUN_STATUS_SEQ == RUN_MANIFESTS_SEQ
178
+ # 라 일치한다. record_start 가 실제 경로로 update 한다.
179
+ "finalStatusRel": final_status_rel,
180
+ "runDirRel": run_dir_rel,
181
+ "invocationFile": str(inv_rel),
182
+ }
183
+ append_jsonl(home / "active.jsonl", row)
184
+ # project index 도 미리 한 줄 — record_start 가 나중에 update 하므로 동일 row 형태로.
185
+ append_jsonl(home / "projects" / project_id / "index.jsonl", row)
186
+ # runCount/activeCount 는 예약 시점에 1회만 증가. record_start 는 had_reservation
187
+ # 일 때 started=False 로 호출되므로 중복 카운트 없음.
188
+ upsert_project_meta(home, project_id, project_root=project_root,
189
+ when=when, started=True)
190
+
191
+
192
+ def remove_reservation(home: Path, *, project_id: str, task_group: str,
193
+ task_id: str, task_type: str, run_seq: int) -> None:
194
+ """예약 row 를 제거한다(spawn 실패 시 cleanup). 중앙 락 보호 필요.
195
+ 같은 (project, group, task, task_type, seq) 의 'reserving' 행만 지운다.
196
+ 예약 시점에 증가시켰던 runCount/activeCount 도 되돌린다.
197
+ """
198
+ run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
199
+ def _match(r):
200
+ return (r.get("runId") == run_id and r.get("status") == "reserving")
201
+ removed = remove_jsonl_row(home / "active.jsonl", _match)
202
+ proj_index = home / "projects" / project_id / "index.jsonl"
203
+ if proj_index.is_file():
204
+ remove_jsonl_row(proj_index, _match)
205
+ if removed is None:
206
+ return
207
+ # reserve 가 카운터를 1 증가시켰으므로 활성/총 카운트를 되돌린다.
208
+ target = home / "projects" / project_id / "meta.json"
209
+ if not target.is_file():
210
+ return
211
+ meta = json.loads(target.read_text())
212
+ meta["runCount"] = max(0, int(meta.get("runCount", 0)) - 1)
213
+ meta["activeCount"] = max(0, int(meta.get("activeCount", 0)) - 1)
214
+ tmp = target.with_suffix(".json.tmp")
215
+ tmp.write_text(json.dumps(meta, indent=2) + "\n")
216
+ os.replace(tmp, target)
@@ -0,0 +1,49 @@
1
+ """invocation JSON 경로/저장/로드."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .ids import _safe_fs_segment
10
+
11
+
12
+ def invocation_path(home: Path, project_id: str,
13
+ task_group: str, task_id: str, task_type: str,
14
+ run_seq: int) -> Path:
15
+ """invocation JSON 파일 절대경로.
16
+ okstra 의 run_seq 는 (group, task_id, task_type) 별로 독립이므로 같은 project 안에
17
+ seq=1 인 run 이 여러 개일 수 있다. 따라서 파일명은 4-tuple 전체로 유일성을 보장한다.
18
+ """
19
+ seq = f"{run_seq:02d}" if run_seq < 100 else str(run_seq)
20
+ # 세그먼트를 평탄 조인하면 ('feature-8','email') 과 ('feature','8-email')
21
+ # 가 같은 파일로 충돌하므로, 각 세그먼트를 하위 디렉터리로 분리해 경계
22
+ # 정보를 손실 없이 보존한다. 또한 task_group/task_id 등은 CLI/manifest
23
+ # 에서 들어오는 미검증 값이므로 fs-safe 슬러그로 강제 정규화해 `/` 나
24
+ # `..` 가 invocations 디렉터리 밖으로 path 를 escape 시키는 것을 막는다.
25
+ return (home / "projects" / _safe_fs_segment(project_id) / "invocations"
26
+ / _safe_fs_segment(task_group) / _safe_fs_segment(task_id)
27
+ / _safe_fs_segment(task_type) / f"r{seq}.json")
28
+
29
+
30
+ def save_invocation(home: Path, project_id: str,
31
+ task_group: str, task_id: str, task_type: str,
32
+ run_seq: int, payload: dict) -> None:
33
+ """invocation 을 원자적으로 저장(임시파일 + os.replace)."""
34
+ target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
35
+ target.parent.mkdir(parents=True, exist_ok=True)
36
+ tmp = target.with_suffix(".json.tmp")
37
+ tmp.write_text(json.dumps(payload, indent=2) + "\n")
38
+ os.chmod(tmp, 0o600)
39
+ os.replace(tmp, target)
40
+
41
+
42
+ def load_invocation(home: Path, project_id: str,
43
+ task_group: str, task_id: str, task_type: str,
44
+ run_seq: int) -> Optional[dict]:
45
+ """invocation JSON 을 읽는다. 없으면 None."""
46
+ target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
47
+ if not target.is_file():
48
+ return None
49
+ return json.loads(target.read_text())
@@ -0,0 +1,84 @@
1
+ """jsonl I/O 및 회전."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Callable, List, Optional
9
+
10
+
11
+ def append_jsonl(path: Path, row: dict) -> None:
12
+ """jsonl 파일 끝에 한 줄 append. 파일이 없으면 생성."""
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ line = json.dumps(row, separators=(",", ":")) + "\n"
15
+ with path.open("a") as f:
16
+ f.write(line)
17
+
18
+
19
+ def read_jsonl(path: Path) -> List[dict]:
20
+ """jsonl 파일을 모두 읽어 dict 목록으로 반환. 파싱 실패 라인은 스킵."""
21
+ if not path.is_file():
22
+ return []
23
+ rows: List[dict] = []
24
+ for line in path.open():
25
+ line = line.strip()
26
+ if not line:
27
+ continue
28
+ try:
29
+ rows.append(json.loads(line))
30
+ except json.JSONDecodeError:
31
+ continue
32
+ return rows
33
+
34
+
35
+ def remove_jsonl_row(path: Path, match: Callable[[dict], bool]) -> Optional[dict]:
36
+ """match 가 True 인 첫 행을 제거하고 반환. 없으면 None.
37
+ 임시 파일 + os.replace 로 원자적으로 교체한다.
38
+ """
39
+ if not path.is_file():
40
+ return None
41
+ removed: Optional[dict] = None
42
+ tmp = path.with_suffix(path.suffix + ".tmp")
43
+ with path.open() as src, tmp.open("w") as dst:
44
+ for line in src:
45
+ stripped = line.strip()
46
+ if not stripped:
47
+ continue
48
+ try:
49
+ row = json.loads(stripped)
50
+ except json.JSONDecodeError:
51
+ dst.write(line)
52
+ continue
53
+ if removed is None and match(row):
54
+ removed = row
55
+ continue
56
+ dst.write(line)
57
+ os.replace(tmp, path)
58
+ return removed
59
+
60
+
61
+ def rotate_recent_if_needed(home: Path, max_rows: int = 2000,
62
+ max_bytes: int = 5 * 1024 * 1024) -> Optional[Path]:
63
+ """recent.jsonl 이 임계를 넘으면 archive/YYYY/YYYY-MM.jsonl 로 이동.
64
+ 이동 후 recent.jsonl 을 비운다. 회전이 발생했으면 archive 경로 반환.
65
+ """
66
+ recent = home / "recent.jsonl"
67
+ if not recent.is_file():
68
+ return None
69
+ rows = list(recent.open())
70
+ size_ok = recent.stat().st_size < max_bytes
71
+ rows_ok = len(rows) < max_rows
72
+ if size_ok and rows_ok:
73
+ return None
74
+ # archive 키는 첫 행의 finishedAt 월. 없으면 현재 월.
75
+ first_row = json.loads(rows[0]) if rows else {}
76
+ finished = first_row.get("finishedAt") or datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%SZ")
77
+ yyyy, yyyymm = finished[:4], finished[:7]
78
+ archive = home / "archive" / yyyy / f"{yyyymm}.jsonl"
79
+ archive.parent.mkdir(parents=True, exist_ok=True)
80
+ with archive.open("a") as out:
81
+ for line in rows:
82
+ out.write(line)
83
+ recent.write_text("")
84
+ return archive
@@ -0,0 +1,156 @@
1
+ """list/format/show."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re as _re
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from .jsonl import read_jsonl
11
+ from .reconcile import _parse_iso
12
+
13
+
14
+ def list_projects(home: Path, *, only_active: bool = False,
15
+ sort_by: str = "recent") -> List[dict]:
16
+ """projects/<id>/meta.json 을 모두 읽어 정렬된 목록을 반환."""
17
+ base = home / "projects"
18
+ if not base.is_dir():
19
+ return []
20
+ items: List[dict] = []
21
+ for child in base.iterdir():
22
+ meta_file = child / "meta.json"
23
+ if not meta_file.is_file():
24
+ continue
25
+ meta = json.loads(meta_file.read_text())
26
+ # projectRoot 가 누락/빈 문자열이면 Path("").is_dir() 이 cwd 를 가리켜
27
+ # True 가 되므로(missing 으로 잡히지 않음), 빈 값은 명시적으로 missing
28
+ # 처리한다.
29
+ proj_root = meta.get("projectRoot") or ""
30
+ meta["missing"] = (not proj_root) or (not Path(proj_root).is_dir())
31
+ if only_active and int(meta.get("activeCount", 0)) <= 0:
32
+ continue
33
+ items.append(meta)
34
+ if sort_by == "name":
35
+ items.sort(key=lambda m: m.get("projectId", ""))
36
+ elif sort_by == "count":
37
+ items.sort(key=lambda m: int(m.get("runCount", 0)), reverse=True)
38
+ else:
39
+ items.sort(key=lambda m: m.get("lastRunAt", ""), reverse=True)
40
+ return items
41
+
42
+
43
+ def format_projects_table(items: List[dict]) -> str:
44
+ """projects 테이블 텍스트 포맷팅."""
45
+ rows = [("PROJECT-ID", "RUNS", "ACTIVE", "LAST-RUN", "ROOT")]
46
+ for it in items:
47
+ root = it.get("projectRoot", "") + (" (MISSING)" if it.get("missing") else "")
48
+ rows.append((it.get("projectId", ""), str(it.get("runCount", 0)),
49
+ str(it.get("activeCount", 0)), it.get("lastRunAt", ""),
50
+ root))
51
+ widths = [max(len(r[i]) for r in rows) for i in range(5)]
52
+ lines = []
53
+ for r in rows:
54
+ lines.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)))
55
+ return "\n".join(lines) + "\n"
56
+
57
+
58
+ def _parse_since(value: str) -> Optional[datetime]:
59
+ """--since 파싱. 빈 문자열은 None(필터 없음). 형식 위반은 ValueError 로
60
+ fail-fast 한다. 이전엔 잘못된 값(`7days`)이 silently None 으로 강등되어
61
+ 필터가 무력화됐고, rerun --filter --since <typo> 가 max-spawn 까지 모든
62
+ historical run 을 spawn 하던 회귀가 있었다.
63
+ """
64
+ if not value:
65
+ return None
66
+ m = _re.match(r"^(\d+)([dhm])$", value)
67
+ if m:
68
+ n, unit = int(m.group(1)), m.group(2)
69
+ delta = {"d": timedelta(days=n), "h": timedelta(hours=n),
70
+ "m": timedelta(minutes=n)}[unit]
71
+ return datetime.now(timezone.utc).replace(tzinfo=None) - delta
72
+ try:
73
+ return datetime.strptime(value, "%Y-%m-%d")
74
+ except ValueError as exc:
75
+ raise ValueError(
76
+ f"invalid --since {value!r}: expected <N>[dhm] or YYYY-MM-DD"
77
+ ) from exc
78
+
79
+
80
+ def list_runs(home: Path, *, project: str = "all", task_group: str = "all",
81
+ status: str = "all", since: str = "", limit: int = 0,
82
+ include_archive: bool = False) -> List[dict]:
83
+ """active + recent (옵션 archive) 를 합쳐 필터/정렬 후 반환."""
84
+ rows = read_jsonl(home / "active.jsonl") + read_jsonl(home / "recent.jsonl")
85
+ if include_archive:
86
+ archive_dir = home / "archive"
87
+ if archive_dir.is_dir():
88
+ for f in sorted(archive_dir.glob("*/*.jsonl")):
89
+ rows.extend(read_jsonl(f))
90
+ threshold = _parse_since(since)
91
+ out: List[dict] = []
92
+ for r in rows:
93
+ if project != "all" and r.get("projectId") != project:
94
+ continue
95
+ if task_group != "all" and r.get("taskGroup") != task_group:
96
+ continue
97
+ if status != "all" and r.get("status") != status:
98
+ continue
99
+ if threshold:
100
+ started = _parse_iso(r.get("startedAt", ""))
101
+ if started and started < threshold:
102
+ continue
103
+ out.append(r)
104
+ out.sort(key=lambda r: r.get("startedAt", ""), reverse=True)
105
+ if limit > 0:
106
+ out = out[:limit]
107
+ return out
108
+
109
+
110
+ def format_runs_table(rows: List[dict]) -> str:
111
+ header = ("RUN-ID", "STATUS", "STARTED", "TASK-TYPE")
112
+ body = [(r.get("runId", ""), r.get("status", ""),
113
+ r.get("startedAt", ""), r.get("taskType", "")) for r in rows]
114
+ table = [header] + body
115
+ widths = [max(len(t[i]) for t in table) for i in range(4)]
116
+ lines = []
117
+ for t in table:
118
+ lines.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(t)))
119
+ return "\n".join(lines) + "\n"
120
+
121
+
122
+ def find_row_by_run_id(home: Path, run_id: str) -> Optional[dict]:
123
+ """active+recent+archive 를 모두 뒤져 runId 가 일치하는 최신 row 반환."""
124
+ rows = list_runs(home, include_archive=True)
125
+ for r in rows:
126
+ if r.get("runId") == run_id:
127
+ return r
128
+ return None
129
+
130
+
131
+ def format_show(row: dict) -> str:
132
+ inv = row.get("invocationFile", "")
133
+ return (
134
+ f"runId : {row.get('runId')}\n"
135
+ f"projectId : {row.get('projectId')}\n"
136
+ f"projectRoot : {row.get('projectRoot')}\n"
137
+ f"taskKey : {row.get('taskGroup')}/{row.get('taskId')}\n"
138
+ f"taskType : {row.get('taskType')}\n"
139
+ f"status : {row.get('status')}\n"
140
+ f"startedAt : {row.get('startedAt')}\n"
141
+ f"finishedAt : {row.get('finishedAt')}\n"
142
+ f"workers : {','.join(row.get('workers', []))}\n"
143
+ f"leadModel : {row.get('leadModel')}\n"
144
+ f"validation : {row.get('validation')}\n"
145
+ f"runDirRel : {row.get('runDirRel')}\n"
146
+ f"finalReportRel : {row.get('finalReportRel')}\n"
147
+ f"invocationFile : {inv}\n"
148
+ )
149
+
150
+
151
+ def absolute_final_report_path(row: dict) -> Optional[Path]:
152
+ project_root = row.get("projectRoot", "")
153
+ rel = row.get("finalReportRel", "")
154
+ if not project_root or not rel:
155
+ return None
156
+ return (Path(project_root) / rel).resolve()
@@ -0,0 +1,42 @@
1
+ """중앙 락과 task-lock 파일명. 다른 okstra_ctl 모듈에 의존하지 않는다."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import fcntl
6
+
7
+ from .ids import _escape_segment_for_join, _safe_fs_segment
8
+
9
+
10
+ @contextlib.contextmanager
11
+ def central_lock(home):
12
+ """~/.okstra/.lock 위에 fcntl LOCK_EX. okstra-central.sh 의
13
+ okstra_central_with_lock 과 같은 파일을 사용하므로 record_start 와
14
+ 상호 직렬화된다.
15
+ """
16
+ from pathlib import Path as _Path
17
+ home_p = _Path(home)
18
+ home_p.mkdir(parents=True, exist_ok=True)
19
+ lockfile = home_p / ".lock"
20
+ if not lockfile.exists():
21
+ lockfile.touch()
22
+ f = lockfile.open("r+")
23
+ try:
24
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
25
+ yield
26
+ finally:
27
+ f.close()
28
+
29
+
30
+ def task_lock_filename(project_id: str, task_group: str, task_id: str,
31
+ task_type: str) -> str:
32
+ """task-key + task-type 단위 mutex 파일명. ~/.okstra/.locks/ 아래에 위치한다.
33
+ okstra 의 RUN_DIR 가 task-type 별로 분리되므로 mutex 도 같은 입자에 둔다.
34
+ 각 세그먼트는 먼저 fs-safe 슬러그로 정규화해 `/` 나 `..` 가 .locks 디렉터리
35
+ 밖으로 mutex 를 escape 시키는 것을 막고, 이어서 `-` 를 `--` 로 escape 한
36
+ 뒤 `-` 로 조인해 슬래시 경계를 보존한다(('p','feature-8','email','x') 와
37
+ ('p','feature','8-email','x') 가 같은 파일명으로 매핑되어 mutex 가 공유
38
+ 되는 문제 방지).
39
+ """
40
+ parts = [_escape_segment_for_join(_safe_fs_segment(s))
41
+ for s in (project_id, task_group, task_id, task_type)]
42
+ return "-".join(parts) + ".lock"
@@ -0,0 +1,62 @@
1
+ """Analysis material body + related-tasks builders.
2
+
3
+ bash `build_review_material`, `resolve_related_tasks_json`,
4
+ `build_related_tasks_bullets`, `build_related_tasks_inline` 의 python 구현.
5
+ brief 본문과 directive 를 합쳐 lead 가 읽을 `analysis-material.md` 본문을
6
+ 만들고, 이미 manifest 에 기록된 related-tasks 와 새 입력을 merge 한다.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ def build_analysis_material(brief_path: Path, directive: str = "") -> str:
16
+ """`analysis-material.md` 본문 문자열을 돌려준다."""
17
+ parts = ["# OKSTRA Analysis Material", "", "## Task Brief", ""]
18
+ parts.append(Path(brief_path).read_text(encoding="utf-8").rstrip())
19
+ if directive:
20
+ parts.append("")
21
+ parts.append("## Directive")
22
+ parts.append("")
23
+ parts.append(
24
+ "> Free-form directive supplied via `--directive`. Treat as a hard hint that may override default heuristics in lead, workers, and downstream skills (e.g. okstra-schedule's Gantt skip gate)."
25
+ )
26
+ parts.append("")
27
+ parts.append(directive)
28
+ return "\n".join(parts).rstrip() + "\n"
29
+
30
+
31
+ def resolve_related_tasks(
32
+ *, task_manifest_path: Optional[Path], raw_related: str
33
+ ) -> list[str]:
34
+ """기존 manifest 의 relatedTasks + 새 CSV 입력을 dedupe-merge."""
35
+ existing: list[str] = []
36
+ if task_manifest_path and Path(task_manifest_path).exists():
37
+ try:
38
+ manifest = json.loads(Path(task_manifest_path).read_text(encoding="utf-8"))
39
+ existing = manifest.get("relatedTasks") or []
40
+ if not isinstance(existing, list):
41
+ existing = []
42
+ except Exception:
43
+ existing = []
44
+ provided = [v.strip() for v in (raw_related or "").split(",") if v.strip()]
45
+ seen: set[str] = set()
46
+ out: list[str] = []
47
+ for v in [*existing, *provided]:
48
+ if not v or v in seen:
49
+ continue
50
+ seen.add(v)
51
+ out.append(v)
52
+ return out
53
+
54
+
55
+ def related_tasks_bullets(items: list[str]) -> str:
56
+ if not items:
57
+ return "- None recorded"
58
+ return "\n".join(f"- `{v}`" for v in items)
59
+
60
+
61
+ def related_tasks_inline(items: list[str]) -> str:
62
+ return ", ".join(items) if items else "None"
@@ -0,0 +1,63 @@
1
+ """Model display / execution-value resolution for okstra workers.
2
+
3
+ bash `resolve_model_metadata` 의 동등한 python 구현. 사용자가 준 모델 alias
4
+ 또는 정식 이름을 `(display, execution_value)` 쌍으로 정규화한다.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ CLAUDE_MAPPING = {
11
+ "opus": ("opus", "opus"),
12
+ "opus-4-7": ("opus-4-7", "claude-opus-4-7"),
13
+ "claude-opus-4-7": ("opus-4-7", "claude-opus-4-7"),
14
+ "sonnet": ("sonnet", "sonnet"),
15
+ "sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
16
+ "claude-sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
17
+ "haiku": ("haiku", "haiku"),
18
+ "haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
19
+ "claude-haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
20
+ "claude-haiku-4-5-20251001": ("haiku-4-5", "claude-haiku-4-5-20251001"),
21
+ }
22
+ GEMINI_MAPPING = {
23
+ "auto": ("auto", "auto"),
24
+ "gemini auto": ("auto", "auto"),
25
+ "pro": ("pro", "pro"),
26
+ "gemini pro": ("pro", "pro"),
27
+ "gemini 3 flash preview": ("Gemini 3 Flash Preview", "gemini-3-flash-preview"),
28
+ "gemini-3-flash-preview": ("Gemini 3 Flash Preview", "gemini-3-flash-preview"),
29
+ "gemini-3-pro-preview": ("Gemini 3 Pro Preview", "gemini-3-pro-preview"),
30
+ }
31
+ CODEX_MAPPING = {
32
+ "gpt-5.5": ("gpt-5.5", "gpt-5.5"),
33
+ "gpt-5.4": ("gpt-5.4", "gpt-5.4"),
34
+ "gpt-5.4-mini": ("gpt-5.4-mini", "gpt-5.4-mini"),
35
+ "gpt-5.3-codex": ("gpt-5.4", "gpt-5.4"),
36
+ "gpt-5.2": ("gpt-5.2", "gpt-5.2"),
37
+ "codex-auto-review": ("codex-auto-review", "codex-auto-review"),
38
+ }
39
+ PROVIDER_MAPPINGS = {"claude": CLAUDE_MAPPING, "gemini": GEMINI_MAPPING, "codex": CODEX_MAPPING}
40
+
41
+
42
+ @dataclass
43
+ class ModelAssignment:
44
+ display: str
45
+ execution: str
46
+
47
+
48
+ def resolve_model_metadata(
49
+ *, provider: str, raw_value: str, default_display: str, default_execution: str,
50
+ ) -> ModelAssignment:
51
+ """alias → (display, execution_value). 알 수 없는 값은 그대로 통과.
52
+
53
+ provider: "claude" | "codex" | "gemini" (그 외는 mapping 미적용)
54
+ """
55
+ raw_value = (raw_value or "").strip()
56
+ value = raw_value or default_display
57
+ display = value
58
+ execution = raw_value or default_execution
59
+ normalized = value.strip().lower()
60
+ mapping = PROVIDER_MAPPINGS.get(provider)
61
+ if mapping and normalized in mapping:
62
+ display, execution = mapping[normalized]
63
+ return ModelAssignment(display=display, execution=execution)