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
@@ -0,0 +1,319 @@
1
+ """cross-project errors-*.jsonl 수집·집계·익명화·zip 생성 (read-only).
2
+
3
+ 글로벌 run-index(recent/active)를 순회해 모든 타겟의 에러 로그를 모은다.
4
+ errorType 값에 분기하지 않는 data-driven 처리.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import datetime as dt
10
+ import json
11
+ import re
12
+ import sys
13
+ import zipfile
14
+ from pathlib import Path
15
+
16
+ from okstra_ctl.error_log_core import aggregate, parse_records
17
+ from okstra_ctl.jsonl import read_jsonl
18
+ from okstra_ctl.locks import central_lock
19
+ from okstra_ctl.paths import okstra_home
20
+
21
+
22
+ def _run_dirs(home: Path) -> list[tuple[str, Path]]:
23
+ rows = read_jsonl(home / "recent.jsonl") + read_jsonl(home / "active.jsonl")
24
+ seen: set[tuple[str, str]] = set()
25
+ out: list[tuple[str, Path]] = []
26
+ for row in rows:
27
+ project_root = str(row.get("projectRoot", ""))
28
+ run_dir_rel = str(row.get("runDirRel", ""))
29
+ if not project_root or not run_dir_rel:
30
+ continue
31
+ key = (project_root, run_dir_rel)
32
+ if key in seen:
33
+ continue
34
+ seen.add(key)
35
+ out.append((project_root, Path(project_root) / run_dir_rel))
36
+ return out
37
+
38
+
39
+ def _logs_in_run_dir(run_dir: Path) -> list[Path]:
40
+ flat = run_dir.glob("logs/errors-*.jsonl")
41
+ # stage-*/logs: 정상 index 에선 비활성, 비정형 트리(과거 stage-isolated 잔재) 방어용.
42
+ staged = run_dir.glob("stage-*/logs/errors-*.jsonl")
43
+ return sorted(set(flat) | set(staged))
44
+
45
+
46
+ def collect_records(home: Path) -> tuple[list[dict], dict]:
47
+ records: list[dict] = []
48
+ project_roots: set[str] = set()
49
+ reachable = 0
50
+ unreachable = 0
51
+ for project_root, run_dir in _run_dirs(home):
52
+ if not run_dir.exists():
53
+ unreachable += 1
54
+ continue
55
+ logs = _logs_in_run_dir(run_dir)
56
+ if not logs:
57
+ continue
58
+ reachable += 1
59
+ recs, _ = parse_records(logs)
60
+ for rec in recs:
61
+ rec["_projectRoot"] = project_root
62
+ records.append(rec)
63
+ project_roots.add(project_root)
64
+ stats = {
65
+ "runCount": reachable,
66
+ "unreachableRuns": unreachable,
67
+ "projectRoots": sorted(project_roots),
68
+ }
69
+ return records, stats
70
+
71
+
72
+ KEEP_FIELDS = (
73
+ "ts", "taskKey", "phase", "agent", "agentRole", "source",
74
+ "errorType", "command", "exitCode", "message", "stderrExcerpt",
75
+ )
76
+
77
+
78
+ def _token_map(records: list[dict]) -> dict:
79
+ roots = sorted({str(r.get("_projectRoot", "")) for r in records if r.get("_projectRoot")})
80
+ return {root: f"proj-{i}" for i, root in enumerate(roots, start=1)}
81
+
82
+
83
+ _HOME_USER = re.compile(r"/Users/[^/\s]+")
84
+ _LINUX_USER = re.compile(r"/home/[^/\s]+")
85
+ # zip 은 프로젝트 간 공유되므로 자유 텍스트의 잔여 절대경로·이메일·IP·토큰을 일괄 마스킹한다.
86
+ # token_map 치환을 먼저 돌려 알려진 프로젝트 루트는 proj-N 신호로 보존한 뒤 나머지 경로만 붕괴시킨다.
87
+ _PATHISH = re.compile(r"[/\\][^\s]+")
88
+ _EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
89
+ _IPV4 = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
90
+ # sk-... / 20자 이상 연속 hex·base64 런 → 토큰/시크릿 추정.
91
+ _SECRET = re.compile(r"\b(?:sk-[A-Za-z0-9_-]{8,}|[A-Za-z0-9+/=_-]{20,})\b")
92
+ # 알려진 자격증명 패턴 — 20자 미만이거나 :@ 인접으로 위 일반 규칙의 \b 런이
93
+ # 끊겨 놓치는 짧은 비밀을 명시적으로 잡는다.
94
+ _KNOWN_SECRETS = re.compile(
95
+ r"(?:AKIA|ASIA)[0-9A-Z]{16}" # AWS access key id
96
+ r"|gh[pousr]_[A-Za-z0-9_]{8,}" # GitHub token (PAT/OAuth/server/refresh)
97
+ r"|github_pat_[A-Za-z0-9_]{8,}"
98
+ r"|xox[abprs]-[A-Za-z0-9-]{8,}" # Slack token
99
+ )
100
+ # URL basic-auth(scheme://user:pass@host) 의 비밀번호. _PATHISH 보다 먼저 돌린다.
101
+ _URL_CRED = re.compile(r"([\w.+-]+://[^\s:/@]+:)[^\s@/]+(@)")
102
+ # 자격증명을 가리키는 키워드(부분일치). 긴 플래그는 철자 열거(denylist) 대신
103
+ # 이 키워드를 품은 모든 플래그를 잡아 --api-key/--access-token/--db-password 등
104
+ # 새 도구의 플래그도 자동 포함한다. 공유 zip 이라 과마스킹이 누출보다 안전.
105
+ _CRED_KEYWORD = (
106
+ r"user(?:name)?|pass(?:wd|word)?|pwd|token|secret"
107
+ r"|api[-_]?key|key|auth|cred(?:ential)?|access[-_]?key"
108
+ )
109
+ # 자격증명 플래그 뒤 값. 긴 플래그는 키워드 부분일치, 짧은 플래그는 -u/-p/-a
110
+ # (mysql/redis). 따옴표로 감싼 값은 공백을 포함할 수 있으므로 닫는 따옴표까지
111
+ # 먹는다 — `--password 'my pass'` 의 공백 뒤 잔여(pass')가 새지 않도록. 비-따옴표
112
+ # 값은 다른 플래그(-) 로 시작하면 마스킹하지 않는다 — `-a -m msg` 같은
113
+ # 비-자격증명 조합(예: git commit -a)을 잘못 먹지 않도록.
114
+ _FLAG_CRED = re.compile(
115
+ rf"((?:--[\w-]*(?:{_CRED_KEYWORD})[\w-]*|(?<![\w-])-[upa])[ =])"
116
+ r"(?:'[^']*'|\"[^\"]*\"|(?!-)\S+)",
117
+ re.IGNORECASE,
118
+ )
119
+ # 단문자 플래그 붙여쓰기형(-uroot -ppassw0rd 같은 MySQL/psql 스타일) — 위 규칙은
120
+ # 구분자를 요구해 놓치므로, 토큰 경계의 -u/-p 에 붙은 값도 잡는다.
121
+ _FLAG_CRED_ATTACHED = re.compile(r"(?<![\w-])(-[up])\S+")
122
+ # HTTP Authorization 스킴 토큰(Authorization: Bearer <jwt> / Basic <base64>).
123
+ _AUTH_SCHEME = re.compile(r"\b(Bearer|Basic) +\S+", re.IGNORECASE)
124
+
125
+ # 자유 텍스트(메시지·stderr·command)는 경로 붕괴까지, 구조 필드(taskKey 등)는 token_map 만.
126
+ _FREETEXT_FIELDS = ("message", "stderrExcerpt", "command")
127
+
128
+
129
+ def _scrub(value: object, token_map: dict) -> object:
130
+ if not isinstance(value, str):
131
+ return value
132
+ out = value
133
+ # 긴 루트부터 치환 — 한 루트가 다른 루트의 prefix 일 때(예: /w/app vs
134
+ # /w/app-v2) 짧은 쪽이 먼저 박혀 긴 쪽이 부분치환되는 오귀속을 막는다.
135
+ for root, token in sorted(token_map.items(), key=lambda kv: -len(kv[0])):
136
+ if root:
137
+ out = out.replace(root, token)
138
+ out = _HOME_USER.sub("/Users/<user>", out)
139
+ out = _LINUX_USER.sub("/home/<user>", out)
140
+ return out
141
+
142
+
143
+ def _scrub_freetext(value: object, token_map: dict) -> object:
144
+ if not isinstance(value, str):
145
+ return value
146
+ out = _scrub(value, token_map)
147
+ out = _EMAIL.sub("<email>", out)
148
+ out = _IPV4.sub("<ip>", out)
149
+ out = _KNOWN_SECRETS.sub("<token>", out)
150
+ out = _URL_CRED.sub(r"\1<secret>\2", out)
151
+ out = _AUTH_SCHEME.sub(r"\1 <secret>", out)
152
+ out = _FLAG_CRED.sub(r"\1<secret>", out)
153
+ out = _FLAG_CRED_ATTACHED.sub(r"\1<secret>", out)
154
+ out = _SECRET.sub("<token>", out)
155
+ # 경로 붕괴는 마지막에: 위 마스킹 토큰(<email> 등)은 경로 모양이 아니므로 안전.
156
+ out = _PATHISH.sub("<path>", out)
157
+ return out
158
+
159
+
160
+ def anonymize(records: list[dict]) -> tuple[list[dict], dict]:
161
+ token_map = _token_map(records)
162
+ clean: list[dict] = []
163
+ for rec in records:
164
+ root = str(rec.get("_projectRoot", ""))
165
+ token = token_map.get(root, "proj-?")
166
+ item = {}
167
+ for k in KEEP_FIELDS:
168
+ if k not in rec:
169
+ continue
170
+ scrub = _scrub_freetext if k in _FREETEXT_FIELDS else _scrub
171
+ item[k] = scrub(rec.get(k), token_map)
172
+ task_key = str(rec.get("taskKey", ""))
173
+ if ":" in task_key:
174
+ item["taskKey"] = token + task_key[task_key.index(":"):]
175
+ item["sourceProject"] = token
176
+ clean.append(item)
177
+ return clean, token_map
178
+
179
+
180
+ _NUM = re.compile(r"\d+")
181
+
182
+
183
+ def cluster_key(rec: dict) -> str:
184
+ msg = str(rec.get("message", ""))
185
+ msg = _PATHISH.sub("<path>", msg)
186
+ msg = _NUM.sub("<n>", msg)
187
+ msg = msg.strip()[:80]
188
+ return "|".join([
189
+ str(rec.get("errorType", "")),
190
+ str(rec.get("phase", "")),
191
+ str(rec.get("agent", "")),
192
+ msg,
193
+ ])
194
+
195
+
196
+ def build_clusters(records: list[dict]) -> tuple[list[dict], list[str]]:
197
+ """클러스터 목록과, records 와 같은 순서의 레코드별 cluster_key 를 함께 반환.
198
+ 호출자가 직렬화 시 키를 재계산(정규식 중복)하지 않도록 키를 노출한다."""
199
+ buckets: dict[str, dict] = {}
200
+ keys: list[str] = []
201
+ for rec in records:
202
+ key = cluster_key(rec)
203
+ keys.append(key)
204
+ b = buckets.setdefault(key, {
205
+ "key": key, "errorType": str(rec.get("errorType", "")),
206
+ "phase": str(rec.get("phase", "")), "agent": str(rec.get("agent", "")),
207
+ "count": 0, "projects": set(), "sample": str(rec.get("message", "")),
208
+ })
209
+ b["count"] += 1
210
+ b["projects"].add(str(rec.get("sourceProject", "")))
211
+ clusters = []
212
+ for b in buckets.values():
213
+ b["projects"] = sorted(p for p in b["projects"] if p)
214
+ clusters.append(b)
215
+ clusters.sort(key=lambda c: (-c["count"], c["key"]))
216
+ return clusters, keys
217
+
218
+
219
+ def cluster_id_map(clusters: list[dict]) -> dict:
220
+ """cluster_key → report 표의 # (1-based, build_clusters 정렬 순서)."""
221
+ return {c["key"]: i for i, c in enumerate(clusters, start=1)}
222
+
223
+
224
+ def render_report(*, agg, clusters, stats, generated_at) -> str:
225
+ lines = [
226
+ "# okstra cross-project 에러 환류 리포트",
227
+ "",
228
+ f"- 생성 시각: {generated_at}",
229
+ f"- 총 에러: {agg['errorCount']} (에러 로그 보유 run {stats['runCount']}개)",
230
+ f"- 도달 불가 run: {stats['unreachableRuns']}",
231
+ f"- errorType 분포: {agg['byErrorType']}",
232
+ "",
233
+ "## 빈발 클러스터 (brief 분할 단위)",
234
+ "",
235
+ "| # | errorType | phase | agent | count | projects | 대표 메시지 |",
236
+ "|---|---|---|---|---:|---|---|",
237
+ ]
238
+ for i, c in enumerate(clusters, start=1):
239
+ sample = c["sample"].replace("|", "\\|").replace("\n", " ")[:60]
240
+ lines.append(
241
+ f"| {i} | {c['errorType']} | {c['phase']} | {c['agent']} "
242
+ f"| {c['count']} | {len(c['projects'])} | {sample} |"
243
+ )
244
+ return "\n".join(lines) + "\n"
245
+
246
+
247
+ _CONFIG_NAME = "error-zip.json"
248
+
249
+
250
+ def remember_output_path(home: Path, out_path: Path) -> None:
251
+ cfg = home / _CONFIG_NAME
252
+ cfg.write_text(
253
+ json.dumps({"lastOutputPath": str(out_path)}, ensure_ascii=False, indent=2),
254
+ encoding="utf-8",
255
+ )
256
+
257
+
258
+ def last_output_path(home: Path) -> str:
259
+ cfg = home / _CONFIG_NAME
260
+ if not cfg.is_file():
261
+ return ""
262
+ try:
263
+ return str(json.loads(cfg.read_text(encoding="utf-8")).get("lastOutputPath", ""))
264
+ except Exception:
265
+ return ""
266
+
267
+
268
+ def _write_zip(out_path: Path, report: str, serialized: list[dict]) -> None:
269
+ out_path.parent.mkdir(parents=True, exist_ok=True)
270
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
271
+ zf.writestr("report.md", report)
272
+ payload = "\n".join(json.dumps(r, ensure_ascii=False) for r in serialized)
273
+ zf.writestr("errors/anonymized.jsonl", payload + ("\n" if payload else ""))
274
+
275
+
276
+ def build_zip(home: Path, out_path: Path, now: dt.datetime) -> dict:
277
+ records, stats = collect_records(home)
278
+ # 집계는 _sourceLog 가 살아있는 raw 레코드에서 한다 — 익명화 레코드는
279
+ # _sourceLog 가 KEEP_FIELDS 밖이라 runCount 가 무너진다.
280
+ agg = aggregate(records)
281
+ clean, _ = anonymize(records)
282
+ clusters, keys = build_clusters(clean)
283
+ report = render_report(
284
+ agg=agg, clusters=clusters, stats=stats,
285
+ generated_at=now.isoformat(),
286
+ )
287
+ id_map = cluster_id_map(clusters)
288
+ serialized = [{**r, "clusterId": id_map.get(k)} for r, k in zip(clean, keys)]
289
+ # 출력 zip 과 공유 error-zip.json 쓰기를 중앙 락으로 직렬화한다 — 동시 error-zip
290
+ # 두 개가 같은 경로에 쓰면 한쪽이 다른 쪽의 부분 기록 zip 을 절단하고
291
+ # lastOutputPath 가 last-writer-wins 로 덮인다.
292
+ with central_lock(home):
293
+ _write_zip(out_path, report, serialized)
294
+ remember_output_path(home, out_path)
295
+ return {
296
+ "outPath": str(out_path),
297
+ "errorCount": len(clean),
298
+ "runCount": stats["runCount"],
299
+ "unreachableRuns": stats["unreachableRuns"],
300
+ "clusterCount": len(clusters),
301
+ "projectCount": len(stats["projectRoots"]),
302
+ }
303
+
304
+
305
+ def main(argv: list[str] | None = None) -> int:
306
+ parser = argparse.ArgumentParser(
307
+ prog="okstra error-zip",
308
+ description="cross-project okstra 에러를 수집·익명화해 zip 으로 묶는다.",
309
+ )
310
+ parser.add_argument("--out", required=True, help="출력 .zip 절대/상대 경로")
311
+ args = parser.parse_args(argv)
312
+ home = okstra_home()
313
+ result = build_zip(home, Path(args.out), dt.datetime.now(dt.timezone.utc))
314
+ print(json.dumps(result, ensure_ascii=False, indent=2))
315
+ return 0
316
+
317
+
318
+ if __name__ == "__main__":
319
+ raise SystemExit(main(sys.argv[1:]))
@@ -12,8 +12,11 @@ def topological_order(units: dict[str, list[str]]) -> list[str]:
12
12
  units: unit-id -> 그 unit 이 의존하는 unit-id 목록.
