@ikon85/agent-workflow-kit 0.36.4 → 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.
Files changed (43) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
  2. package/.agents/skills/setup-workflow/SKILL.md +16 -2
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
  5. package/.agents/skills/wrapup/SKILL.md +20 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
  8. package/.claude/skills/setup-workflow/SKILL.md +16 -2
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +11 -10
  13. package/README.md +59 -1
  14. package/agent-workflow-kit.package.json +26 -26
  15. package/docs/agents/workflow-capabilities.json +1 -0
  16. package/package.json +1 -1
  17. package/scripts/anchor_table.py +14 -8
  18. package/scripts/project-skill-extension.mjs +21 -2
  19. package/scripts/readiness.mjs +32 -4
  20. package/scripts/release-state.mjs +19 -9
  21. package/scripts/release-state.test.mjs +71 -8
  22. package/scripts/test_anchor_table.py +69 -0
  23. package/scripts/test_board_sync_create_idempotency.py +15 -2
  24. package/scripts/test_board_sync_wave_title.py +14 -2
  25. package/scripts/test_census_backstop.py +33 -0
  26. package/scripts/test_orchestrate_wave_contract.py +35 -0
  27. package/scripts/test_retro_wrapup_contract.py +19 -2
  28. package/scripts/test_wrapup_land.py +428 -0
  29. package/scripts/workflow-advisories/core.py +44 -2
  30. package/scripts/worktree-lifecycle/README.md +18 -4
  31. package/scripts/worktree-lifecycle/cleanup.py +173 -6
  32. package/scripts/worktree-lifecycle/core.py +331 -18
  33. package/scripts/worktree-lifecycle/profile.py +2 -0
  34. package/scripts/wrapup-land.py +336 -15
  35. package/src/cli.mjs +60 -27
  36. package/src/lib/manifest.mjs +173 -3
  37. package/src/lib/projectSkillExtension.mjs +78 -1
  38. package/src/lib/updateCandidate.mjs +11 -1
  39. package/src/lib/updateDecisions.mjs +2 -2
  40. package/src/lib/updateReconcile.mjs +6 -0
  41. package/src/lib/verifyUpdateCandidate.mjs +6 -3
  42. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
  43. package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
@@ -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 = {"FAILURE", "CANCELLED", "TIMED_OUT"}
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
- d = json.loads(run(["gh", "pr", "view", pr, "--json",
437
- "state,mergeable,mergeStateStatus,statusCheckRollup"],
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
- const args = process.argv.slice(2);
31
- const cmd = args[0];
32
- const force = args.includes('--force');
33
- const yes = args.includes('--yes') || args.includes('-y');
34
- const owned = args.includes('--owned');
35
- const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
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
- p.intro('agent-workflow-kit');
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: KIT_ROOT,
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: KIT_ROOT, consumerRoot, owned });
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 readUpdateRelease();
64
- const r = await update({
65
- kitRoot: KIT_ROOT,
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
- process.exitCode = 2;
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.'); process.exit(0); }
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: KIT_ROOT, consumerRoot, path });
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: KIT_ROOT, consumerRoot, path, output,
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: KIT_ROOT, consumerRoot, path: args[1] });
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] [--owned] [--as=contribution-bridge|explicit-fork]');
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
- process.exit(1);
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,