@ikon85/agent-workflow-kit 0.36.5 → 0.37.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 +15 -7
- package/.agents/skills/setup-workflow/SKILL.md +16 -2
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.agents/skills/wrapup/SKILL.md +20 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
- package/.claude/skills/setup-workflow/SKILL.md +16 -2
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +11 -10
- package/README.md +52 -1
- package/agent-workflow-kit.package.json +26 -26
- package/docs/agents/workflow-capabilities.json +1 -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 +35 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +18 -4
- package/scripts/worktree-lifecycle/cleanup.py +173 -6
- package/scripts/worktree-lifecycle/core.py +331 -18
- package/scripts/worktree-lifecycle/profile.py +2 -0
- package/scripts/wrapup-land.py +336 -15
- package/src/cli.mjs +60 -27
- 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]]:
|
|
@@ -432,20 +764,9 @@ def cmd_land(args) -> dict:
|
|
|
432
764
|
report["warnings"].append("pr-body-check exit 2 (fail-open): "
|
|
433
765
|
+ (p.stdout + p.stderr).strip()[:300])
|
|
434
766
|
|
|
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")
|
|
767
|
+
# merge gate — wait boundedly for fresh-PR checks; already-MERGED resumes
|
|
768
|
+
# directly at teardown. Progress stays on stderr so stdout remains one JSON.
|
|
769
|
+
already_merged = wait_for_merge_gate(pr)
|
|
449
770
|
|
|
450
771
|
# Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
|
|
451
772
|
# PR state is the authority, not gh's exit code: `--delete-branch` also tries
|
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
import * as p from '@clack/prompts';
|
|
@@ -20,29 +21,51 @@ import { createCommandAdapter } from '../scripts/release-state.mjs';
|
|
|
20
21
|
import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
|
|
21
22
|
|
|
22
23
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
-
const consumerRoot = process.cwd();
|
|
24
24
|
|
|
25
25
|
function stamp() {
|
|
26
26
|
// backup suffix: YYYYMMDDTHHMMSS (no separators that collide with shells)
|
|
27
27
|
return new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
export async function runCli({
|
|
31
|
+
argv = process.argv.slice(2),
|
|
32
|
+
kitRoot = KIT_ROOT,
|
|
33
|
+
consumerRoot = process.cwd(),
|
|
34
|
+
hasTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
35
|
+
readUpdateRelease: releaseReader = readUpdateRelease,
|
|
36
|
+
updateCommand = update,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const args = argv;
|
|
39
|
+
const cmd = args[0];
|
|
40
|
+
const force = args.includes('--force');
|
|
41
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
42
|
+
const owned = args.includes('--owned');
|
|
43
|
+
const keepDeleted = args.includes('--keep-deleted');
|
|
44
|
+
const restoreDeleted = args.includes('--restore-deleted');
|
|
45
|
+
const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
|
|
46
|
+
let exitCode = 0;
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
if (cmd === 'update' && keepDeleted && restoreDeleted) {
|
|
49
|
+
process.stderr.write('Error: --keep-deleted and --restore-deleted are mutually exclusive.\n');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
if (cmd === 'update' && !hasTTY && !yes) {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
'Error: interactive prompts need a TTY. For non-interactive update, pass --yes '
|
|
55
|
+
+ 'and optionally --keep-deleted or --restore-deleted.\n',
|
|
56
|
+
);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
p.intro('agent-workflow-kit');
|
|
38
61
|
|
|
39
|
-
try {
|
|
62
|
+
try {
|
|
40
63
|
if (cmd === 'init') {
|
|
41
64
|
const r = await init({
|
|
42
|
-
kitRoot
|
|
65
|
+
kitRoot,
|
|
43
66
|
consumerRoot,
|
|
44
67
|
force,
|
|
45
|
-
routingProfile: routingProfileOptions(),
|
|
68
|
+
routingProfile: routingProfileOptions(yes),
|
|
46
69
|
});
|
|
47
70
|
p.note(
|
|
48
71
|
`copied ${r.copied.length} · seeded ${r.seeded.length} stub(s)` +
|
|
@@ -53,21 +76,23 @@ try {
|
|
|
53
76
|
p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
|
|
54
77
|
'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
|
|
55
78
|
} else if (cmd === 'diff') {
|
|
56
|
-
const r = await diff({ kitRoot
|
|
79
|
+
const r = await diff({ kitRoot, consumerRoot, owned });
|
|
57
80
|
printPlan(r);
|
|
58
81
|
p.outro('Dry run — nothing written. Run `update` to apply.');
|
|
59
82
|
} else if (cmd === 'update') {
|
|
60
83
|
const decide = (action, path, classification) => (
|
|
61
|
-
decideUpdate(action, path, yes, classification
|
|
84
|
+
decideUpdate(action, path, yes, classification, {
|
|
85
|
+
deleted: keepDeleted ? 'keep' : restoreDeleted ? 'restore' : undefined,
|
|
86
|
+
})
|
|
62
87
|
);
|
|
63
|
-
const releaseIdentities = await
|
|
64
|
-
const r = await
|
|
65
|
-
kitRoot
|
|
88
|
+
const releaseIdentities = await releaseReader();
|
|
89
|
+
const r = await updateCommand({
|
|
90
|
+
kitRoot,
|
|
66
91
|
consumerRoot,
|
|
67
92
|
now: stamp(),
|
|
68
93
|
decide,
|
|
69
94
|
releaseIdentities,
|
|
70
|
-
routingProfile: routingProfileOptions(),
|
|
95
|
+
routingProfile: routingProfileOptions(yes),
|
|
71
96
|
});
|
|
72
97
|
printPlan(r);
|
|
73
98
|
printRoutingProfile(r.routingProfile);
|
|
@@ -79,7 +104,7 @@ try {
|
|
|
79
104
|
`not applied · conflicts ${r.conflicts.length} · ` +
|
|
80
105
|
`ownership collisions ${r.collisions.length}`,
|
|
81
106
|
);
|
|
82
|
-
|
|
107
|
+
exitCode = 2;
|
|
83
108
|
} else if (r.status === 'current') {
|
|
84
109
|
p.outro(`aktuell · unchanged ${r.unchanged.length} · local modifications ${r.userModified.length}`);
|
|
85
110
|
} else {
|
|
@@ -87,7 +112,7 @@ try {
|
|
|
87
112
|
}
|
|
88
113
|
} else if (cmd === 'uninstall') {
|
|
89
114
|
const ok = yes || (await p.confirm({ message: 'Remove kit-installed files?' })) === true;
|
|
90
|
-
if (!ok) { p.cancel('Aborted.');
|
|
115
|
+
if (!ok) { p.cancel('Aborted.'); return 0; }
|
|
91
116
|
const r = await uninstall({ consumerRoot });
|
|
92
117
|
p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
|
|
93
118
|
} else if (cmd === 'contribute') {
|
|
@@ -101,7 +126,7 @@ try {
|
|
|
101
126
|
);
|
|
102
127
|
}
|
|
103
128
|
if (action === 'start') {
|
|
104
|
-
await beginContributionBridge({ kitRoot
|
|
129
|
+
await beginContributionBridge({ kitRoot, consumerRoot, path });
|
|
105
130
|
p.outro(`${path} is now a contribution bridge; no remote was changed`);
|
|
106
131
|
} else if (action === 'status') {
|
|
107
132
|
const surface = args.find((arg) => arg.startsWith('--surface='))
|
|
@@ -132,7 +157,7 @@ try {
|
|
|
132
157
|
);
|
|
133
158
|
}
|
|
134
159
|
await prepareContributionArtifact({
|
|
135
|
-
kitRoot
|
|
160
|
+
kitRoot, consumerRoot, path, output,
|
|
136
161
|
});
|
|
137
162
|
p.outro(`prepared local contribution artifact ${output}; no remote was changed`);
|
|
138
163
|
}
|
|
@@ -145,7 +170,7 @@ try {
|
|
|
145
170
|
}
|
|
146
171
|
const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
|
|
147
172
|
if (origin === CONSUMER_ORIGIN && ownershipState === 'contribution-bridge') {
|
|
148
|
-
await beginContributionBridge({ kitRoot
|
|
173
|
+
await beginContributionBridge({ kitRoot, consumerRoot, path: args[1] });
|
|
149
174
|
} else {
|
|
150
175
|
await setOwnership({ consumerRoot, path: args[1], origin, ownershipState });
|
|
151
176
|
}
|
|
@@ -153,12 +178,20 @@ try {
|
|
|
153
178
|
(origin === CONSUMER_ORIGIN ? ` (${ownershipState ?? 'explicit-fork'})` : ''));
|
|
154
179
|
} else {
|
|
155
180
|
p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown|contribute> ' +
|
|
156
|
-
'[<path>] [--force] [--yes] [--
|
|
181
|
+
'[<path>] [--force] [--yes] [--keep-deleted|--restore-deleted] [--owned] ' +
|
|
182
|
+
'[--as=contribution-bridge|explicit-fork]');
|
|
157
183
|
p.outro('');
|
|
158
184
|
}
|
|
159
185
|
} catch (err) {
|
|
160
186
|
p.cancel(`Error: ${err.message}`);
|
|
161
|
-
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
return exitCode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
193
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
194
|
+
process.exitCode = await runCli();
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
function printPlan(r) {
|
|
@@ -190,8 +223,8 @@ function printPlan(r) {
|
|
|
190
223
|
p.note(lines.join('\n') || 'no changes', 'plan');
|
|
191
224
|
}
|
|
192
225
|
|
|
193
|
-
async function decideUpdate(action, path, yes, classification) {
|
|
194
|
-
if (yes) return nonInteractiveUpdateDecision(action);
|
|
226
|
+
async function decideUpdate(action, path, yes, classification, choices = {}) {
|
|
227
|
+
if (yes) return nonInteractiveUpdateDecision(action, choices);
|
|
195
228
|
if (action === 'delete') {
|
|
196
229
|
return (await p.confirm({ message: `Upstream removed ${path} — delete it locally?` })) === true;
|
|
197
230
|
}
|
|
@@ -213,7 +246,7 @@ async function decideUpdate(action, path, yes, classification) {
|
|
|
213
246
|
throw new Error(`unknown update decision action: ${action}`);
|
|
214
247
|
}
|
|
215
248
|
|
|
216
|
-
function routingProfileOptions() {
|
|
249
|
+
function routingProfileOptions(yes) {
|
|
217
250
|
return {
|
|
218
251
|
currentSurface: currentAgentSurface(),
|
|
219
252
|
prompt: yes ? undefined : promptRoutingProfile,
|