13
13
  순환이면 CycleError, 알 수 없는 의존이면 ValueError.
14
14
  """
15
+ # 같은 의존을 두 번 적으면(`depends-on: a, a`) indegree 와 감소 횟수가 어긋나
16
+ # 순환이 없는데도 CycleError 가 난다. unit 별 의존을 dedupe 해 일치시킨다.
17
+ dep_sets = {u: set(deps) for u, deps in units.items()}
15
18
  indeg = {u: 0 for u in units}
16
- for u, deps in units.items():
19
+ for u, deps in dep_sets.items():
17
20
  for d in deps:
18
21
  if d not in units:
19
22
  raise ValueError(f"unit {u!r} depends on unknown unit {d!r}")
@@ -23,7 +26,7 @@ def topological_order(units: dict[str, list[str]]) -> list[str]:
23
26
  while queue:
24
27
  u = queue.pop(0)
25
28
  order.append(u)
26
- for v, deps in units.items():
29
+ for v, deps in dep_sets.items():
27
30
  if u in deps:
28
31
  indeg[v] -= 1
29
32
  if indeg[v] == 0:
@@ -35,14 +35,26 @@ def fix_cycles_path(task_root: Path) -> Path:
35
35
 
36
36
 
37
37
  def read_rows(task_root: Path) -> List[Dict[str, Any]]:
38
+ """fix-cycles.jsonl 의 유효 행만 반환.
39
+
40
+ append-only SSOT 의 손상 내성 경계: 중단된 writer 가 남긴 깨진 줄과
41
+ `cycle` 키가 없는(스키마 드리프트) 행은 여기서 걸러, downstream
42
+ open_cycle/summarize/packet_summary 의 r["cycle"] 접근이 항상 안전하다.
43
+ """
38
44
  p = fix_cycles_path(task_root)
39
45
  if not p.exists():
40
46
  return []
41
47
  out: List[Dict[str, Any]] = []
42
48
  for line in p.read_text(encoding="utf-8").splitlines():
43
49
  line = line.strip()
44
- if line:
45
- out.append(json.loads(line))
50
+ if not line:
51
+ continue
52
+ try:
53
+ row = json.loads(line)
54
+ except json.JSONDecodeError:
55
+ continue
56
+ if isinstance(row, dict) and "cycle" in row:
57
+ out.append(row)
46
58
  return out
47
59
 
48
60
 
@@ -72,7 +72,13 @@ def content_merged(project_root: Path, commit: str, candidate: str,
72
72
  if not mb:
73
73
  return MatchResult("not-merged")
74
74
  range_base = _resolve_commit_sha(project_root, base) or mb
75
- cand_ids: Dict[str, str] = dict(_patch_ids_of_range(project_root, mb, cand))
75
+ # git log 최신 커밋부터 출력 → patch-id 가 후보에 중복(revert·재적용,
76
+ # cross-branch cherry-pick)되면 가장 최신(tip 쪽) SHA 를 재기록 대상으로
77
+ # 골라야 한다. dict() 는 나중 항목이 이기므로 오래된 SHA 가 남는다 → 역순 적재.
78
+ cand_ids: Dict[str, str] = {
79
+ pid: sha
80
+ for pid, sha in reversed(_patch_ids_of_range(project_root, mb, cand))
81
+ }
76
82
  stage_ids = _patch_ids_of_range(project_root, range_base, resolved)
77
83
  if stage_ids and all(pid in cand_ids for pid, _ in stage_ids):
78
84
  # git log 는 최신 커밋부터 출력 → stage_ids[0] 이 tip. matched_commit
@@ -13,7 +13,10 @@ from pathlib import Path
13
13
  from typing import Any, Callable, Dict, List, Optional, Tuple
14
14
 
15
15
  from . import consumers, worktree_registry
16
- from .worktree import compute_branch_name, compute_worktree_path
16
+ from .worktree import (compute_branch_name, compute_worktree_path,
17
+ main_worktree_path, is_dirty_excluding_okstra,
18
+ nested_worktree_excludes, is_ancestor, merge_branch,
19
+ remove_worktree_force, MergeError, _git)
17
20
 
18
21
 
19
22
  class HandoffError(Exception):
@@ -23,11 +26,13 @@ class HandoffError(Exception):
23
26
  class HandoffConflict(Exception):
24
27
  """stage 간 merge 충돌 — exit 2, 충돌 경로 동봉."""
25
28
 
26
- def __init__(self, stage: int, paths: List[str]):
29
+ def __init__(self, stage: int, branch: str, paths: List[str]):
27
30
  self.stage = stage
31
+ self.branch = branch
28
32
  self.paths = paths
29
33
  super().__init__(
30
- f"merge conflict while merging stage {stage}: {', '.join(paths)}")
34
+ f"merge conflict while merging stage {stage} ({branch}): "
35
+ f"{', '.join(paths)}")
31
36
 
32
37
 
33
38
  def group_id_for(stages: List[int]) -> str:
@@ -113,8 +118,8 @@ def check_dependency_closure(
113
118
 
114
119
 
115
120
  def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedProcess:
116
- r = subprocess.run(["git", *args], cwd=str(cwd),
117
- capture_output=True, text=True)
121
+ """worktree._git(공용 러너) 위에 check=True 시 HandoffError 변환만 얹는다."""
122
+ r = _git(Path(cwd), *args)
118
123
  if check and r.returncode != 0:
119
124
  raise HandoffError(f"git {' '.join(args)} failed: {r.stderr.strip()}")
120
125
  return r
@@ -133,9 +138,7 @@ def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
133
138
 
134
139
  def _require_closure(stages, stage_map, done, project_root, base_branch) -> None:
135
140
  def merged(commit: str) -> bool:
136
- return _run_git(["merge-base", "--is-ancestor", commit,
137
- f"origin/{base_branch}"], project_root,
138
- check=False).returncode == 0
141
+ return is_ancestor(project_root, commit, f"origin/{base_branch}")
139
142
  violations = check_dependency_closure(stages, stage_map, done, merged)
140
143
  if violations:
141
144
  lines = [
@@ -151,8 +154,7 @@ def _reuse_existing(entry, stages, done, project_root) -> Dict[str, Any]:
151
154
  head = _run_git(["rev-parse", entry.branch], project_root).stdout.strip()
152
155
  for n in stages:
153
156
  h = (done.get(n) or {}).get("head_commit", "")
154
- if _run_git(["merge-base", "--is-ancestor", h, head], project_root,
155
- check=False).returncode != 0:
157
+ if not is_ancestor(project_root, h, head):
156
158
  raise HandoffError(
157
159
  f"collector branch {entry.branch} exists but stage {n} head "
158
160
  f"{h} is not merged into it — remove the branch/worktree and "
@@ -164,27 +166,37 @@ def _reuse_existing(entry, stages, done, project_root) -> Dict[str, Any]:
164
166
 
165
167
  def _cleanup_group(project_root, wt_path, branch, project_id, task_group,
166
168
  task_id, gid) -> None:
167
- _run_git(["worktree", "remove", "--force", str(wt_path)], project_root,
168
- check=False)
169
+ remove_worktree_force(project_root, wt_path)
169
170
  _run_git(["branch", "-D", branch], project_root, check=False)
170
171
  worktree_registry.release(project_id, task_group, task_id, group_id=gid)
171
172
 
172
173
 
173
174
  def _merge_stages(wt_path, stages, work_category, task_id, project_root,
174
- project_id, task_group, gid, branch) -> List[str]:
175
+ project_id, task_group, gid, branch, done) -> List[str]:
176
+ """선택 stage 들을 수집 브랜치에 위상순 --no-ff 머지한다.
177
+
178
+ 머지 대상은 `-sN` 브랜치 tip 이 아니라 기록된 done.head_commit 이다 —
179
+ consumers 의 done.head_commit 은 git-reconcile 로 브랜치 tip 과 다른 commit
180
+ 일 수 있어, 브랜치 tip 을 머지하면 done 시점 이후의 갈라진 변경을 끌어들인다.
181
+ stage_branch 는 충돌 식별용으로만 산출한다(머지 대상 아님)."""
175
182
  merge_commits = []
176
183
  for n in sorted(stages):
177
184
  stage_branch = compute_branch_name(
178
185
  work_category=work_category, task_id_segment=task_id,
179
186
  stage_number=n)
180
- r = _run_git(["merge", "--no-edit", stage_branch], wt_path, check=False)
181
- if r.returncode != 0:
182
- paths = _run_git(["diff", "--name-only", "--diff-filter=U"],
183
- wt_path).stdout.split()
184
- _run_git(["merge", "--abort"], wt_path, check=False)
187
+ done_commit = (done.get(n) or {}).get("head_commit", "")
188
+ try:
189
+ conflicts = merge_branch(wt_path, done_commit, no_ff=False)
190
+ except MergeError as exc:
191
+ # cleanup assemble 의 except Exception 이 수행한다(worktree-add
192
+ # 실패 경로와 동일 처리) — 여기서 직접 정리하지 않는다.
193
+ raise HandoffError(
194
+ f"stage {n} ({stage_branch}) 머지 실패(내용 충돌 아님): {exc}"
195
+ ) from exc
196
+ if conflicts is not None:
185
197
  _cleanup_group(project_root, wt_path, branch, project_id,
186
198
  task_group, task_id, gid)
187
- raise HandoffConflict(stage=n, paths=paths)
199
+ raise HandoffConflict(stage=n, branch=stage_branch, paths=conflicts)
188
200
  merge_commits.append(
189
201
  _run_git(["rev-parse", "HEAD"], wt_path).stdout.strip())
190
202
  return merge_commits
@@ -218,16 +230,23 @@ def assemble(*, project_root, plan_run_root, stage_map, stages, base_branch,
218
230
  wt_path = compute_worktree_path(
219
231
  project_id=project_id, task_group_segment=task_group,
220
232
  task_id_segment=task_id, group_id=gid)
221
- worktree_registry.reserve(
222
- project_id=project_id, task_group=task_group, task_id=task_id,
223
- worktree_path=str(wt_path), branch=branch, base_ref=base_commit,
224
- phase="release-handoff", group_id=gid, stages=stages)
233
+ # reserve 충돌(이미 등록된 task-key/branch)은 RuntimeError 로 오는데, 이를
234
+ # HandoffError 로 감싸 main 의 envelope(JSON+exit1)로 흘려보낸다. 감싸지
235
+ # 않으면 raw traceback 으로 빠진다. cleanup 을 거는 worktree-add try 와
236
+ # 분리해 두어, 예약 생성 실패 시 자신이 만들지도 않은 예약을 되돌리지 않는다.
237
+ try:
238
+ worktree_registry.reserve(
239
+ project_id=project_id, task_group=task_group, task_id=task_id,
240
+ worktree_path=str(wt_path), branch=branch, base_ref=base_commit,
241
+ phase="release-handoff", group_id=gid, stages=stages)
242
+ except RuntimeError as exc:
243
+ raise HandoffError(str(exc))
225
244
  try:
226
245
  _run_git(["worktree", "add", "-b", branch, str(wt_path), base_commit],
227
246
  project_root)
228
247
  merge_commits = _merge_stages(wt_path, stages, work_category, task_id,
229
248
  project_root, project_id, task_group, gid,
230
- branch)
249
+ branch, done)
231
250
  except HandoffConflict:
232
251
  raise # _merge_stages 가 이미 _cleanup_group 으로 예약·worktree 를 되돌림
233
252
  except Exception:
@@ -241,6 +260,59 @@ def assemble(*, project_root, plan_run_root, stage_map, stages, base_branch,
241
260
  "merge_commits": merge_commits}
242
261
 
243
262
 
263
+ def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
264
+ """clean 으로 확인된 task-key 워크트리를 제거. 이미 없으면 no-op.
265
+ 실패하면 사람이 읽을 detail 문자열, 성공/부재면 None.
266
+ nested stage 워크트리가 남아있으면 제거하지 않는다 — --force 로 지우면 물리
267
+ 디렉터리만 사라지고 git/registry 의 stage-key admin 엔트리가 고아로 남는다.
268
+ 통합/teardown 이 끝나 nested 가 없을 때만 제거한다."""
269
+ if not Path(target).exists():
270
+ return None # 이미 teardown 됨 — checkout 으로 진행
271
+ nested = nested_worktree_excludes(target)
272
+ if nested:
273
+ return f"nested 워크트리가 남아있습니다: {', '.join(nested)}"
274
+ rm = remove_worktree_force(main_wt, target)
275
+ if rm.returncode != 0:
276
+ return rm.stderr.strip()
277
+ return None
278
+
279
+
280
+ def local_checkout(*, project_root, project_id, task_group, task_id) -> Dict[str, Any]:
281
+ """whole-task: okstra task-key 워크트리를 제거하고 task 브랜치를 메인
282
+ 워크트리에 checkout 한다(브랜치 보존). 메인·대상 워크트리가 dirty 면 거부.
283
+ base 브랜치는 건드리지 않는다."""
284
+ entry = worktree_registry.lookup(project_id, task_group, task_id)
285
+ if not entry or not entry.branch or not entry.worktree_path:
286
+ raise HandoffError(
287
+ "task-key worktree registry 엔트리가 없습니다 — local checkout 대상 없음")
288
+ main_wt = main_worktree_path(Path(project_root))
289
+ if is_dirty_excluding_okstra(main_wt):
290
+ raise HandoffError(
291
+ f"메인 워크트리에 미커밋 변경이 있습니다 ({main_wt}) — "
292
+ "커밋/스태시 후 다시 시도하세요")
293
+ # 제거 게이트는 파괴 대상인 task-key 워크트리를 검사해야 한다 (메인 아님).
294
+ if (Path(entry.worktree_path).exists()
295
+ and is_dirty_excluding_okstra(entry.worktree_path)):
296
+ raise HandoffError(
297
+ f"task 워크트리에 미커밋 변경이 있습니다 ({entry.worktree_path}) — "
298
+ "해당 워크트리에서 커밋/스태시 후 다시 시도하세요")
299
+ detail = _remove_task_worktree(main_wt, entry.worktree_path)
300
+ if detail is not None:
301
+ raise HandoffError(
302
+ f"task 워크트리 제거 실패 ({entry.worktree_path}): {detail} — "
303
+ "nested stage 워크트리가 남아있거나 통합이 미완료일 수 있습니다. "
304
+ "먼저 stage 통합/teardown 하거나 수동으로 "
305
+ f"`git worktree remove {entry.worktree_path}` 후 다시 시도하세요")
306
+ worktree_registry.release_status(project_id, task_group, task_id)
307
+ co = _git(Path(main_wt), "checkout", entry.branch)
308
+ if co.returncode != 0:
309
+ raise HandoffError(
310
+ f"git checkout {entry.branch} 실패: {co.stderr.strip()} — 워크트리는 "
311
+ f"제거됨; 메인({main_wt})에서 수동 checkout 으로 복구하세요")
312
+ return {"ok": True, "branch": entry.branch, "mainWorktreePath": str(main_wt),
313
+ "removedWorktree": entry.worktree_path, "status": "checked-out"}
314
+
315
+
244
316
  def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
245
317
  done = consumers.latest_done_by_stage(rows)
246
318
  row = done.get(stage)
@@ -251,6 +323,20 @@ def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
251
323
  return row.get("impl_task_key", "")
252
324
 
253
325
 
326
+ def _impl_task_key_for_any(rows: List[Dict[str, Any]], stages: List[int]) -> str:
327
+ """task 를 식별하는 키 — stages 중 done 행이 있는 아무 stage 에서나 승계한다.
328
+ PR 기록은 특정 stage 의 done 에 묶일 이유가 없으므로 최저 stage 가 비어도
329
+ 다른 stage 의 done 으로 충족한다."""
330
+ done = consumers.latest_done_by_stage(rows)
331
+ for n in sorted(stages):
332
+ row = done.get(n)
333
+ if row:
334
+ return row.get("impl_task_key", "")
335
+ raise HandoffError(
336
+ f"none of stages {sorted(stages)} have a done row in consumers.jsonl — "
337
+ "finish at least one implementation stage first")
338
+
339
+
254
340
  def record_verified(*, plan_run_root, stage: int, report_path: str,
255
341
  data_json) -> Dict[str, Any]:
256
342
  """단독-stage accepted 만 기록. data.json 의 taskType/scope/verdict 를 검증해
