@ikon85/agent-workflow-kit 0.36.5 → 0.38.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/.agents/skills/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
package/scripts/wrapup-land.py
CHANGED
|
@@ -26,7 +26,9 @@ import os
|
|
|
26
26
|
import re
|
|
27
27
|
import subprocess
|
|
28
28
|
import sys
|
|
29
|
+
import time
|
|
29
30
|
from pathlib import Path
|
|
31
|
+
from urllib.parse import quote
|
|
30
32
|
|
|
31
33
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
34
|
|
|
@@ -41,7 +43,32 @@ ISSUE_BRANCH_RE = re.compile(r"^(feat|fix|chore|docs)/(\d+)-")
|
|
|
41
43
|
DRIFT_LINE_RE = re.compile(r"^-\s*#(\d+)(?:\s*§([^:]+?))?\s*:\s*(.+)$")
|
|
42
44
|
RETRO_LINE_RE = re.compile(r"^\*\*Retro:\*\*", re.MULTILINE)
|
|
43
45
|
DRIFT_MARKER_RE = re.compile(r"<!--\s*annahme-drift:\s*(\{.*?\})\s*-->")
|
|
44
|
-
RED_CHECK_CONCLUSIONS = {
|
|
46
|
+
RED_CHECK_CONCLUSIONS = {
|
|
47
|
+
"ACTION_REQUIRED",
|
|
48
|
+
"CANCELLED",
|
|
49
|
+
"FAILURE",
|
|
50
|
+
"STALE",
|
|
51
|
+
"STARTUP_FAILURE",
|
|
52
|
+
"TIMED_OUT",
|
|
53
|
+
}
|
|
54
|
+
GREEN_CHECK_CONCLUSIONS = {"NEUTRAL", "SKIPPED", "SUCCESS"}
|
|
55
|
+
CHECK_WAIT_SECONDS = 20 * 60
|
|
56
|
+
CHECK_POLL_SECONDS = 10
|
|
57
|
+
MAX_EXTERNAL_DETAIL = 500
|
|
58
|
+
INFRA_FAILURE_RE = re.compile(
|
|
59
|
+
r"(?:"
|
|
60
|
+
r"account payments? (?:have )?failed|"
|
|
61
|
+
r"billing issue|"
|
|
62
|
+
r"spending limit|"
|
|
63
|
+
r"no hosted parallelism|"
|
|
64
|
+
r"no (?:hosted )?runners? (?:are )?available|"
|
|
65
|
+
r"no runner matching|"
|
|
66
|
+
r"runner (?:is )?unavailable|"
|
|
67
|
+
r"failed to (?:acquire|assign) (?:a )?job|"
|
|
68
|
+
r"job was not started"
|
|
69
|
+
r")",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
45
72
|
|
|
46
73
|
|
|
47
74
|
class Stop(Exception):
|
|
@@ -62,6 +89,311 @@ def git(args: list[str], cwd: str | None = None, check: bool = False) -> subproc
|
|
|
62
89
|
return run(["git", *args], cwd=cwd, check=check)
|
|
63
90
|
|
|
64
91
|
|
|
92
|
+
# ---------- PR check gate ----------
|
|
93
|
+
|
|
94
|
+
def check_name(check: dict) -> str:
|
|
95
|
+
return str(check.get("name") or check.get("context") or "?")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_conclusion(check: dict) -> str:
|
|
99
|
+
# CheckRun entries carry `conclusion: null` while pending. Do not let a
|
|
100
|
+
# simultaneously present legacy `state` field turn that explicit null green.
|
|
101
|
+
value = check.get("conclusion") if "conclusion" in check else check.get("state")
|
|
102
|
+
return str(value or "").upper()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pending_checks(checks: list[dict]) -> list[dict]:
|
|
106
|
+
pending = []
|
|
107
|
+
for check in checks:
|
|
108
|
+
conclusion = check_conclusion(check)
|
|
109
|
+
status = str(check.get("status") or "").upper()
|
|
110
|
+
if conclusion in RED_CHECK_CONCLUSIONS | GREEN_CHECK_CONCLUSIONS:
|
|
111
|
+
continue
|
|
112
|
+
if conclusion in {"EXPECTED", "IN_PROGRESS", "PENDING", "QUEUED", "REQUESTED", "WAITING"}:
|
|
113
|
+
pending.append(check)
|
|
114
|
+
continue
|
|
115
|
+
if not conclusion or status not in {"", "COMPLETED"}:
|
|
116
|
+
pending.append(check)
|
|
117
|
+
return pending
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def red_checks(checks: list[dict]) -> list[dict]:
|
|
121
|
+
return [
|
|
122
|
+
check for check in checks
|
|
123
|
+
if check_conclusion(check) in RED_CHECK_CONCLUSIONS | {"ERROR"}
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sanitize_external_detail(text: str) -> str:
|
|
128
|
+
compact = re.sub(r"[\x00-\x1f\x7f]+", " ", text)
|
|
129
|
+
return re.sub(r"\s+", " ", compact).strip()[:MAX_EXTERNAL_DETAIL]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_text(check: dict) -> str:
|
|
133
|
+
values = []
|
|
134
|
+
for key in ("name", "context", "description", "title", "summary", "text"):
|
|
135
|
+
value = check.get(key)
|
|
136
|
+
if isinstance(value, str):
|
|
137
|
+
values.append(value)
|
|
138
|
+
return " ".join(values)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _matching_actions_job(check: dict, jobs: list[dict]) -> dict | None:
|
|
142
|
+
"""Return the one Actions job represented by this check, never a run sibling."""
|
|
143
|
+
candidates = [job for job in jobs if isinstance(job, dict)]
|
|
144
|
+
details_url = check.get("link") or check.get("detailsUrl") or check.get("targetUrl")
|
|
145
|
+
job_match = (
|
|
146
|
+
re.search(r"/job/(\d+)(?:/|$)", details_url)
|
|
147
|
+
if isinstance(details_url, str)
|
|
148
|
+
else None
|
|
149
|
+
)
|
|
150
|
+
if job_match is not None:
|
|
151
|
+
job_id = job_match.group(1)
|
|
152
|
+
matching_ids = [
|
|
153
|
+
job for job in candidates
|
|
154
|
+
if str(job.get("databaseId") or "") == job_id
|
|
155
|
+
or re.search(
|
|
156
|
+
rf"/job/{re.escape(job_id)}(?:/|$)",
|
|
157
|
+
str(job.get("url") or ""),
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
return matching_ids[0] if len(matching_ids) == 1 else None
|
|
161
|
+
|
|
162
|
+
name = check_name(check)
|
|
163
|
+
matching_names = [
|
|
164
|
+
job for job in candidates if str(job.get("name") or "") == name
|
|
165
|
+
]
|
|
166
|
+
return matching_names[0] if len(matching_names) == 1 else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _job_database_id(job: dict) -> str:
|
|
170
|
+
database_id = job.get("databaseId")
|
|
171
|
+
if database_id is not None:
|
|
172
|
+
return str(database_id)
|
|
173
|
+
match = re.search(r"/job/(\d+)(?:/|$)", str(job.get("url") or ""))
|
|
174
|
+
return match.group(1) if match is not None else ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def infrastructure_failure_diagnosis(
|
|
178
|
+
check: dict,
|
|
179
|
+
*,
|
|
180
|
+
command_runner=run,
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Classify known Actions billing/runner failures with bounded read-only detail."""
|
|
183
|
+
if INFRA_FAILURE_RE.search(_check_text(check)):
|
|
184
|
+
return "infrastructure failure (billing or runner unavailable)"
|
|
185
|
+
|
|
186
|
+
details_url = check.get("link") or check.get("detailsUrl") or check.get("targetUrl")
|
|
187
|
+
run_match = (
|
|
188
|
+
re.search(r"/actions/runs/(\d+)(?:/|$)", details_url)
|
|
189
|
+
if isinstance(details_url, str)
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
if run_match is None:
|
|
193
|
+
return ""
|
|
194
|
+
run_id = run_match.group(1)
|
|
195
|
+
|
|
196
|
+
jobs_result = command_runner(
|
|
197
|
+
["gh", "run", "view", run_id, "--json", "jobs"]
|
|
198
|
+
)
|
|
199
|
+
matching_job = None
|
|
200
|
+
if jobs_result.returncode == 0:
|
|
201
|
+
try:
|
|
202
|
+
jobs = json.loads(jobs_result.stdout).get("jobs") or []
|
|
203
|
+
except (AttributeError, json.JSONDecodeError):
|
|
204
|
+
jobs = []
|
|
205
|
+
matching_job = _matching_actions_job(check, jobs)
|
|
206
|
+
zero_step_failure = (
|
|
207
|
+
matching_job is not None
|
|
208
|
+
and str(matching_job.get("conclusion") or "").lower()
|
|
209
|
+
in {"failure", "cancelled", "timed_out", "startup_failure"}
|
|
210
|
+
and not (matching_job.get("steps") or [])
|
|
211
|
+
)
|
|
212
|
+
if zero_step_failure:
|
|
213
|
+
return "infrastructure failure (failed Actions job had zero steps)"
|
|
214
|
+
|
|
215
|
+
job_id = _job_database_id(matching_job) if matching_job is not None else ""
|
|
216
|
+
if not job_id:
|
|
217
|
+
return ""
|
|
218
|
+
log_result = command_runner([
|
|
219
|
+
"gh", "run", "view", run_id, "--job", job_id, "--log-failed"
|
|
220
|
+
])
|
|
221
|
+
external = sanitize_external_detail(
|
|
222
|
+
(log_result.stdout or "") + " " + (log_result.stderr or "")
|
|
223
|
+
)
|
|
224
|
+
if INFRA_FAILURE_RE.search(external):
|
|
225
|
+
return "infrastructure failure (billing or runner unavailable)"
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _pr_gate_snapshot(pr: str, command_runner) -> dict:
|
|
230
|
+
result = command_runner([
|
|
231
|
+
"gh", "pr", "view", pr, "--json",
|
|
232
|
+
"state,mergeable,mergeStateStatus,statusCheckRollup,baseRefName",
|
|
233
|
+
])
|
|
234
|
+
if result.returncode != 0:
|
|
235
|
+
raise Stop(
|
|
236
|
+
"0c merge-gate",
|
|
237
|
+
"cannot inspect PR checks",
|
|
238
|
+
sanitize_external_detail(result.stderr or result.stdout),
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
return json.loads(result.stdout)
|
|
242
|
+
except json.JSONDecodeError as error:
|
|
243
|
+
raise Stop("0c merge-gate", "invalid PR check response", str(error)) from error
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _required_checks_snapshot(pr: str, command_runner) -> list[dict]:
|
|
247
|
+
result = command_runner([
|
|
248
|
+
"gh", "pr", "checks", pr, "--required", "--json",
|
|
249
|
+
"name,state,link,bucket,workflow",
|
|
250
|
+
])
|
|
251
|
+
# gh uses 1 for failed checks and 8 for pending checks. Both still carry
|
|
252
|
+
# the authoritative JSON needed by this gate.
|
|
253
|
+
if result.returncode not in {0, 1, 8}:
|
|
254
|
+
raise Stop(
|
|
255
|
+
"0c merge-gate",
|
|
256
|
+
"cannot inspect required PR checks",
|
|
257
|
+
sanitize_external_detail(result.stderr or result.stdout),
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
checks = json.loads(result.stdout)
|
|
261
|
+
except json.JSONDecodeError as error:
|
|
262
|
+
raise Stop(
|
|
263
|
+
"0c merge-gate", "invalid required PR check response", str(error)
|
|
264
|
+
) from error
|
|
265
|
+
if not isinstance(checks, list) or not all(
|
|
266
|
+
isinstance(check, dict) for check in checks
|
|
267
|
+
):
|
|
268
|
+
raise Stop(
|
|
269
|
+
"0c merge-gate",
|
|
270
|
+
"invalid required PR check response",
|
|
271
|
+
"expected a JSON array of checks",
|
|
272
|
+
)
|
|
273
|
+
return checks
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _configured_required_check_names(snapshot: dict, command_runner) -> set[str]:
|
|
277
|
+
branch = snapshot.get("baseRefName")
|
|
278
|
+
if not isinstance(branch, str) or not branch:
|
|
279
|
+
raise Stop("0c merge-gate", "PR response has no base branch")
|
|
280
|
+
repo_result = command_runner(["gh", "repo", "view", "--json", "nameWithOwner"])
|
|
281
|
+
if repo_result.returncode != 0:
|
|
282
|
+
raise Stop(
|
|
283
|
+
"0c merge-gate",
|
|
284
|
+
"cannot inspect repository identity",
|
|
285
|
+
sanitize_external_detail(repo_result.stderr or repo_result.stdout),
|
|
286
|
+
)
|
|
287
|
+
try:
|
|
288
|
+
repository = json.loads(repo_result.stdout)["nameWithOwner"]
|
|
289
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
290
|
+
raise Stop("0c merge-gate", "invalid repository identity response", str(error)) from error
|
|
291
|
+
rules_result = command_runner([
|
|
292
|
+
"gh", "api",
|
|
293
|
+
f"repos/{quote(repository, safe='/')}/rules/branches/{quote(branch, safe='')}",
|
|
294
|
+
])
|
|
295
|
+
if rules_result.returncode != 0:
|
|
296
|
+
raise Stop(
|
|
297
|
+
"0c merge-gate",
|
|
298
|
+
"cannot inspect required-check rules",
|
|
299
|
+
sanitize_external_detail(rules_result.stderr or rules_result.stdout),
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
rules = json.loads(rules_result.stdout)
|
|
303
|
+
except json.JSONDecodeError as error:
|
|
304
|
+
raise Stop("0c merge-gate", "invalid required-check rules response", str(error)) from error
|
|
305
|
+
if not isinstance(rules, list):
|
|
306
|
+
raise Stop("0c merge-gate", "invalid required-check rules response", "expected a JSON array")
|
|
307
|
+
return {
|
|
308
|
+
check["context"]
|
|
309
|
+
for rule in rules
|
|
310
|
+
if isinstance(rule, dict) and rule.get("type") == "required_status_checks"
|
|
311
|
+
for check in (rule.get("parameters", {}).get("required_status_checks") or [])
|
|
312
|
+
if isinstance(check, dict) and isinstance(check.get("context"), str)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _failed_check_detail(failed: list[dict], command_runner) -> str:
|
|
317
|
+
details = []
|
|
318
|
+
for check in failed:
|
|
319
|
+
diagnosis = infrastructure_failure_diagnosis(
|
|
320
|
+
check, command_runner=command_runner
|
|
321
|
+
)
|
|
322
|
+
suffix = f" — {diagnosis}" if diagnosis else ""
|
|
323
|
+
details.append(f"{check_name(check)}{suffix}")
|
|
324
|
+
return ", ".join(details)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _waiting_checks(
|
|
328
|
+
required_checks: list[dict],
|
|
329
|
+
configured_required_names: set[str],
|
|
330
|
+
) -> list[dict]:
|
|
331
|
+
waiting = pending_checks(required_checks)
|
|
332
|
+
observed = {check_name(check) for check in required_checks}
|
|
333
|
+
waiting.extend(
|
|
334
|
+
{"name": f"{name} (awaiting discovery)"}
|
|
335
|
+
for name in sorted(configured_required_names - observed)
|
|
336
|
+
)
|
|
337
|
+
return waiting
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def wait_for_merge_gate(
|
|
341
|
+
pr: str,
|
|
342
|
+
*,
|
|
343
|
+
timeout_seconds: float = CHECK_WAIT_SECONDS,
|
|
344
|
+
poll_interval: float = CHECK_POLL_SECONDS,
|
|
345
|
+
command_runner=run,
|
|
346
|
+
clock=time.monotonic,
|
|
347
|
+
sleeper=time.sleep,
|
|
348
|
+
progress_stream=sys.stderr,
|
|
349
|
+
configured_required_names: set[str] | None = None,
|
|
350
|
+
) -> bool:
|
|
351
|
+
"""Wait for pending checks. Return True when the PR was already merged."""
|
|
352
|
+
started = clock()
|
|
353
|
+
while True:
|
|
354
|
+
snapshot = _pr_gate_snapshot(pr, command_runner)
|
|
355
|
+
state = snapshot.get("state")
|
|
356
|
+
if state == "MERGED":
|
|
357
|
+
return True
|
|
358
|
+
if state != "OPEN":
|
|
359
|
+
raise Stop("0c merge-gate", f"PR state {state} — cannot merge")
|
|
360
|
+
if snapshot.get("mergeable") == "CONFLICTING":
|
|
361
|
+
raise Stop("0c merge-gate", "PR is CONFLICTING — rebase/resolve the branch")
|
|
362
|
+
|
|
363
|
+
if configured_required_names is None:
|
|
364
|
+
configured_required_names = _configured_required_check_names(
|
|
365
|
+
snapshot, command_runner
|
|
366
|
+
)
|
|
367
|
+
checks = _required_checks_snapshot(pr, command_runner)
|
|
368
|
+
failed = red_checks(checks)
|
|
369
|
+
if failed:
|
|
370
|
+
raise Stop(
|
|
371
|
+
"0c merge-gate",
|
|
372
|
+
"red checks on the PR",
|
|
373
|
+
_failed_check_detail(failed, command_runner),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
waiting = _waiting_checks(checks, configured_required_names)
|
|
377
|
+
if not waiting:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
elapsed = clock() - started
|
|
381
|
+
names = ", ".join(check_name(check) for check in waiting)
|
|
382
|
+
if elapsed >= timeout_seconds:
|
|
383
|
+
raise Stop(
|
|
384
|
+
"0c merge-gate",
|
|
385
|
+
"check wait budget exceeded",
|
|
386
|
+
f"elapsed={elapsed:.1f}s; still pending: {names}",
|
|
387
|
+
)
|
|
388
|
+
print(
|
|
389
|
+
f"wrapup: waiting for PR #{pr} checks "
|
|
390
|
+
f"({elapsed:.1f}s elapsed): {names}",
|
|
391
|
+
file=progress_stream,
|
|
392
|
+
flush=True,
|
|
393
|
+
)
|
|
394
|
+
sleeper(min(poll_interval, timeout_seconds - elapsed))
|
|
395
|
+
|
|
396
|
+
|
|
65
397
|
# ---------- context ----------
|
|
66
398
|
|
|
67
399
|
def worktree_map(cwd: str | None = None) -> tuple[str, dict[str, str]]:
|
|
@@ -105,18 +437,189 @@ def load_worktree_cleanup_core():
|
|
|
105
437
|
return module
|
|
106
438
|
|
|
107
439
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
440
|
+
def load_candidate_landing_profile(core, wt: str):
|
|
441
|
+
"""Let the committed worktree policy nominate evidence without deletion."""
|
|
442
|
+
result = core.run(
|
|
443
|
+
[
|
|
444
|
+
"git", "show",
|
|
445
|
+
"HEAD:docs/agents/workflow-capabilities.json",
|
|
446
|
+
],
|
|
447
|
+
cwd=Path(wt),
|
|
448
|
+
check=False,
|
|
449
|
+
)
|
|
450
|
+
if result.returncode != 0:
|
|
451
|
+
detail = (result.stderr or result.stdout).strip()
|
|
452
|
+
raise core.LifecycleError(
|
|
453
|
+
f"committed landing profile cannot be read from worktree HEAD: {detail}"
|
|
454
|
+
)
|
|
455
|
+
candidate = core.load_profile_text(result.stdout)
|
|
456
|
+
if not candidate.landing_generated_artifact_policy_configured:
|
|
457
|
+
raise core.LifecycleError(
|
|
458
|
+
"worktree landing artifact policy is not configured; "
|
|
459
|
+
"run setup-workflow and commit the explicit policy decision first"
|
|
460
|
+
)
|
|
461
|
+
return candidate
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def load_canonical_landing_profile(core, wt: str, main_tree: str):
|
|
465
|
+
"""Authorize deletion only after the candidate policy is canonical."""
|
|
466
|
+
candidate = load_candidate_landing_profile(core, wt)
|
|
467
|
+
result = core.run(
|
|
468
|
+
[
|
|
469
|
+
"git", "show",
|
|
470
|
+
"origin/main:docs/agents/workflow-capabilities.json",
|
|
471
|
+
],
|
|
472
|
+
cwd=Path(main_tree),
|
|
473
|
+
check=False,
|
|
474
|
+
)
|
|
475
|
+
if result.returncode != 0:
|
|
476
|
+
detail = (result.stderr or result.stdout).strip()
|
|
477
|
+
raise core.LifecycleError(
|
|
478
|
+
f"canonical landing profile cannot be read from origin/main: {detail}"
|
|
479
|
+
)
|
|
480
|
+
canonical = core.load_profile_text(result.stdout)
|
|
481
|
+
if not canonical.landing_generated_artifact_policy_configured:
|
|
482
|
+
raise core.LifecycleError(
|
|
483
|
+
"merged canonical landing artifact policy is not configured"
|
|
484
|
+
)
|
|
485
|
+
if (
|
|
486
|
+
candidate.landing_generated_artifact_patterns
|
|
487
|
+
!= canonical.landing_generated_artifact_patterns
|
|
488
|
+
or candidate.scratch_patterns != canonical.scratch_patterns
|
|
489
|
+
):
|
|
490
|
+
raise core.LifecycleError(
|
|
491
|
+
"worktree cleanup policy differs from merged canonical origin/main"
|
|
492
|
+
)
|
|
493
|
+
attempt_path = core.artifact_baseline_path(Path(wt)).with_name(
|
|
494
|
+
core.LANDING_ATTEMPT_FILE
|
|
495
|
+
)
|
|
496
|
+
if not os.path.lexists(attempt_path):
|
|
497
|
+
raise core.LifecycleError(
|
|
498
|
+
"landing attempt is missing before canonical cleanup authorization"
|
|
499
|
+
)
|
|
500
|
+
attempt = core.landing_start_artifact_inventory(candidate, Path(wt))
|
|
501
|
+
if (
|
|
502
|
+
attempt["policyDigest"]
|
|
503
|
+
!= core.landing_cleanup_policy_digest(canonical)
|
|
504
|
+
):
|
|
505
|
+
raise core.LifecycleError(
|
|
506
|
+
"landing attempt cleanup policy differs from merged canonical origin/main"
|
|
507
|
+
)
|
|
508
|
+
return canonical
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def landing_artifact_baseline_digest(wt: str, main_tree: str) -> str:
|
|
112
512
|
core = load_worktree_cleanup_core()
|
|
113
513
|
try:
|
|
114
|
-
|
|
115
|
-
|
|
514
|
+
load_candidate_landing_profile(core, wt)
|
|
515
|
+
return core.load_artifact_baseline(Path(wt)).digest
|
|
516
|
+
except core.LifecycleError as error:
|
|
517
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
|
|
521
|
+
core = load_worktree_cleanup_core()
|
|
522
|
+
try:
|
|
523
|
+
profile = load_candidate_landing_profile(core, wt)
|
|
524
|
+
return core.landing_start_artifact_inventory(profile, Path(wt))
|
|
525
|
+
except core.LifecycleError as error:
|
|
526
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def landing_verified_scratch_evidence(
|
|
530
|
+
wt: str,
|
|
531
|
+
main_tree: str,
|
|
532
|
+
*,
|
|
533
|
+
expected_baseline_digest: str | None = None,
|
|
534
|
+
landing_start_files: tuple[str, ...] = (),
|
|
535
|
+
) -> tuple[dict, ...]:
|
|
536
|
+
core = load_worktree_cleanup_core()
|
|
537
|
+
try:
|
|
538
|
+
profile = load_candidate_landing_profile(core, wt)
|
|
539
|
+
return core.verified_landing_scratch_evidence(
|
|
116
540
|
profile,
|
|
117
|
-
Path(main_tree),
|
|
118
541
|
Path(wt),
|
|
119
|
-
|
|
542
|
+
expected_baseline_digest=expected_baseline_digest,
|
|
543
|
+
landing_start_files=landing_start_files,
|
|
544
|
+
)
|
|
545
|
+
except core.LifecycleError as error:
|
|
546
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def freeze_landing_artifact_evidence(
|
|
550
|
+
wt: str,
|
|
551
|
+
main_tree: str,
|
|
552
|
+
*,
|
|
553
|
+
push_succeeded: bool,
|
|
554
|
+
) -> tuple[dict, ...]:
|
|
555
|
+
core = load_worktree_cleanup_core()
|
|
556
|
+
try:
|
|
557
|
+
profile = load_candidate_landing_profile(core, wt)
|
|
558
|
+
return core.freeze_landing_artifact_evidence(
|
|
559
|
+
profile,
|
|
560
|
+
Path(wt),
|
|
561
|
+
push_succeeded=push_succeeded,
|
|
562
|
+
)
|
|
563
|
+
except core.LifecycleError as error:
|
|
564
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def reopen_frozen_landing_attempt(wt: str, main_tree: str) -> tuple[dict, ...]:
|
|
568
|
+
core = load_worktree_cleanup_core()
|
|
569
|
+
try:
|
|
570
|
+
profile = load_candidate_landing_profile(core, wt)
|
|
571
|
+
return core.reopen_frozen_landing_attempt(profile, Path(wt))
|
|
572
|
+
except core.LifecycleError as error:
|
|
573
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
|
|
577
|
+
core = load_worktree_cleanup_core()
|
|
578
|
+
try:
|
|
579
|
+
return str(core.abandon_unfinished_landing_attempt(Path(wt)))
|
|
580
|
+
except core.LifecycleError as error:
|
|
581
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def landing_verified_scratch_files(
|
|
585
|
+
wt: str,
|
|
586
|
+
main_tree: str,
|
|
587
|
+
*,
|
|
588
|
+
expected_baseline_digest: str | None = None,
|
|
589
|
+
) -> tuple[str, ...]:
|
|
590
|
+
core = load_worktree_cleanup_core()
|
|
591
|
+
try:
|
|
592
|
+
profile = load_candidate_landing_profile(core, wt)
|
|
593
|
+
return core.verified_landing_scratch_files(
|
|
594
|
+
profile,
|
|
595
|
+
Path(wt),
|
|
596
|
+
expected_baseline_digest=expected_baseline_digest,
|
|
597
|
+
)
|
|
598
|
+
except core.LifecycleError as error:
|
|
599
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def ensure_worktree_removable(
|
|
603
|
+
wt: str,
|
|
604
|
+
main_tree: str,
|
|
605
|
+
*,
|
|
606
|
+
verified_scratch_files: tuple[str, ...] = (),
|
|
607
|
+
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
608
|
+
):
|
|
609
|
+
core = load_worktree_cleanup_core()
|
|
610
|
+
try:
|
|
611
|
+
profile = load_canonical_landing_profile(core, wt, main_tree)
|
|
612
|
+
kwargs = {"merge_target": "origin/main"}
|
|
613
|
+
paths = (
|
|
614
|
+
tuple(item["path"] for item in verified_scratch_evidence)
|
|
615
|
+
or verified_scratch_files
|
|
616
|
+
)
|
|
617
|
+
if paths:
|
|
618
|
+
kwargs["verified_scratch_files"] = paths
|
|
619
|
+
if verified_scratch_evidence:
|
|
620
|
+
kwargs["verified_scratch_evidence"] = verified_scratch_evidence
|
|
621
|
+
assessment = core.cleanup_assessment(
|
|
622
|
+
profile, Path(main_tree), Path(wt), **kwargs
|
|
120
623
|
)
|
|
121
624
|
except core.LifecycleError as error:
|
|
122
625
|
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
@@ -129,6 +632,69 @@ def ensure_worktree_removable(wt: str, main_tree: str):
|
|
|
129
632
|
return assessment
|
|
130
633
|
|
|
131
634
|
|
|
635
|
+
def remove_verified_worktree_scratch(
|
|
636
|
+
wt: str,
|
|
637
|
+
main_tree: str,
|
|
638
|
+
assessment,
|
|
639
|
+
*,
|
|
640
|
+
verified_scratch_files: tuple[str, ...] = (),
|
|
641
|
+
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
642
|
+
):
|
|
643
|
+
"""Re-verify and delete only assessed regular scratch files."""
|
|
644
|
+
core = load_worktree_cleanup_core()
|
|
645
|
+
try:
|
|
646
|
+
profile = load_canonical_landing_profile(core, wt, main_tree)
|
|
647
|
+
evidence = verified_scratch_evidence
|
|
648
|
+
paths = tuple(item["path"] for item in evidence) or verified_scratch_files
|
|
649
|
+
latest = core.cleanup_assessment(
|
|
650
|
+
profile,
|
|
651
|
+
Path(main_tree),
|
|
652
|
+
Path(wt),
|
|
653
|
+
merge_target="origin/main",
|
|
654
|
+
verified_scratch_files=paths,
|
|
655
|
+
verified_scratch_evidence=evidence,
|
|
656
|
+
)
|
|
657
|
+
if latest.reasons:
|
|
658
|
+
raise core.LifecycleError(
|
|
659
|
+
"cleanup changed before removal: " + "; ".join(latest.reasons)
|
|
660
|
+
)
|
|
661
|
+
if (
|
|
662
|
+
latest.branch != assessment.branch
|
|
663
|
+
or latest.scratch_files != assessment.scratch_files
|
|
664
|
+
or latest.assumptions != assessment.assumptions
|
|
665
|
+
or latest.root_device != assessment.root_device
|
|
666
|
+
or latest.root_inode != assessment.root_inode
|
|
667
|
+
or latest.scratch_evidence != assessment.scratch_evidence
|
|
668
|
+
):
|
|
669
|
+
raise core.LifecycleError(
|
|
670
|
+
"cleanup changed before removal: inventory no longer matches preview"
|
|
671
|
+
)
|
|
672
|
+
with core.verified_worktree_root(
|
|
673
|
+
latest.worktree,
|
|
674
|
+
latest.root_device,
|
|
675
|
+
latest.root_inode,
|
|
676
|
+
) as root_descriptor:
|
|
677
|
+
core.remove_authorized_scratch(
|
|
678
|
+
profile,
|
|
679
|
+
root_descriptor,
|
|
680
|
+
latest.scratch_files,
|
|
681
|
+
latest.scratch_evidence,
|
|
682
|
+
)
|
|
683
|
+
final = core.cleanup_assessment(
|
|
684
|
+
profile,
|
|
685
|
+
Path(main_tree),
|
|
686
|
+
Path(wt),
|
|
687
|
+
merge_target="origin/main",
|
|
688
|
+
)
|
|
689
|
+
if final.reasons:
|
|
690
|
+
raise core.LifecycleError(
|
|
691
|
+
"cleanup changed after scratch removal: " + "; ".join(final.reasons)
|
|
692
|
+
)
|
|
693
|
+
return final
|
|
694
|
+
except core.LifecycleError as error:
|
|
695
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
696
|
+
|
|
697
|
+
|
|
132
698
|
# ---------- drift log (ANNAHMEN.md) ----------
|
|
133
699
|
|
|
134
700
|
def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
|
|
@@ -370,9 +936,39 @@ def cmd_land(args) -> dict:
|
|
|
370
936
|
|
|
371
937
|
# drift markers from the build-time log — mechanical, no gate
|
|
372
938
|
markers: list[dict] = []
|
|
939
|
+
artifact_baseline_digest: str | None = None
|
|
940
|
+
landing_start_files: tuple[str, ...] = ()
|
|
941
|
+
landing_attempt: dict | None = None
|
|
942
|
+
generated_evidence: tuple[dict, ...] = ()
|
|
373
943
|
if wt_exists:
|
|
374
944
|
if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
|
|
375
945
|
raise Stop("land", "worktree dirty — run `commit` first", wt)
|
|
946
|
+
if getattr(args, "abandon_unfinished_attempt", False):
|
|
947
|
+
return {
|
|
948
|
+
"landing_attempt_abandoned": abandon_unfinished_landing_attempt(
|
|
949
|
+
wt, main_tree
|
|
950
|
+
),
|
|
951
|
+
"files_removed": [],
|
|
952
|
+
"next": (
|
|
953
|
+
"current files remain protected; classify or move blockers, "
|
|
954
|
+
"then rerun land"
|
|
955
|
+
),
|
|
956
|
+
}
|
|
957
|
+
landing_attempt = landing_start_artifact_inventory(wt, main_tree)
|
|
958
|
+
if landing_attempt["state"] == "started" and not landing_attempt["newAttempt"]:
|
|
959
|
+
raise Stop(
|
|
960
|
+
"cleanup",
|
|
961
|
+
"unfinished landing generator attempt has no frozen output evidence; "
|
|
962
|
+
"classify its files, then rerun with --abandon-unfinished-attempt",
|
|
963
|
+
)
|
|
964
|
+
landing_start_files = tuple(landing_attempt["generatedFiles"])
|
|
965
|
+
if landing_start_files:
|
|
966
|
+
raise Stop(
|
|
967
|
+
"cleanup",
|
|
968
|
+
"landing-start generated paths are consumer-owned and protected: "
|
|
969
|
+
+ ", ".join(landing_start_files),
|
|
970
|
+
)
|
|
971
|
+
artifact_baseline_digest = landing_attempt["baselineDigest"]
|
|
376
972
|
annahmen = Path(wt) / "ANNAHMEN.md"
|
|
377
973
|
if annahmen.is_file():
|
|
378
974
|
markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
|
|
@@ -384,9 +980,25 @@ def cmd_land(args) -> dict:
|
|
|
384
980
|
|
|
385
981
|
# Step 0b — push
|
|
386
982
|
if wt_exists:
|
|
387
|
-
|
|
388
|
-
if
|
|
389
|
-
|
|
983
|
+
assert landing_attempt is not None
|
|
984
|
+
if landing_attempt["state"] == "frozen" and landing_attempt["pushSucceeded"]:
|
|
985
|
+
generated_evidence = freeze_landing_artifact_evidence(
|
|
986
|
+
wt, main_tree, push_succeeded=True
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
if landing_attempt["state"] == "frozen":
|
|
990
|
+
# Validate every previously frozen identity before a retry can
|
|
991
|
+
# invoke the generator-capable pre-push hook.
|
|
992
|
+
reopen_frozen_landing_attempt(wt, main_tree)
|
|
993
|
+
p = git(["push", "-u", "origin", branch], cwd=wt)
|
|
994
|
+
generated_evidence = freeze_landing_artifact_evidence(
|
|
995
|
+
wt, main_tree, push_succeeded=p.returncode == 0
|
|
996
|
+
)
|
|
997
|
+
if p.returncode != 0:
|
|
998
|
+
raise Stop(
|
|
999
|
+
"0b push", "push rejected",
|
|
1000
|
+
(p.stderr or p.stdout).strip()[-2000:],
|
|
1001
|
+
)
|
|
390
1002
|
|
|
391
1003
|
# Step 0c — ensure PR + final body
|
|
392
1004
|
p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
|
|
@@ -432,20 +1044,9 @@ def cmd_land(args) -> dict:
|
|
|
432
1044
|
report["warnings"].append("pr-body-check exit 2 (fail-open): "
|
|
433
1045
|
+ (p.stdout + p.stderr).strip()[:300])
|
|
434
1046
|
|
|
435
|
-
# merge gate
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
check=True).stdout)
|
|
439
|
-
red = [c for c in (d.get("statusCheckRollup") or [])
|
|
440
|
-
if (c.get("conclusion") or "").upper() in RED_CHECK_CONCLUSIONS]
|
|
441
|
-
if red:
|
|
442
|
-
raise Stop("0c merge-gate", "red checks on the PR",
|
|
443
|
-
", ".join(c.get("name") or c.get("context", "?") for c in red))
|
|
444
|
-
if d.get("mergeable") == "CONFLICTING":
|
|
445
|
-
raise Stop("0c merge-gate", "PR is CONFLICTING — rebase/resolve the branch")
|
|
446
|
-
already_merged = d.get("state") == "MERGED"
|
|
447
|
-
if d.get("state") not in ("OPEN", "MERGED"):
|
|
448
|
-
raise Stop("0c merge-gate", f"PR state {d.get('state')} — cannot merge")
|
|
1047
|
+
# merge gate — wait boundedly for fresh-PR checks; already-MERGED resumes
|
|
1048
|
+
# directly at teardown. Progress stays on stderr so stdout remains one JSON.
|
|
1049
|
+
already_merged = wait_for_merge_gate(pr)
|
|
449
1050
|
|
|
450
1051
|
# Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
|
|
451
1052
|
# PR state is the authority, not gh's exit code: `--delete-branch` also tries
|
|
@@ -468,12 +1069,26 @@ def cmd_land(args) -> dict:
|
|
|
468
1069
|
# Step 2 — kill the worktree's dev server, then Step 4 — teardown
|
|
469
1070
|
if wt_exists:
|
|
470
1071
|
git(["fetch", "origin", "main"], cwd=main_tree, check=True)
|
|
471
|
-
|
|
1072
|
+
generated = tuple(item["path"] for item in generated_evidence)
|
|
1073
|
+
cleanup = ensure_worktree_removable(
|
|
1074
|
+
wt,
|
|
1075
|
+
main_tree,
|
|
1076
|
+
verified_scratch_files=generated,
|
|
1077
|
+
verified_scratch_evidence=generated_evidence,
|
|
1078
|
+
)
|
|
472
1079
|
report["cleanup_guard"] = {
|
|
473
1080
|
"active": cleanup is not None,
|
|
474
1081
|
"assumptions_read": bool(cleanup and cleanup.assumptions),
|
|
1082
|
+
"landing_generated_files": list(generated),
|
|
475
1083
|
}
|
|
476
1084
|
report["killed_processes"] = kill_worktree_processes(wt)
|
|
1085
|
+
if cleanup is not None:
|
|
1086
|
+
cleanup = remove_verified_worktree_scratch(
|
|
1087
|
+
wt,
|
|
1088
|
+
main_tree,
|
|
1089
|
+
cleanup,
|
|
1090
|
+
verified_scratch_evidence=generated_evidence,
|
|
1091
|
+
)
|
|
477
1092
|
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
478
1093
|
if p.returncode != 0:
|
|
479
1094
|
raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
|
|
@@ -601,6 +1216,14 @@ def main() -> int:
|
|
|
601
1216
|
l.add_argument("--body-file", help="final PR body (create or overwrite)")
|
|
602
1217
|
l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
|
|
603
1218
|
l.add_argument("--skip-malformed-drift", action="store_true")
|
|
1219
|
+
l.add_argument(
|
|
1220
|
+
"--abandon-unfinished-attempt",
|
|
1221
|
+
action="store_true",
|
|
1222
|
+
help=(
|
|
1223
|
+
"archive an interrupted pre-freeze landing attempt without deleting "
|
|
1224
|
+
"or claiming its ambiguous files"
|
|
1225
|
+
),
|
|
1226
|
+
)
|
|
604
1227
|
args = ap.parse_args()
|
|
605
1228
|
|
|
606
1229
|
try:
|