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.
Files changed (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +30 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /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 invocation_path, save_invocation
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 _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
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] = 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)
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
- "runId": run_id,
76
- "projectId": project_id,
77
- "projectRoot": project_root,
78
- "taskGroup": task_group,
79
- "taskId": task_id,
80
- "taskKey": f"{task_group}/{task_id}",
81
- "taskType": task_type,
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
- _replace_or_append_active_row(home, run_id, row)
81
+ _replace_or_append_row(home / "active.jsonl", run_id, row)
122
82
  had_recent = False
123
- _replace_or_append_project_row(home, project_id, run_id, row)
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
- run_id = build_run_id(project_id, task_group, task_id, task_type, run_seq)
160
- inv_rel = invocation_path(home, project_id, task_group, task_id,
161
- task_type, run_seq).relative_to(home)
162
- row = {
163
- "runId": run_id,
164
- "projectId": project_id,
165
- "projectRoot": project_root,
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
- 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
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
- rows = list(recent.open())
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
- # 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:
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 archive
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
- meta = json.loads(meta_file.read_text())
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 = read_jsonl(home / "active.jsonl") + read_jsonl(home / "recent.jsonl")
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(read_jsonl(f))
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 = _parse_iso(r.get("startedAt", ""))
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*:?-{2,}:?\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
- if LEGACY_OKSTRA_DIR_NAME in text or f"{LEGACY_OKSTRA_DIR_NAME}/" in text:
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
- stripped = line.strip()
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
- latest_states = snap.get("phaseStates", {}) or latest_states
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
- with log_path.open("a", encoding="utf-8") as fh:
81
- fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
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, read_jsonl, rotate_recent_if_needed
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 = read_jsonl(proj_index)
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 = read_jsonl(active)
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 필터: 스코프 외 행은 추론 없이 active 에 그대로 남긴다.
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 = read_jsonl(recent)
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
- for match in _TOKEN_RE.finditer(template):
2153
+
2154
+ def _resolve(match: "re.Match[str]") -> str:
2121
2155
  token = match.group(1)
2122
2156
  if token in lookup:
2123
- value = lookup[token]
2124
- elif token in ctx:
2125
- value = str(ctx[token])
2126
- else:
2127
- missing.append(token)
2128
- continue
2129
- rendered = rendered.replace("{{" + token + "}}", value)
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)