@@ -283,7 +369,7 @@ def record_pr(*, plan_run_root, stages: List[int], branch: str,
283
369
  if not stages:
284
370
  raise HandoffError("record-pr requires at least one stage")
285
371
  rows = consumers.read_consumers(Path(plan_run_root))
286
- key = _impl_task_key_for(rows, sorted(stages)[0])
372
+ key = _impl_task_key_for_any(rows, stages)
287
373
  consumers.append_pr(Path(plan_run_root), impl_task_key=key,
288
374
  stages=stages, branch=branch, url=url)
289
375
  return {"ok": True, "stages": sorted(stages), "branch": branch, "url": url}
@@ -338,6 +424,13 @@ def main(argv: Optional[list] = None) -> int:
338
424
  sp.add_argument("--branch", required=True)
339
425
  sp.add_argument("--url", required=True)
340
426
 
427
+ sp = sub.add_parser("local-checkout")
428
+ common(sp, plan=False)
429
+ sp.add_argument("--project-root", default=".")
430
+ sp.add_argument("--project-id", required=True)
431
+ sp.add_argument("--task-group", required=True)
432
+ sp.add_argument("--task-id", required=True)
433
+
341
434
  a = p.parse_args(argv)
342
435
  try:
343
436
  if a.cmd == "eligible":
@@ -356,13 +449,19 @@ def main(argv: Optional[list] = None) -> int:
356
449
  out = record_verified(plan_run_root=Path(a.plan_run_root),
357
450
  stage=a.stage, report_path=a.report_path,
358
451
  data_json=a.data_json)
452
+ elif a.cmd == "local-checkout":
453
+ out = local_checkout(
454
+ project_root=Path(a.project_root).resolve(),
455
+ project_id=a.project_id, task_group=a.task_group,
456
+ task_id=a.task_id)
359
457
  else:
360
458
  out = record_pr(plan_run_root=Path(a.plan_run_root),
361
459
  stages=_parse_stages_csv(a.stages),
362
460
  branch=a.branch, url=a.url)
363
461
  except HandoffConflict as exc:
364
462
  print(json.dumps({"error": str(exc), "stage": exc.stage,
365
- "conflicts": exc.paths}, ensure_ascii=False))
463
+ "branch": exc.branch, "conflicts": exc.paths},
464
+ ensure_ascii=False))
366
465
  return 2
