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,40 @@
1
+ """User-supplied file path resolution.
2
+
3
+ bash `resolve_brief_path`, `resolve_clarification_response_path`,
4
+ `relative_to_project_root` 의 python 구현. 우선순위: 절대/cwd-기준 → project
5
+ root 기준 → None.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+
13
+ def resolve_user_file(raw: str, project_root: Path) -> Optional[Path]:
14
+ """`raw` 가 (a) 절대경로 또는 cwd 기준으로 존재하면 그 절대경로
15
+ (b) project_root 기준으로 존재하면 그 경로 (c) 둘 다 실패면 None.
16
+
17
+ bash 구현과 동일 의미. cwd-우선이 의도된 동작이다 — 사용자가 IDE 에서
18
+ 프로젝트 일부 디렉터리에 cd 한 채 상대경로를 줄 수 있어야 함.
19
+ """
20
+ if not raw:
21
+ return None
22
+ p = Path(raw)
23
+ if p.is_file():
24
+ return p.resolve()
25
+ if not p.is_absolute():
26
+ in_root = Path(project_root) / raw
27
+ if in_root.is_file():
28
+ return in_root.resolve()
29
+ return None
30
+
31
+
32
+ def relative_to_project_root(path: Path, project_root: Path) -> str:
33
+ """project_root 안의 경로면 그 기준 상대경로 문자열을, 바깥이면 입력값
34
+ 그대로 돌려준다."""
35
+ project_root = Path(project_root).resolve()
36
+ path = Path(path)
37
+ try:
38
+ return str(path.resolve().relative_to(project_root))
39
+ except (ValueError, OSError):
40
+ return str(path)
@@ -0,0 +1,251 @@
1
+ """Pure path computation for an okstra run.
2
+
3
+ Inputs: project root, identity (project-id/task-group/task-id), task-type,
4
+ workspace root, optional run-seq override.
5
+
6
+ Output: dict with every path value the bash render-context used to expose as
7
+ environment variables (TASK_ROOT, RUN_DIR, RUN_MANIFESTS_DIR, RUN_MANIFEST_FILE,
8
+ FINAL_REPORT_FILE, ...). 호출자가 이 dict 를 그대로 인자로 사용하거나
9
+ `write_run_context()` 로 디스크에 박는다.
10
+
11
+ 이 모듈은 read-only 한 path 계산만 담당한다. 디렉터리 생성, 파일 쓰기,
12
+ seq advancement 같은 부수효과는 `run_context.py` 가 담당한다.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from okstra_project.state import slugify
22
+
23
+ OKSTRA_RELATIVE = Path(".project-docs/okstra")
24
+ TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
25
+ DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
26
+
27
+
28
+ def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
29
+ """run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
30
+ 돌려준다. 디렉터리 부재 시 1.
31
+ """
32
+ pattern = re.compile(rf"-{re.escape(task_type_segment)}-(\d{{3}})\.[^.]+$")
33
+ max_seq = 0
34
+ if run_seq_dir.is_dir():
35
+ for entry in os.listdir(run_seq_dir):
36
+ full = run_seq_dir / entry
37
+ if not full.is_file():
38
+ continue
39
+ m = pattern.search(entry)
40
+ if m:
41
+ n = int(m.group(1))
42
+ if n > max_seq:
43
+ max_seq = n
44
+ return max_seq + 1
45
+
46
+
47
+ def _rel(project_root: Path, target: Path) -> str:
48
+ """target 을 project_root 기준 상대경로 문자열로 변환. project_root 바깥이면
49
+ 절대경로 그대로 돌려준다(workspace-root 같은 경우)."""
50
+ try:
51
+ return str(target.resolve().relative_to(project_root.resolve()))
52
+ except (ValueError, OSError):
53
+ return str(target)
54
+
55
+
56
+ def compute_run_paths(
57
+ *,
58
+ project_root: Path,
59
+ workspace_root: Path,
60
+ project_id: str,
61
+ task_group: str,
62
+ task_id: str,
63
+ task_type: str,
64
+ run_seq_override: Optional[int] = None,
65
+ ) -> dict:
66
+ """주어진 identity 와 task-type 에 대해 모든 path/segment 값을 계산해
67
+ dict 로 돌려준다. 부수효과 없음.
68
+
69
+ run_seq_override: 정수가 주어지면 7개 seq 모두 그 값으로 강제. okstra-ctl
70
+ rerun 처럼 외부에서 seq 를 미리 reserve 한 경로용. 미지정 시 디스크 스캔
71
+ 결과로 결정.
72
+ """
73
+ project_root = Path(project_root)
74
+ workspace_root = Path(workspace_root)
75
+
76
+ task_group_segment = slugify(task_group)
77
+ task_id_segment = slugify(task_id)
78
+ task_type_segment = slugify(task_type)
79
+
80
+ if not task_group_segment or not task_id_segment:
81
+ raise ValueError(
82
+ "task-group and task-id must contain at least one alphanumeric character")
83
+ if not task_type_segment:
84
+ raise ValueError("task-type must contain at least one alphanumeric character")
85
+
86
+ okstra_root = project_root / OKSTRA_RELATIVE
87
+ tasks_root = project_root / TASKS_RELATIVE
88
+ discovery_dir = project_root / DISCOVERY_RELATIVE
89
+
90
+ task_root = tasks_root / task_group_segment / task_id_segment
91
+ task_manifest = task_root / "task-manifest.json"
92
+ task_index = task_root / "task-index.md"
93
+ instruction_set = task_root / "instruction-set"
94
+ runs_dir = task_root / "runs"
95
+ history_dir = task_root / "history"
96
+ timeline_file = history_dir / "timeline.json"
97
+
98
+ run_dir = runs_dir / task_type_segment
99
+ run_manifests = run_dir / "manifests"
100
+ run_state = run_dir / "state"
101
+ run_prompts = run_dir / "prompts"
102
+ run_reports = run_dir / "reports"
103
+ run_status = run_dir / "status"
104
+ run_sessions = run_dir / "sessions"
105
+ run_logs = run_dir / "logs"
106
+ worker_results = run_dir / "worker-results"
107
+
108
+ if run_seq_override is not None:
109
+ seq_int = int(run_seq_override)
110
+ seqs = {k: f"{seq_int:03d}" for k in (
111
+ "manifests", "prompts", "reports", "status", "state",
112
+ "sessions", "worker_results")}
113
+ else:
114
+ seqs = {
115
+ "manifests": f"{next_run_seq(run_manifests, task_type_segment):03d}",
116
+ "prompts": f"{next_run_seq(run_prompts, task_type_segment):03d}",
117
+ "reports": f"{next_run_seq(run_reports, task_type_segment):03d}",
118
+ "status": f"{next_run_seq(run_status, task_type_segment):03d}",
119
+ "state": f"{next_run_seq(run_state, task_type_segment):03d}",
120
+ "sessions": f"{next_run_seq(run_sessions, task_type_segment):03d}",
121
+ "worker_results": f"{next_run_seq(worker_results, task_type_segment):03d}",
122
+ }
123
+
124
+ suffixes = {k: f"-{task_type_segment}-{v}" for k, v in seqs.items()}
125
+ run_file_suffix = suffixes["reports"]
126
+
127
+ run_manifest_file = run_manifests / f"run-manifest{suffixes['manifests']}.json"
128
+ run_prompt_snapshot = run_prompts / f"claude-execution-prompt{suffixes['prompts']}.md"
129
+ claude_worker_prompt = run_prompts / f"claude-worker-prompt{suffixes['prompts']}.md"
130
+ codex_worker_prompt = run_prompts / f"codex-worker-prompt{suffixes['prompts']}.md"
131
+ gemini_worker_prompt = run_prompts / f"gemini-worker-prompt{suffixes['prompts']}.md"
132
+ report_writer_worker_prompt = run_prompts / f"report-writer-worker-prompt{suffixes['prompts']}.md"
133
+ final_report = run_reports / f"final-report{suffixes['reports']}.md"
134
+ final_status = run_status / f"final{suffixes['status']}.status"
135
+ team_state = run_state / f"team-state{suffixes['state']}.json"
136
+ final_report_template = instruction_set / "final-report-template.md"
137
+ reference_expectations = instruction_set / "reference-expectations.md"
138
+ claude_resume_command = run_sessions / f"claude-resume{suffixes['sessions']}.sh"
139
+ latest_task_file = discovery_dir / "latest-task.json"
140
+ task_catalog_file = discovery_dir / "task-catalog.json"
141
+ claude_worker_result = worker_results / f"claude-worker{suffixes['worker_results']}.md"
142
+ codex_worker_result = worker_results / f"codex-worker{suffixes['worker_results']}.md"
143
+ gemini_worker_result = worker_results / f"gemini-worker{suffixes['worker_results']}.md"
144
+ report_writer_worker_result = worker_results / f"report-writer-worker{suffixes['worker_results']}.md"
145
+
146
+ run_validator_script = workspace_root / "validators" / "validate-run.py"
147
+
148
+ abs_paths = {
149
+ "PROJECT_ID": project_id,
150
+ "PROJECT_ROOT": str(project_root),
151
+ "WORKSPACE_ROOT": str(workspace_root),
152
+ "TASK_GROUP": task_group,
153
+ "TASK_ID": task_id,
154
+ "TASK_KEY": f"{project_id}:{task_group}:{task_id}",
155
+ "ANALYSIS_TYPE": task_type,
156
+ "TASK_GROUP_SEGMENT": task_group_segment,
157
+ "TASK_ID_SEGMENT": task_id_segment,
158
+ "TASK_TYPE_SEGMENT": task_type_segment,
159
+ "OKSTRA_ROOT": str(okstra_root),
160
+ "OKSTRA_TASKS_ROOT": str(tasks_root),
161
+ "OKSTRA_DISCOVERY_DIR": str(discovery_dir),
162
+ "TASK_ROOT": str(task_root),
163
+ "TASK_MANIFEST_FILE": str(task_manifest),
164
+ "TASK_INDEX_FILE": str(task_index),
165
+ "INSTRUCTION_SET_DIR": str(instruction_set),
166
+ "RUNS_DIR": str(runs_dir),
167
+ "HISTORY_DIR": str(history_dir),
168
+ "TIMELINE_FILE": str(timeline_file),
169
+ "RUN_DIR": str(run_dir),
170
+ "RUN_MANIFESTS_DIR": str(run_manifests),
171
+ "RUN_STATE_DIR": str(run_state),
172
+ "RUN_PROMPTS_DIR": str(run_prompts),
173
+ "RUN_REPORTS_DIR": str(run_reports),
174
+ "RUN_STATUS_DIR": str(run_status),
175
+ "RUN_SESSIONS_DIR": str(run_sessions),
176
+ "RUN_LOGS_DIR": str(run_logs),
177
+ "WORKER_RESULTS_DIR": str(worker_results),
178
+ "RUN_MANIFEST_FILE": str(run_manifest_file),
179
+ "RUN_PROMPT_SNAPSHOT_FILE": str(run_prompt_snapshot),
180
+ "CLAUDE_WORKER_PROMPT_FILE": str(claude_worker_prompt),
181
+ "CODEX_WORKER_PROMPT_FILE": str(codex_worker_prompt),
182
+ "GEMINI_WORKER_PROMPT_FILE": str(gemini_worker_prompt),
183
+ "REPORT_WRITER_WORKER_PROMPT_FILE": str(report_writer_worker_prompt),
184
+ "FINAL_REPORT_FILE": str(final_report),
185
+ "FINAL_STATUS_FILE": str(final_status),
186
+ "TEAM_STATE_FILE": str(team_state),
187
+ "FINAL_REPORT_TEMPLATE_FILE": str(final_report_template),
188
+ "REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
189
+ "CLAUDE_RESUME_COMMAND_FILE": str(claude_resume_command),
190
+ "OKSTRA_LATEST_TASK_FILE": str(latest_task_file),
191
+ "OKSTRA_TASK_CATALOG_FILE": str(task_catalog_file),
192
+ "CLAUDE_WORKER_RESULT_FILE": str(claude_worker_result),
193
+ "CODEX_WORKER_RESULT_FILE": str(codex_worker_result),
194
+ "GEMINI_WORKER_RESULT_FILE": str(gemini_worker_result),
195
+ "REPORT_WRITER_WORKER_RESULT_FILE": str(report_writer_worker_result),
196
+ "RUN_VALIDATOR_SCRIPT": str(run_validator_script),
197
+ "RUN_MANIFEST_FILENAME": run_manifest_file.name,
198
+ "RUN_PROMPT_SNAPSHOT_FILENAME": run_prompt_snapshot.name,
199
+ "FINAL_REPORT_FILENAME": final_report.name,
200
+ "FINAL_STATUS_FILENAME": final_status.name,
201
+ "CLAUDE_RESUME_COMMAND_FILENAME": claude_resume_command.name,
202
+ "RUN_FILE_SUFFIX": run_file_suffix,
203
+ "RUN_MANIFESTS_SEQ": seqs["manifests"],
204
+ "RUN_PROMPTS_SEQ": seqs["prompts"],
205
+ "RUN_REPORTS_SEQ": seqs["reports"],
206
+ "RUN_STATUS_SEQ": seqs["status"],
207
+ "RUN_STATE_SEQ": seqs["state"],
208
+ "RUN_SESSIONS_SEQ": seqs["sessions"],
209
+ "WORKER_RESULTS_SEQ": seqs["worker_results"],
210
+ "LATEST_RUN_PATH": str(run_dir),
211
+ }
212
+ rel_pairs = [
213
+ ("OKSTRA_DISCOVERY_RELATIVE_PATH", discovery_dir),
214
+ ("OKSTRA_LATEST_TASK_RELATIVE_PATH", latest_task_file),
215
+ ("OKSTRA_TASK_CATALOG_RELATIVE_PATH", task_catalog_file),
216
+ ("TASK_ROOT_RELATIVE_PATH", task_root),
217
+ ("TASK_MANIFEST_RELATIVE_PATH", task_manifest),
218
+ ("TASK_INDEX_RELATIVE_PATH", task_index),
219
+ ("INSTRUCTION_SET_RELATIVE_PATH", instruction_set),
220
+ ("RUNS_RELATIVE_PATH", runs_dir),
221
+ ("HISTORY_RELATIVE_PATH", history_dir),
222
+ ("TIMELINE_RELATIVE_PATH", timeline_file),
223
+ ("RUN_DIR_RELATIVE_PATH", run_dir),
224
+ ("RUN_MANIFESTS_RELATIVE_PATH", run_manifests),
225
+ ("RUN_STATE_RELATIVE_PATH", run_state),
226
+ ("RUN_PROMPTS_RELATIVE_PATH", run_prompts),
227
+ ("RUN_REPORTS_RELATIVE_PATH", run_reports),
228
+ ("RUN_STATUS_RELATIVE_PATH", run_status),
229
+ ("RUN_SESSIONS_RELATIVE_PATH", run_sessions),
230
+ ("RUN_MANIFEST_RELATIVE_PATH", run_manifest_file),
231
+ ("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", run_prompt_snapshot),
232
+ ("CLAUDE_WORKER_PROMPT_RELATIVE_PATH", claude_worker_prompt),
233
+ ("CODEX_WORKER_PROMPT_RELATIVE_PATH", codex_worker_prompt),
234
+ ("GEMINI_WORKER_PROMPT_RELATIVE_PATH", gemini_worker_prompt),
235
+ ("REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH", report_writer_worker_prompt),
236
+ ("FINAL_REPORT_RELATIVE_PATH", final_report),
237
+ ("FINAL_STATUS_RELATIVE_PATH", final_status),
238
+ ("TEAM_STATE_RELATIVE_PATH", team_state),
239
+ ("WORKER_RESULTS_RELATIVE_PATH", worker_results),
240
+ ("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
241
+ ("REFERENCE_EXPECTATIONS_RELATIVE_PATH", reference_expectations),
242
+ ("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", claude_resume_command),
243
+ ("RUN_VALIDATOR_RELATIVE_PATH", run_validator_script),
244
+ ("CLAUDE_WORKER_RESULT_RELATIVE_PATH", claude_worker_result),
245
+ ("CODEX_WORKER_RESULT_RELATIVE_PATH", codex_worker_result),
246
+ ("GEMINI_WORKER_RESULT_RELATIVE_PATH", gemini_worker_result),
247
+ ("REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH", report_writer_worker_result),
248
+ ("LATEST_RUN_RELATIVE_PATH", run_dir),
249
+ ]
250
+ rel_paths = {key: _rel(project_root, target) for key, target in rel_pairs}
251
+ return {**abs_paths, **rel_paths}
@@ -0,0 +1,51 @@
1
+ """프로젝트 meta.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
+
10
+ def _project_meta_path(home: Path, project_id: str) -> Path:
11
+ return home / "projects" / project_id / "meta.json"
12
+
13
+
14
+ def load_project_meta(home: Path, project_id: str) -> Optional[dict]:
15
+ """프로젝트 meta.json 을 읽는다. 없으면 None."""
16
+ p = _project_meta_path(home, project_id)
17
+ if not p.is_file():
18
+ return None
19
+ return json.loads(p.read_text())
20
+
21
+
22
+ def upsert_project_meta(home: Path, project_id: str, *,
23
+ project_root: str, when: str,
24
+ started: bool = False, finished: bool = False) -> None:
25
+ """프로젝트 meta.json 을 read-modify-write 로 갱신.
26
+ started=True 면 runCount+1, activeCount+1, lastRunAt 갱신, 최초이면 firstRunAt 도 설정.
27
+ finished=True 면 activeCount-1, lastRunAt 갱신.
28
+ """
29
+ target = _project_meta_path(home, project_id)
30
+ target.parent.mkdir(parents=True, exist_ok=True)
31
+ if target.is_file():
32
+ meta = json.loads(target.read_text())
33
+ else:
34
+ meta = {
35
+ "projectId": project_id,
36
+ "projectRoot": project_root,
37
+ "firstRunAt": when,
38
+ "lastRunAt": when,
39
+ "runCount": 0,
40
+ "activeCount": 0,
41
+ }
42
+ meta["projectRoot"] = project_root
43
+ meta["lastRunAt"] = when
44
+ if started:
45
+ meta["runCount"] = int(meta.get("runCount", 0)) + 1
46
+ meta["activeCount"] = int(meta.get("activeCount", 0)) + 1
47
+ if finished:
48
+ meta["activeCount"] = max(0, int(meta.get("activeCount", 0)) - 1)
49
+ tmp = target.with_suffix(".json.tmp")
50
+ tmp.write_text(json.dumps(meta, indent=2) + "\n")
51
+ os.replace(tmp, target)
@@ -0,0 +1,166 @@
1
+ """reconciliation/normalization."""
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 Optional
9
+
10
+ from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
11
+ from .locks import central_lock
12
+ from .project_meta import upsert_project_meta
13
+
14
+
15
+ DEFAULT_ABORT_AFTER_SECONDS = 12 * 3600 # 12시간 무진척이면 aborted 로 간주
16
+
17
+
18
+ def _now_iso() -> str:
19
+ return datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%SZ")
20
+
21
+
22
+ def _parse_iso(ts: str) -> Optional[datetime]:
23
+ try:
24
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
25
+ except (ValueError, TypeError):
26
+ return None
27
+
28
+
29
+ def normalize_central_status(manifest_status: str, validation_status: str) -> str:
30
+ """Map okstra manifest/validation statuses to control-center statuses."""
31
+ failed_values = {
32
+ "failed",
33
+ "contract-violation",
34
+ "contract-violated",
35
+ "validation-failed",
36
+ }
37
+ manifest_status = str(manifest_status or "")
38
+ validation_status = str(validation_status or "")
39
+ if manifest_status in failed_values or validation_status in failed_values:
40
+ return "failed"
41
+ if manifest_status in {"aborted", "prepared", "running", "in-progress"}:
42
+ return manifest_status
43
+ if manifest_status in {"error", "errored"}:
44
+ return "failed"
45
+ return "completed"
46
+
47
+
48
+ def normalize_reconciled_report_status(validation_status: str) -> str:
49
+ """Final report existence means terminal success unless validation failed."""
50
+ return normalize_central_status("completed", validation_status)
51
+
52
+
53
+ def _read_run_manifest_validation(project_root: Path, run_dir_rel: str,
54
+ task_type: str, run_seq: int) -> str:
55
+ """run-manifest 의 validation.status 를 읽는다. 못 읽으면 'not-run'.
56
+ 완료/실패 여부를 정확히 반영하기 위함 — 단순히 final-report 존재만으로
57
+ 'passed' 로 판정하면 contract-violation/failed 를 잘못 기록한다.
58
+ """
59
+ if not run_dir_rel:
60
+ return "not-run"
61
+ suffix = f"-{task_type}-{run_seq:03d}"
62
+ manifest = project_root / run_dir_rel / "manifests" / f"run-manifest{suffix}.json"
63
+ if not manifest.is_file():
64
+ return "not-run"
65
+ try:
66
+ data = json.loads(manifest.read_text())
67
+ except (OSError, json.JSONDecodeError):
68
+ return "not-run"
69
+ validation = data.get("validation")
70
+ if isinstance(validation, dict):
71
+ return str(validation.get("status") or "not-run")
72
+ if isinstance(validation, str):
73
+ return validation
74
+ return "not-run"
75
+
76
+
77
+ def reconcile_active(home: Path, *,
78
+ abort_after_seconds: int = DEFAULT_ABORT_AFTER_SECONDS,
79
+ project: Optional[str] = None) -> dict:
80
+ """active.jsonl 의 각 행을 검사해 종결 추론. 종결된 행은 recent 로 이동.
81
+ project 가 주어지면 해당 projectId 행만 추론 대상으로 삼고, 다른 프로젝트
82
+ 행은 그대로 보존한다(스코프 reconcile).
83
+ 중앙 락 안에서 read-modify-write 하므로 동시에 일어나는 record_start 와
84
+ 같은 active 행 append 가 누락되지 않는다.
85
+ """
86
+ with central_lock(home):
87
+ return _reconcile_active_locked(home,
88
+ abort_after_seconds=abort_after_seconds,
89
+ project=project)
90
+
91
+
92
+ def _reconcile_active_locked(home: Path, *,
93
+ abort_after_seconds: int,
94
+ project: Optional[str] = None) -> dict:
95
+ active = home / "active.jsonl"
96
+ summary = {"completed": 0, "aborted": 0, "running": 0}
97
+ rows = read_jsonl(active)
98
+ if not rows:
99
+ return summary
100
+ survivors = []
101
+ promoted = []
102
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
103
+ for row in rows:
104
+ # project 필터: 스코프 외 행은 추론 없이 active 에 그대로 남긴다.
105
+ if project and row.get("projectId") != project:
106
+ survivors.append(row)
107
+ summary["running"] += 1
108
+ continue
109
+ project_root = Path(row.get("projectRoot", ""))
110
+ final_rel = row.get("finalReportRel", "")
111
+ final_abs = project_root / final_rel if final_rel else None
112
+ if final_abs and final_abs.is_file():
113
+ validation_status = _read_run_manifest_validation(
114
+ project_root, row.get("runDirRel", ""),
115
+ row.get("taskType", ""), int(row.get("runSeq", 0)))
116
+ # validate-okstra-run.py 가 아직 실행되지 않은 시점에는 final-report
117
+ # 만 존재하고 validation 은 'not-run' 으로 보고된다. 이때 promote 하면
118
+ # normalize_reconciled_report_status 가 'completed' 로 매핑하여 행이
119
+ # recent 로 옮겨가고, 이후 validator 가 failed/contract-violated 를
120
+ # 기록해도 reconcile 이 다시 보지 않아 영구히 잘못된 상태로 남는다.
121
+ # validation 이 terminal 신호('not-run' 이외) 일 때만 promote 하고,
122
+ # 그 외에는 active 에 유지해 후속 reconcile / abort timeout 에 맡긴다.
123
+ if validation_status and validation_status != "not-run":
124
+ row["status"] = normalize_reconciled_report_status(validation_status)
125
+ row["finishedAt"] = _now_iso()
126
+ row["validation"] = validation_status
127
+ promoted.append(row)
128
+ if row["status"] == "completed":
129
+ summary["completed"] += 1
130
+ else:
131
+ summary["completed"] += 0 # 실패도 종결로 분류하지만 카운터는 분리
132
+ continue
133
+ started = _parse_iso(row.get("startedAt", ""))
134
+ if started and (now - started).total_seconds() > abort_after_seconds:
135
+ row["status"] = "aborted"
136
+ row["finishedAt"] = _now_iso()
137
+ promoted.append(row)
138
+ summary["aborted"] += 1
139
+ continue
140
+ survivors.append(row)
141
+ summary["running"] += 1
142
+ if not promoted:
143
+ return summary
144
+ tmp = active.with_suffix(".jsonl.tmp")
145
+ with tmp.open("w") as f:
146
+ for row in survivors:
147
+ f.write(json.dumps(row, separators=(",", ":")) + "\n")
148
+ os.replace(tmp, active)
149
+ for row in promoted:
150
+ append_jsonl(home / "recent.jsonl", row)
151
+ proj_index = home / "projects" / row["projectId"] / "index.jsonl"
152
+ if proj_index.is_file():
153
+ existing = read_jsonl(proj_index)
154
+ for er in existing:
155
+ if er.get("runId") == row["runId"]:
156
+ er.update({k: row[k] for k in ("status", "finishedAt", "validation")})
157
+ tmp = proj_index.with_suffix(".jsonl.tmp")
158
+ with tmp.open("w") as f:
159
+ for er in existing:
160
+ f.write(json.dumps(er, separators=(",", ":")) + "\n")
161
+ os.replace(tmp, proj_index)
162
+ upsert_project_meta(home, row["projectId"],
163
+ project_root=row["projectRoot"],
164
+ when=row["finishedAt"], finished=True)
165
+ rotate_recent_if_needed(home)
166
+ return summary