okstra 0.96.1 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +22 -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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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=
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 =
|
|
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},
|
|
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
|
-
|
|
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(
|
|
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:
|