okstra 0.96.1 → 0.97.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/kr/architecture/storage-model.md +273 -0
- package/docs/kr/architecture.md +6 -277
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
- package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
- package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
- package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
- package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
- package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
- package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
- package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +30 -4
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
- package/runtime/bin/okstra-render-final-report.py +4 -15
- package/runtime/bin/okstra.sh +5 -2
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +10 -4
- package/runtime/python/okstra_ctl/__init__.py +3 -2
- package/runtime/python/okstra_ctl/backfill.py +12 -21
- package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
- package/runtime/python/okstra_ctl/conformance.py +21 -3
- package/runtime/python/okstra_ctl/consumers.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
- package/runtime/python/okstra_ctl/doctor.py +29 -12
- package/runtime/python/okstra_ctl/error_log_core.py +72 -0
- package/runtime/python/okstra_ctl/error_report.py +8 -58
- package/runtime/python/okstra_ctl/error_zip.py +319 -0
- package/runtime/python/okstra_ctl/fanout.py +5 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
- package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
- package/runtime/python/okstra_ctl/handoff.py +125 -26
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
- package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
- package/runtime/python/okstra_ctl/index.py +34 -91
- package/runtime/python/okstra_ctl/jsonl.py +29 -18
- package/runtime/python/okstra_ctl/listing.py +30 -5
- package/runtime/python/okstra_ctl/md_table.py +1 -1
- package/runtime/python/okstra_ctl/migrate.py +17 -7
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/recap.py +12 -3
- package/runtime/python/okstra_ctl/reconcile.py +13 -11
- package/runtime/python/okstra_ctl/render.py +49 -12
- package/runtime/python/okstra_ctl/render_final_report.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +47 -6
- package/runtime/python/okstra_ctl/run.py +128 -100
- package/runtime/python/okstra_ctl/run_index_row.py +118 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
- package/runtime/python/okstra_ctl/sequence.py +2 -2
- package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
- package/runtime/python/okstra_ctl/stage_targets.py +2 -8
- package/runtime/python/okstra_ctl/task_target.py +4 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
- package/runtime/python/okstra_ctl/wizard.py +27 -12
- package/runtime/python/okstra_ctl/workflow.py +9 -1
- package/runtime/python/okstra_ctl/worktree.py +93 -4
- package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
- package/runtime/python/okstra_token_usage/collect.py +43 -38
- package/runtime/skills/okstra-brief/SKILL.md +43 -12
- package/runtime/skills/okstra-inspect/SKILL.md +44 -1
- package/runtime/templates/reports/final-report.template.md +2 -2
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/validators/forbidden_actions.py +8 -1
- package/runtime/validators/validate-brief.py +13 -2
- package/runtime/validators/validate-run.py +27 -6
- package/runtime/validators/validate-schedule.py +1 -2
- package/runtime/validators/validate_improvement_report.py +14 -0
- package/src/cli-registry.mjs +45 -31
- package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
- package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
- package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
- package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
- package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
- package/src/commands/execute/integrate-stages.mjs +25 -0
- package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
- package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
- package/src/{run.mjs → commands/execute/run.mjs} +4 -4
- package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
- package/src/{team.mjs → commands/execute/team.mjs} +3 -3
- package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
- package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
- package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
- package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
- package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
- package/src/commands/inspect/error-zip.mjs +25 -0
- package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
- package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
- package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
- package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
- package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
- package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
- package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
- package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
- package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
- package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
- package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
- package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
- package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
- package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
- package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
- package/src/lib/paths.mjs +60 -0
- package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
- package/src/{version.mjs → lib/version.mjs} +2 -2
- /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
- /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
- /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
- /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
- /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
|
@@ -34,6 +34,8 @@ def is_valid_lens(value: str) -> bool:
|
|
|
34
34
|
def is_valid_lens_subset(values: list[str]) -> bool:
|
|
35
35
|
if not (MIN_PRIORITY_LENSES <= len(values) <= MAX_PRIORITY_LENSES):
|
|
36
36
|
return False
|
|
37
|
+
if len(set(values)) != len(values):
|
|
38
|
+
return False
|
|
37
39
|
return all(v in LENSES for v in values)
|
|
38
40
|
|
|
39
41
|
|
|
@@ -6,49 +6,28 @@ import os
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from .ids import build_run_id
|
|
9
|
-
from .invocation import
|
|
9
|
+
from .invocation import save_invocation
|
|
10
10
|
from .jsonl import append_jsonl, read_jsonl, remove_jsonl_row, rotate_recent_if_needed
|
|
11
11
|
from .project_meta import upsert_project_meta
|
|
12
|
+
from .run_index_row import build_run_index_row, slim_run_row
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
def _replace_or_append_row(path: Path, run_id: str, row: dict) -> None:
|
|
16
|
+
"""run_id 매칭 row 를 교체하고 없으면 append. 디스크에는 항상 slim 으로
|
|
17
|
+
기록한다 — 파생 4필드가 새지 않도록 모든 인덱스 쓰기가 거치는 단일 길목."""
|
|
18
|
+
slim = slim_run_row(row)
|
|
19
|
+
rows = read_jsonl(path)
|
|
18
20
|
for i, r in enumerate(rows):
|
|
19
21
|
if r.get("runId") == run_id:
|
|
20
|
-
rows[i] =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
|
22
|
+
rows[i] = slim
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
25
|
+
with tmp.open("w") as f:
|
|
26
|
+
for rr in rows:
|
|
27
|
+
f.write(json.dumps(rr, separators=(",", ":")) + "\n")
|
|
28
|
+
os.replace(tmp, path)
|
|
29
|
+
return
|
|
30
|
+
append_jsonl(path, slim)
|
|
52
31
|
|
|
53
32
|
|
|
54
33
|
def record_start(home: Path, *, project_id: str, project_root: str,
|
|
@@ -65,36 +44,17 @@ def record_start(home: Path, *, project_id: str, project_root: str,
|
|
|
65
44
|
반환값: 생성된 runId.
|
|
66
45
|
"""
|
|
67
46
|
run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
|
|
68
|
-
inv_path_abs = invocation_path(home, project_id, task_group, task_id,
|
|
69
|
-
task_type, run_seq)
|
|
70
|
-
inv_path_rel = inv_path_abs.relative_to(home)
|
|
71
47
|
# 종결 상태(prepared/aborted/etc) 로 시작하는 경우 finishedAt 도 같이 채워
|
|
72
48
|
# 인덱스 위치를 active 가 아닌 recent 로 라우팅한다.
|
|
73
49
|
is_terminal = initial_status != "running"
|
|
74
|
-
row =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"runSeq": run_seq,
|
|
83
|
-
"status": initial_status,
|
|
84
|
-
"startedAt": when,
|
|
85
|
-
"finishedAt": when if is_terminal else None,
|
|
86
|
-
"workers": workers,
|
|
87
|
-
"leadModel": lead_model,
|
|
88
|
-
"validation": "not-run",
|
|
89
|
-
"finalReportRel": final_report_rel,
|
|
90
|
-
# FINAL_STATUS_PATH 의 카운터(RUN_STATUS_SEQ) 는 RUN_MANIFESTS_SEQ
|
|
91
|
-
# 와 별개로 advance 한다(render-only/실패 prep 은 manifest 만 증가).
|
|
92
|
-
# 따라서 status 파일을 추적할 때는 manifest seq 를 추정해 glob 으로
|
|
93
|
-
# 찾지 말고 run 시작 시점의 정식 경로를 row 에 박아 두고 사용한다.
|
|
94
|
-
"finalStatusRel": final_status_rel,
|
|
95
|
-
"runDirRel": run_dir_rel,
|
|
96
|
-
"invocationFile": str(inv_path_rel),
|
|
97
|
-
}
|
|
50
|
+
row = build_run_index_row(
|
|
51
|
+
project_id=project_id, project_root=project_root,
|
|
52
|
+
task_group=task_group, task_id=task_id, task_type=task_type,
|
|
53
|
+
run_seq=run_seq, status=initial_status, started_at=when,
|
|
54
|
+
finished_at=when if is_terminal else None,
|
|
55
|
+
workers=workers, lead_model=lead_model, validation="not-run",
|
|
56
|
+
run_dir_rel=run_dir_rel, final_report_rel=final_report_rel,
|
|
57
|
+
final_status_rel=final_status_rel)
|
|
98
58
|
# okstra-ctl 가 미리 'reserving' 예약 row 를 작성했을 수 있다(P1-2). 같은
|
|
99
59
|
# runId 의 예약 row 가 있으면 update; 없으면 append.
|
|
100
60
|
existing_rows = read_jsonl(home / "active.jsonl")
|
|
@@ -118,9 +78,10 @@ def record_start(home: Path, *, project_id: str, project_root: str,
|
|
|
118
78
|
# 넘어도 archive 로 이동되지 않는다.
|
|
119
79
|
rotate_recent_if_needed(home)
|
|
120
80
|
else:
|
|
121
|
-
|
|
81
|
+
_replace_or_append_row(home / "active.jsonl", run_id, row)
|
|
122
82
|
had_recent = False
|
|
123
|
-
|
|
83
|
+
_replace_or_append_row(
|
|
84
|
+
home / "projects" / project_id / "index.jsonl", run_id, row)
|
|
124
85
|
save_invocation(home, project_id, task_group, task_id, task_type, run_seq, {
|
|
125
86
|
"runId": run_id,
|
|
126
87
|
"okstraVersion": okstra_version or os.environ.get("OKSTRA_SCRIPT_VERSION", ""),
|
|
@@ -156,31 +117,13 @@ def reserve_run_in_active(home: Path, *,
|
|
|
156
117
|
record_start 가 나중에 같은 (project, group, task, task_type, seq) 를 만나면
|
|
157
118
|
이 row 를 update 한다.
|
|
158
119
|
"""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"taskGroup": task_group,
|
|
167
|
-
"taskId": task_id,
|
|
168
|
-
"taskKey": f"{task_group}/{task_id}",
|
|
169
|
-
"taskType": task_type,
|
|
170
|
-
"runSeq": run_seq,
|
|
171
|
-
"status": "reserving",
|
|
172
|
-
"startedAt": when,
|
|
173
|
-
"finishedAt": None,
|
|
174
|
-
"workers": [],
|
|
175
|
-
"leadModel": "",
|
|
176
|
-
"validation": "not-run",
|
|
177
|
-
"finalReportRel": final_report_rel,
|
|
178
|
-
# 예약 시점 best-effort: 정상 run 은 RUN_STATUS_SEQ == RUN_MANIFESTS_SEQ
|
|
179
|
-
# 라 일치한다. record_start 가 실제 경로로 update 한다.
|
|
180
|
-
"finalStatusRel": final_status_rel,
|
|
181
|
-
"runDirRel": run_dir_rel,
|
|
182
|
-
"invocationFile": str(inv_rel),
|
|
183
|
-
}
|
|
120
|
+
row = build_run_index_row(
|
|
121
|
+
project_id=project_id, project_root=project_root,
|
|
122
|
+
task_group=task_group, task_id=task_id, task_type=task_type,
|
|
123
|
+
run_seq=run_seq, status="reserving", started_at=when,
|
|
124
|
+
finished_at=None, workers=[], lead_model="", validation="not-run",
|
|
125
|
+
run_dir_rel=run_dir_rel, final_report_rel=final_report_rel,
|
|
126
|
+
final_status_rel=final_status_rel)
|
|
184
127
|
append_jsonl(home / "active.jsonl", row)
|
|
185
128
|
# project index 도 미리 한 줄 — record_start 가 나중에 update 하므로 동일 row 형태로.
|
|
186
129
|
append_jsonl(home / "projects" / project_id / "index.jsonl", row)
|
|
@@ -21,14 +21,15 @@ def read_jsonl(path: Path) -> List[dict]:
|
|
|
21
21
|
if not path.is_file():
|
|
22
22
|
return []
|
|
23
23
|
rows: List[dict] = []
|
|
24
|
-
|
|
25
|
-
line
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
with path.open() as f:
|
|
25
|
+
for line in f:
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if not line:
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
rows.append(json.loads(line))
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
continue
|
|
32
33
|
return rows
|
|
33
34
|
|
|
34
35
|
|
|
@@ -66,19 +67,29 @@ def rotate_recent_if_needed(home: Path, max_rows: int = 2000,
|
|
|
66
67
|
recent = home / "recent.jsonl"
|
|
67
68
|
if not recent.is_file():
|
|
68
69
|
return None
|
|
69
|
-
|
|
70
|
+
with recent.open() as f:
|
|
71
|
+
rows = list(f)
|
|
70
72
|
size_ok = recent.stat().st_size < max_bytes
|
|
71
73
|
rows_ok = len(rows) < max_rows
|
|
72
74
|
if size_ok and rows_ok:
|
|
73
75
|
return None
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
with archive.open("a") as out:
|
|
81
|
-
for line in rows:
|
|
76
|
+
last_archive: Optional[Path] = None
|
|
77
|
+
for line in rows:
|
|
78
|
+
archive = _archive_path_for_line(home, line)
|
|
79
|
+
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with archive.open("a") as out:
|
|
82
81
|
out.write(line)
|
|
82
|
+
last_archive = archive
|
|
83
83
|
recent.write_text("")
|
|
84
|
-
return
|
|
84
|
+
return last_archive
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _archive_path_for_line(home: Path, line: str) -> Path:
|
|
88
|
+
"""행의 finishedAt 월(YYYY-MM)로 archive/YYYY/YYYY-MM.jsonl 경로 결정. 없으면 현재 월."""
|
|
89
|
+
try:
|
|
90
|
+
finished = json.loads(line).get("finishedAt")
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
finished = None
|
|
93
|
+
finished = finished or datetime.now(timezone.utc).replace(
|
|
94
|
+
tzinfo=None).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
95
|
+
return home / "archive" / finished[:4] / f"{finished[:7]}.jsonl"
|
|
@@ -7,8 +7,8 @@ from datetime import datetime, timedelta, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import List, Optional
|
|
9
9
|
|
|
10
|
-
from .jsonl import read_jsonl
|
|
11
10
|
from .reconcile import _parse_iso
|
|
11
|
+
from .run_index_row import read_run_index
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def list_projects(home: Path, *, only_active: bool = False,
|
|
@@ -22,7 +22,12 @@ def list_projects(home: Path, *, only_active: bool = False,
|
|
|
22
22
|
meta_file = child / "meta.json"
|
|
23
23
|
if not meta_file.is_file():
|
|
24
24
|
continue
|
|
25
|
-
|
|
25
|
+
try:
|
|
26
|
+
meta = json.loads(meta_file.read_text())
|
|
27
|
+
except (OSError, json.JSONDecodeError):
|
|
28
|
+
# 동시 쓰기/이전 크래시로 깨진 meta.json 하나가 전체 listing 을
|
|
29
|
+
# 죽이지 않도록 해당 프로젝트만 건너뛴다(다른 JSON 리더와 동일 정책).
|
|
30
|
+
continue
|
|
26
31
|
# projectRoot 가 누락/빈 문자열이면 Path("").is_dir() 이 cwd 를 가리켜
|
|
27
32
|
# True 가 되므로(missing 으로 잡히지 않음), 빈 값은 명시적으로 missing
|
|
28
33
|
# 처리한다.
|
|
@@ -77,16 +82,36 @@ def _parse_since(value: str) -> Optional[datetime]:
|
|
|
77
82
|
) from exc
|
|
78
83
|
|
|
79
84
|
|
|
85
|
+
def _parse_started_at(ts: str) -> Optional[datetime]:
|
|
86
|
+
"""--since 비교용 startedAt 파싱. 기준값은 naive UTC 이므로 결과도 naive UTC.
|
|
87
|
+
|
|
88
|
+
_parse_iso 는 `...Z` 형식만 받지만 backfill 이 manifest createdAt 을 그대로
|
|
89
|
+
startedAt 으로 옮기면 offset 형식(`+00:00`)이 섞일 수 있다. 그 경우 None 으로
|
|
90
|
+
빠지면 --since 필터가 fail-open 으로 모든 row 를 통과시키므로, fromisoformat
|
|
91
|
+
fallback 으로 offset 형식도 받아 naive UTC 로 정규화한다.
|
|
92
|
+
"""
|
|
93
|
+
parsed = _parse_iso(ts)
|
|
94
|
+
if parsed is not None:
|
|
95
|
+
return parsed
|
|
96
|
+
try:
|
|
97
|
+
dt = datetime.fromisoformat(ts)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
return None
|
|
100
|
+
if dt.tzinfo is not None:
|
|
101
|
+
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
102
|
+
return dt
|
|
103
|
+
|
|
104
|
+
|
|
80
105
|
def list_runs(home: Path, *, project: str = "all", task_group: str = "all",
|
|
81
106
|
status: str = "all", since: str = "", limit: int = 0,
|
|
82
107
|
include_archive: bool = False) -> List[dict]:
|
|
83
108
|
"""active + recent (옵션 archive) 를 합쳐 필터/정렬 후 반환."""
|
|
84
|
-
rows =
|
|
109
|
+
rows = read_run_index(home / "active.jsonl") + read_run_index(home / "recent.jsonl")
|
|
85
110
|
if include_archive:
|
|
86
111
|
archive_dir = home / "archive"
|
|
87
112
|
if archive_dir.is_dir():
|
|
88
113
|
for f in sorted(archive_dir.glob("*/*.jsonl")):
|
|
89
|
-
rows.extend(
|
|
114
|
+
rows.extend(read_run_index(f))
|
|
90
115
|
threshold = _parse_since(since)
|
|
91
116
|
out: List[dict] = []
|
|
92
117
|
for r in rows:
|
|
@@ -97,7 +122,7 @@ def list_runs(home: Path, *, project: str = "all", task_group: str = "all",
|
|
|
97
122
|
if status != "all" and r.get("status") != status:
|
|
98
123
|
continue
|
|
99
124
|
if threshold:
|
|
100
|
-
started =
|
|
125
|
+
started = _parse_started_at(r.get("startedAt", ""))
|
|
101
126
|
if started and started < threshold:
|
|
102
127
|
continue
|
|
103
128
|
out.append(r)
|
|
@@ -15,7 +15,7 @@ from typing import Any
|
|
|
15
15
|
# A `|` not preceded by a backslash: a real column boundary.
|
|
16
16
|
UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
|
|
17
17
|
|
|
18
|
-
_SEPARATOR_CELL_RE = re.compile(r"\s
|
|
18
|
+
_SEPARATOR_CELL_RE = re.compile(r"\s*:?-+:?\s*")
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def escape_pipes(value: Any) -> str:
|
|
@@ -127,7 +127,11 @@ def prepare_migration_plan(
|
|
|
127
127
|
text = gitignore.read_text(encoding="utf-8")
|
|
128
128
|
except OSError:
|
|
129
129
|
text = ""
|
|
130
|
-
|
|
130
|
+
# Detect using the same line predicate apply uses, so a previewed
|
|
131
|
+
# gitignore_path always corresponds to an actual rewrite. A bare
|
|
132
|
+
# substring match would flag comments or longer paths
|
|
133
|
+
# (`.project-docs/okstra-archive/`) that _update_gitignore never touches.
|
|
134
|
+
if any(_is_legacy_gitignore_line(line) for line in text.splitlines()):
|
|
131
135
|
gitignore_path = gitignore
|
|
132
136
|
|
|
133
137
|
registry_updates = _scan_registries(
|
|
@@ -238,19 +242,25 @@ def _maybe_remove_empty_parent(plan: MigrationPlan) -> bool:
|
|
|
238
242
|
return False
|
|
239
243
|
|
|
240
244
|
|
|
245
|
+
def _is_legacy_gitignore_line(line: str) -> bool:
|
|
246
|
+
"""A gitignore line that names exactly the legacy okstra entry.
|
|
247
|
+
|
|
248
|
+
Comment lines and longer paths (`.project-docs/okstra-archive/`) that merely
|
|
249
|
+
contain the legacy prefix are intentionally excluded — they are not the
|
|
250
|
+
ignore rule the migration rewrites.
|
|
251
|
+
"""
|
|
252
|
+
stripped = line.strip()
|
|
253
|
+
return stripped == LEGACY_OKSTRA_DIR_NAME or stripped == f"{LEGACY_OKSTRA_DIR_NAME}/"
|
|
254
|
+
|
|
255
|
+
|
|
241
256
|
def _update_gitignore(plan: MigrationPlan) -> bool:
|
|
242
257
|
if plan.gitignore_path is None:
|
|
243
258
|
return False
|
|
244
259
|
text = plan.gitignore_path.read_text(encoding="utf-8")
|
|
245
|
-
# Match both forms: with and without trailing slash. Use word-boundary-ish
|
|
246
|
-
# replacement by anchoring on the legacy prefix; downstream comment lines
|
|
247
|
-
# that mention the old path in narrative are left alone because they
|
|
248
|
-
# never start at line-start with the bare path.
|
|
249
260
|
new_lines = []
|
|
250
261
|
changed = False
|
|
251
262
|
for line in text.splitlines(keepends=True):
|
|
252
|
-
|
|
253
|
-
if stripped == LEGACY_OKSTRA_DIR_NAME or stripped == f"{LEGACY_OKSTRA_DIR_NAME}/":
|
|
263
|
+
if _is_legacy_gitignore_line(line):
|
|
254
264
|
indent = line[: len(line) - len(line.lstrip())]
|
|
255
265
|
new_lines.append(f"{indent}{OKSTRA_DIR_NAME}/\n")
|
|
256
266
|
changed = True
|
|
@@ -60,7 +60,7 @@ def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
|
|
|
60
60
|
"""run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
|
|
61
61
|
돌려준다. 디렉터리 부재 시 1.
|
|
62
62
|
"""
|
|
63
|
-
pattern = re.compile(rf"-{re.escape(task_type_segment)}-(\d{{3}})\.[^.]+$")
|
|
63
|
+
pattern = re.compile(rf"-{re.escape(task_type_segment)}-(\d{{3,}})\.[^.]+$")
|
|
64
64
|
max_seq = 0
|
|
65
65
|
if run_seq_dir.is_dir():
|
|
66
66
|
for entry in os.listdir(run_seq_dir):
|
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from okstra_ctl.run_context import dir_flock
|
|
13
14
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
14
15
|
|
|
15
16
|
|
|
@@ -55,7 +56,10 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
|
55
56
|
"reportPath": run.get("reportPath", ""),
|
|
56
57
|
})
|
|
57
58
|
prev_phase = cur
|
|
58
|
-
|
|
59
|
+
# latestPhaseStates 는 항상 가장 최근 run 의 상태를 반영해야 한다.
|
|
60
|
+
# 빈/누락 snapshot 일 때 이전 run 값으로 폴백하면 오래된 phase map 을
|
|
61
|
+
# 최신 상태인 양 보고하게 된다.
|
|
62
|
+
latest_states = snap.get("phaseStates", {}) or {}
|
|
59
63
|
return {
|
|
60
64
|
"taskKey": _task_key(task_root),
|
|
61
65
|
"runCount": len(runs),
|
|
@@ -77,8 +81,13 @@ def append_recap_entry(task_root, project_root, *, kind, mode, question,
|
|
|
77
81
|
"answerSummary": answer_summary,
|
|
78
82
|
"citations": list(citations),
|
|
79
83
|
}
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
# 같은 task 에 대한 동시 record 호출이 한 JSONL 줄을 쪼개지 않도록
|
|
85
|
+
# open+write+close(flush) 전체를 dir_flock 으로 직렬화한다 (consumers.jsonl
|
|
86
|
+
# 과 같은 append-only idiom — 데이터 fd 가 아닌 별도 .lock 파일을 잠가야
|
|
87
|
+
# flush 가 락 안에서 끝난다).
|
|
88
|
+
with dir_flock(recap_dir, ".recap-log.lock"):
|
|
89
|
+
with log_path.open("a", encoding="utf-8") as fh:
|
|
90
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
82
91
|
return {"logPath": project_rel(log_path, project_root), "entry": entry}
|
|
83
92
|
|
|
84
93
|
|
|
@@ -7,9 +7,10 @@ from datetime import datetime, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
|
-
from .jsonl import append_jsonl,
|
|
10
|
+
from .jsonl import append_jsonl, rotate_recent_if_needed
|
|
11
11
|
from .locks import central_lock
|
|
12
12
|
from .project_meta import upsert_project_meta
|
|
13
|
+
from .run_index_row import read_run_index, slim_run_row
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
DEFAULT_ABORT_AFTER_SECONDS = 12 * 3600 # 12시간 무진척이면 aborted 로 간주
|
|
@@ -86,7 +87,7 @@ def _sync_project_index(home: Path, project_id: str, updated_rows: list) -> None
|
|
|
86
87
|
if not proj_index.is_file():
|
|
87
88
|
return
|
|
88
89
|
by_id = {r["runId"]: r for r in updated_rows}
|
|
89
|
-
existing =
|
|
90
|
+
existing = read_run_index(proj_index)
|
|
90
91
|
for er in existing:
|
|
91
92
|
src = by_id.get(er.get("runId"))
|
|
92
93
|
if src:
|
|
@@ -94,7 +95,7 @@ def _sync_project_index(home: Path, project_id: str, updated_rows: list) -> None
|
|
|
94
95
|
tmp = proj_index.with_suffix(".jsonl.tmp")
|
|
95
96
|
with tmp.open("w") as f:
|
|
96
97
|
for er in existing:
|
|
97
|
-
f.write(json.dumps(er, separators=(",", ":")) + "\n")
|
|
98
|
+
f.write(json.dumps(slim_run_row(er), separators=(",", ":")) + "\n")
|
|
98
99
|
os.replace(tmp, proj_index)
|
|
99
100
|
|
|
100
101
|
|
|
@@ -118,17 +119,18 @@ def _reconcile_active_locked(home: Path, *,
|
|
|
118
119
|
project: Optional[str] = None) -> dict:
|
|
119
120
|
active = home / "active.jsonl"
|
|
120
121
|
summary = {"completed": 0, "failed": 0, "aborted": 0, "running": 0}
|
|
121
|
-
rows =
|
|
122
|
+
rows = read_run_index(active)
|
|
122
123
|
if not rows:
|
|
123
124
|
return summary
|
|
124
125
|
survivors = []
|
|
125
126
|
promoted = []
|
|
126
127
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
127
128
|
for row in rows:
|
|
128
|
-
# project 필터: 스코프 외 행은
|
|
129
|
+
# project 필터: 스코프 외 행은 추론하지 않고 active 에 그대로 남긴다.
|
|
130
|
+
# 상태를 추론하지 않았으므로 running 으로 집계하지 않는다 — summary 는
|
|
131
|
+
# 이번 패스가 실제로 추론/이동한 행만 반영한다.
|
|
129
132
|
if project and row.get("projectId") != project:
|
|
130
133
|
survivors.append(row)
|
|
131
|
-
summary["running"] += 1
|
|
132
134
|
continue
|
|
133
135
|
project_root = Path(row.get("projectRoot", ""))
|
|
134
136
|
final_rel = row.get("finalReportRel", "")
|
|
@@ -136,7 +138,7 @@ def _reconcile_active_locked(home: Path, *,
|
|
|
136
138
|
if final_abs and final_abs.is_file():
|
|
137
139
|
validation_status = _read_run_manifest_validation(
|
|
138
140
|
project_root, row.get("runDirRel", ""),
|
|
139
|
-
row.get("taskType", ""), int(row.get("runSeq", 0)))
|
|
141
|
+
row.get("taskType", ""), int(row.get("runSeq", 0) or 0))
|
|
140
142
|
# validate-okstra-run.py 가 아직 실행되지 않은 시점에는 final-report
|
|
141
143
|
# 만 존재하고 validation 은 'not-run' 으로 보고된다. 이때 promote 하면
|
|
142
144
|
# normalize_reconciled_report_status 가 'completed' 로 매핑하여 행이
|
|
@@ -168,10 +170,10 @@ def _reconcile_active_locked(home: Path, *,
|
|
|
168
170
|
tmp = active.with_suffix(".jsonl.tmp")
|
|
169
171
|
with tmp.open("w") as f:
|
|
170
172
|
for row in survivors:
|
|
171
|
-
f.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
173
|
+
f.write(json.dumps(slim_run_row(row), separators=(",", ":")) + "\n")
|
|
172
174
|
os.replace(tmp, active)
|
|
173
175
|
for row in promoted:
|
|
174
|
-
append_jsonl(home / "recent.jsonl", row)
|
|
176
|
+
append_jsonl(home / "recent.jsonl", slim_run_row(row))
|
|
175
177
|
_sync_project_index(home, row["projectId"], [row])
|
|
176
178
|
upsert_project_meta(home, row["projectId"],
|
|
177
179
|
project_root=row["projectRoot"],
|
|
@@ -195,7 +197,7 @@ def reconcile_recent(home: Path, *, project: Optional[str] = None) -> dict:
|
|
|
195
197
|
def _reconcile_recent_locked(home: Path, *, project: Optional[str] = None) -> dict:
|
|
196
198
|
recent = home / "recent.jsonl"
|
|
197
199
|
summary = {"completed": 0, "failed": 0, "unchanged": 0}
|
|
198
|
-
rows =
|
|
200
|
+
rows = read_run_index(recent)
|
|
199
201
|
updated_by_project: dict = {}
|
|
200
202
|
for row in rows:
|
|
201
203
|
if (project and row.get("projectId") != project) or \
|
|
@@ -222,7 +224,7 @@ def _reconcile_recent_locked(home: Path, *, project: Optional[str] = None) -> di
|
|
|
222
224
|
tmp = recent.with_suffix(".jsonl.tmp")
|
|
223
225
|
with tmp.open("w") as f:
|
|
224
226
|
for row in rows:
|
|
225
|
-
f.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
227
|
+
f.write(json.dumps(slim_run_row(row), separators=(",", ":")) + "\n")
|
|
226
228
|
os.replace(tmp, recent)
|
|
227
229
|
for pid, urows in updated_by_project.items():
|
|
228
230
|
_sync_project_index(home, pid, urows)
|
|
@@ -720,6 +720,39 @@ def render_latest_task_discovery(output_path: str, ctx: dict) -> None:
|
|
|
720
720
|
# --------------------------------------------------------------------------- #
|
|
721
721
|
|
|
722
722
|
|
|
723
|
+
_PATH_BOUNDARY_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _rewrite_path_refs(text: str, path_rewrites: dict) -> str:
|
|
727
|
+
"""저장된 경로 문자열을 새 경로로 교체하되, 경계를 검사해 더 긴 무관한
|
|
728
|
+
경로 안에 부분 문자열로 끼어든 경우는 건드리지 않는다.
|
|
729
|
+
|
|
730
|
+
`old_rel` 앞뒤가 경로를 잇는 문자(영숫자/`.`/`-`/`_`/`/`)면 그 위치는
|
|
731
|
+
다른 경로의 일부이므로 교체 대상에서 제외한다."""
|
|
732
|
+
for old_rel, new_rel in path_rewrites.items():
|
|
733
|
+
if not old_rel:
|
|
734
|
+
continue
|
|
735
|
+
out: list[str] = []
|
|
736
|
+
idx = 0
|
|
737
|
+
while True:
|
|
738
|
+
hit = text.find(old_rel, idx)
|
|
739
|
+
if hit == -1:
|
|
740
|
+
out.append(text[idx:])
|
|
741
|
+
break
|
|
742
|
+
before = text[hit - 1] if hit > 0 else ""
|
|
743
|
+
after_pos = hit + len(old_rel)
|
|
744
|
+
after = text[after_pos] if after_pos < len(text) else ""
|
|
745
|
+
on_boundary = (
|
|
746
|
+
before not in _PATH_BOUNDARY_CHARS
|
|
747
|
+
and after not in _PATH_BOUNDARY_CHARS
|
|
748
|
+
)
|
|
749
|
+
out.append(text[idx:hit])
|
|
750
|
+
out.append(new_rel if on_boundary else old_rel)
|
|
751
|
+
idx = after_pos
|
|
752
|
+
text = "".join(out)
|
|
753
|
+
return text
|
|
754
|
+
|
|
755
|
+
|
|
723
756
|
def migrate_legacy_run_artifacts(ctx: dict) -> None:
|
|
724
757
|
import shutil
|
|
725
758
|
|
|
@@ -768,9 +801,7 @@ def migrate_legacy_run_artifacts(ctx: dict) -> None:
|
|
|
768
801
|
original = path.read_text(encoding="utf-8")
|
|
769
802
|
except Exception:
|
|
770
803
|
continue
|
|
771
|
-
updated = original
|
|
772
|
-
for old_rel, new_rel in path_rewrites.items():
|
|
773
|
-
updated = updated.replace(old_rel, new_rel)
|
|
804
|
+
updated = _rewrite_path_refs(original, path_rewrites)
|
|
774
805
|
if updated != original:
|
|
775
806
|
path.write_text(updated, encoding="utf-8")
|
|
776
807
|
|
|
@@ -2063,6 +2094,9 @@ def apply_lead_prompt_defaults(ctx: dict) -> None:
|
|
|
2063
2094
|
# Empty for non-final-verification runs; the final-verification prepare
|
|
2064
2095
|
# path overwrites it with the resolved verification target block.
|
|
2065
2096
|
ctx.setdefault("VERIFICATION_TARGET", "")
|
|
2097
|
+
# Empty except for whole-task final-verification, where the prepare path
|
|
2098
|
+
# overwrites it with the stage auto-integration summary.
|
|
2099
|
+
ctx.setdefault("STAGE_INTEGRATION", "")
|
|
2066
2100
|
runtime_home = okstra_home()
|
|
2067
2101
|
ctx.setdefault(
|
|
2068
2102
|
"WORKER_PROMPT_PREAMBLE_PATH",
|
|
@@ -2115,18 +2149,21 @@ def render_template_with_ctx(template_path: str, output_path: str, ctx: dict) ->
|
|
|
2115
2149
|
key = tok_with_braces[2:-2] # "{{X}}" -> "X"
|
|
2116
2150
|
lookup[key] = value
|
|
2117
2151
|
|
|
2118
|
-
rendered = template
|
|
2119
2152
|
missing: list[str] = []
|
|
2120
|
-
|
|
2153
|
+
|
|
2154
|
+
def _resolve(match: "re.Match[str]") -> str:
|
|
2121
2155
|
token = match.group(1)
|
|
2122
2156
|
if token in lookup:
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2157
|
+
return lookup[token]
|
|
2158
|
+
if token in ctx:
|
|
2159
|
+
return str(ctx[token])
|
|
2160
|
+
missing.append(token)
|
|
2161
|
+
return match.group(0)
|
|
2162
|
+
|
|
2163
|
+
# 단일 패스 치환: 토큰을 _TOKEN_RE 매칭으로 한 번만 훑어 치환값으로 바꾼다.
|
|
2164
|
+
# 치환값 안에 `{{TOKEN}}`/`{% if %}` 같은 마커가 들어 있어도 이미 소비된
|
|
2165
|
+
# 구간이라 재치환되지 않는다 (데이터를 통한 토큰/마커 주입 방지).
|
|
2166
|
+
rendered = _TOKEN_RE.sub(_resolve, template)
|
|
2130
2167
|
|
|
2131
2168
|
if missing:
|
|
2132
2169
|
names = ", ".join(sorted(set(missing)))
|
|
@@ -89,6 +89,10 @@ def _format_duration_ms(value: Any) -> str:
|
|
|
89
89
|
ms = int(value)
|
|
90
90
|
except (TypeError, ValueError):
|
|
91
91
|
return "--"
|
|
92
|
+
# A negative elapsed time is nonsensical (clock skew between start/end
|
|
93
|
+
# timestamps); divmod would otherwise produce a malformed "-1m 59s".
|
|
94
|
+
if ms < 0:
|
|
95
|
+
return "--"
|
|
92
96
|
total_seconds = ms // 1000
|
|
93
97
|
hours, remainder = divmod(total_seconds, 3600)
|
|
94
98
|
minutes, seconds = divmod(remainder, 60)
|