367
466
  except HandoffError as exc:
368
467
  print(json.dumps({"error": str(exc)}, ensure_ascii=False))
@@ -109,7 +109,14 @@ def select_and_provision_implementation_stage(
109
109
  concurrent_stages=concurrent_stages,
110
110
  )
111
111
 
112
- head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
112
+ # The anchor and multi-dep candidate base must come from the task-key
113
+ # worktree HEAD (where stage work accumulates), not from inp.project_root
114
+ # (the user's invocation cwd, which may sit on an older/unrelated commit).
115
+ # The task-key worktree is the SSOT in the registry; whole-task
116
+ # final-verification (run.py) resolves its base from the same path.
117
+ task_entry = _reg.lookup(inp.project_id, inp.task_group, inp.task_id)
118
+ task_worktree_path = (task_entry.worktree_path if task_entry else "") or inp.project_root
119
+ head_sha = _git_out(task_worktree_path, "rev-parse", "HEAD")
113
120
  if head_sha:
114
121
  _reg.set_implementation_base(
115
122
  inp.project_id,
@@ -133,7 +140,7 @@ def select_and_provision_implementation_stage(
133
140
  consumer_done_rows,
134
141
  anchor_base_commit=anchor,
135
142
  candidate_base=head_sha,
136
- project_root=Path(inp.project_root),
143
+ project_root=Path(task_worktree_path),
137
144
  plan_run_root=plan_run_root,
138
145
  )
139
146
  except stage_targets.StageTargetError as exc: