@event4u/agent-config 4.9.0 → 5.0.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 (65) hide show
  1. package/.agent-src/commands/implement-ticket.md +5 -4
  2. package/.agent-src/rules/language-and-tone.md +4 -10
  3. package/.agent-src/skills/command-routing/SKILL.md +5 -4
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/CHANGELOG.md +73 -0
  6. package/CONTRIBUTING.md +19 -0
  7. package/README.md +11 -0
  8. package/dist/cli/registry.js +0 -2
  9. package/dist/cli/registry.js.map +1 -1
  10. package/dist/discovery/deprecation-report.md +1 -1
  11. package/dist/discovery/discovery-manifest.json +5 -5
  12. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  13. package/dist/discovery/discovery-manifest.summary.md +1 -1
  14. package/dist/discovery/orphan-report.md +1 -1
  15. package/dist/discovery/packs.json +2 -2
  16. package/dist/discovery/trust-report.md +1 -1
  17. package/dist/discovery/workspaces.json +2 -2
  18. package/dist/mcp/registry-manifest.json +2 -2
  19. package/dist/router.json +1 -1671
  20. package/docs/benchmark.md +20 -8
  21. package/docs/benchmarks.md +11 -0
  22. package/docs/contracts/benchmark-corpus-spec.md +31 -3
  23. package/docs/contracts/command-surface-tiers.md +1 -1
  24. package/docs/contracts/hook-architecture-v1.md +33 -0
  25. package/docs/contracts/migrate-command.md +197 -0
  26. package/docs/contracts/settings-api.md +2 -1
  27. package/docs/contracts/value-dashboard-spec.md +374 -0
  28. package/docs/contracts/value-report-schema.md +150 -0
  29. package/docs/decisions/ADR-031-validation-severity-tiers-and-projection-roundtrip.md +97 -0
  30. package/docs/decisions/INDEX.md +1 -0
  31. package/docs/guidelines/agent-infra/installed-tools-manifest.md +6 -3
  32. package/docs/guidelines/agent-infra/language-and-tone-examples.md +35 -0
  33. package/docs/migration/v1-to-v2.md +40 -27
  34. package/docs/value.md +84 -0
  35. package/package.json +8 -8
  36. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  37. package/scripts/_cli/cmd_migrate.py +264 -102
  38. package/scripts/_cli/cmd_settings_migrate.py +2 -1
  39. package/scripts/_dispatch.bash +147 -49
  40. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  41. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  42. package/scripts/_lib/install_regenerator.py +129 -0
  43. package/scripts/_lib/value_ladder.py +599 -0
  44. package/scripts/_lib/value_report.py +441 -0
  45. package/scripts/bench_rtk_savings.py +320 -0
  46. package/scripts/compile_router.py +19 -5
  47. package/scripts/expected_perms.json +1 -1
  48. package/scripts/first_run_gate_hook.py +178 -0
  49. package/scripts/hook_manifest.yaml +16 -7
  50. package/scripts/hooks/dispatch_hook.py +27 -0
  51. package/scripts/hooks/dispatch_issues.py +136 -0
  52. package/scripts/hooks_doctor.py +40 -1
  53. package/scripts/install.py +25 -21
  54. package/scripts/lint_agents_layout.py +5 -4
  55. package/scripts/lint_bench_corpus.py +86 -4
  56. package/scripts/lint_global_paths.py +4 -3
  57. package/scripts/lint_marketplace_install_completeness.py +188 -0
  58. package/scripts/lint_value_dashboard.py +218 -0
  59. package/scripts/render_benchmark_md.py +6 -2
  60. package/scripts/render_value_md.py +355 -0
  61. package/scripts/repro/repro_marketplace_install_gap.sh +161 -0
  62. package/scripts/roadmap_progress_hook.py +23 -0
  63. package/scripts/router_telemetry.py +470 -0
  64. package/scripts/validate_frontmatter.py +23 -9
  65. package/scripts/_cli/cmd_migrate_to_global.py +0 -415
@@ -12,7 +12,11 @@ Supported keywords: ``type``, ``required``, ``properties``,
12
12
 
13
13
  The goal is a **better error surface**: each violation comes back as a
14
14
  ``SchemaError`` with ``path`` (dotted JSON pointer), ``rule`` (the schema
15
- keyword that failed), and a human-readable message.
15
+ keyword that failed), a human-readable message, and a ``severity``
16
+ (``"error"`` = fatal / fails CI, ``"warning"`` = advisory). Structural
17
+ keywords (``required``/``type``/``enum``/``pattern``/``additionalProperties``/
18
+ ``minItems``) are fatal; length keywords (``minLength``/``maxLength``) are
19
+ advisory warnings. See ADR-031.
16
20
  """
17
21
 
18
22
  from __future__ import annotations
@@ -39,12 +43,14 @@ class SchemaError:
39
43
  path: str
40
44
  rule: str
41
45
  message: str
46
+ severity: str = "error" # "error" (fatal, fails CI) | "warning" (advisory)
42
47
 
43
48
  def format(self, file: str | None = None, line: int | None = None) -> str:
44
49
  prefix = file or "<data>"
45
50
  if line is not None:
46
51
  prefix = f"{prefix}:{line}"
47
- return f"{prefix} {self.rule} at {self.path} {self.message}"
52
+ marker = "⚠️ " if self.severity == "warning" else ""
53
+ return f"{prefix} – {marker}{self.rule} at {self.path} – {self.message}"
48
54
 
49
55
 
50
56
  # --- Frontmatter parser (stdlib-only, YAML subset) -------------------------
@@ -318,12 +324,14 @@ def _validate_string(data: str, schema: dict[str, Any], path: str, errors: list[
318
324
  pattern = schema.get("pattern")
319
325
  if pattern is not None and not re.search(pattern, data):
320
326
  errors.append(SchemaError(path, "pattern", f"Value {data!r} does not match /{pattern}/"))
327
+ # Length constraints are advisory (quality, not structural correctness):
328
+ # they surface as warnings, not fatal CI failures. See ADR-031.
321
329
  min_len = schema.get("minLength")
322
330
  if min_len is not None and len(data) < min_len:
323
- errors.append(SchemaError(path, "minLength", f"String length {len(data)} < {min_len}"))
331
+ errors.append(SchemaError(path, "minLength", f"String length {len(data)} < {min_len}", severity="warning"))
324
332
  max_len = schema.get("maxLength")
325
333
  if max_len is not None and len(data) > max_len:
326
- errors.append(SchemaError(path, "maxLength", f"String length {len(data)} > {max_len}"))
334
+ errors.append(SchemaError(path, "maxLength", f"String length {len(data)} > {max_len}", severity="warning"))
327
335
 
328
336
 
329
337
  def _validate_integer(data: int, schema: dict[str, Any], path: str, errors: list[SchemaError]) -> None:
@@ -458,6 +466,7 @@ def _main() -> int:
458
466
 
459
467
  total = 0
460
468
  failing = 0
469
+ warned = 0
461
470
  for root in roots:
462
471
  for artefact_type, path in _iter_artefacts(root):
463
472
  total += 1
@@ -468,14 +477,19 @@ def _main() -> int:
468
477
  continue
469
478
  schema = load_schema(artefact_type)
470
479
  errors = validate(data, schema)
471
- if errors:
480
+ fatal = [e for e in errors if e.severity == "error"]
481
+ warnings = [e for e in errors if e.severity == "warning"]
482
+ if fatal:
472
483
  failing += 1
473
- for error in errors:
474
- print(f"[{artefact_type}] {path}: {error.rule} at "
475
- f"{error.path} {error.message}")
484
+ if warnings:
485
+ warned += 1
486
+ for error in errors:
487
+ marker = "⚠️ " if error.severity == "warning" else "❌ "
488
+ print(f"{marker}[{artefact_type}] {path}: {error.rule} at "
489
+ f"{error.path} – {error.message}")
476
490
 
477
491
  print(f"\n== Frontmatter schema: {total} artefacts, "
478
- f"{failing} failing ==")
492
+ f"{failing} failing, {warned} with warnings ==")
479
493
  return 1 if failing else 0
480
494
 
481
495
 
@@ -1,415 +0,0 @@
1
- """``agent-config migrate-to-global`` — one-shot legacy → global migration.
2
-
3
- Phase 5.1 + 5.3 + 5.5 of ``agents/roadmaps/road-to-global-only-install.md``.
4
- Lifts a v2.x global-default consumer onto the v2.x global-only surface
5
- (ADR-020).
6
-
7
- **Order (per A2)**: ``copy → verify → move → bridge`` — never the inverse.
8
-
9
- 1. **Gate** — run ``scripts/lint_global_paths.py`` first; any finding aborts
10
- before a single byte is written. Once ``.legacy-pre-global-only/`` is on
11
- disk, a perms leak cannot be un-written.
12
- 2. **Detect** — project-local YAML settings (``.agent-settings.yml``,
13
- ``.agent-user.yml``, optionally under ``settings/``) and tool-scope
14
- leftover directories (``.augment/``, ``.claude/``, ``.cursor/``).
15
- 3. **Copy** YAML values into ``~/.event4u/agent-config/``. Refuses to
16
- overwrite a non-empty global file without ``--force``.
17
- 4. **Verify** every global copy round-trip parses and has mode ``0600``.
18
- 5. **Move** legacy originals into ``.legacy-pre-global-only/<stamp>/``
19
- alongside a ``manifest.json`` recording every file moved and every
20
- global file this migration created (used by ``--rollback``).
21
- 6. **Bridge** — write ``agents/.event4u-bridge.yml`` last.
22
- 7. **Summary** — single block: copied / verified / moved / skipped per file.
23
-
24
- ``--dry-run`` lists the plan without touching disk; exit 0.
25
- ``--rollback`` reads the latest snapshot manifest and reverses the
26
- migration byte-identically (Phase 5.5 / A3).
27
- """
28
- from __future__ import annotations
29
-
30
- import argparse
31
- import json
32
- import os
33
- import shutil
34
- import sys
35
- from datetime import datetime, timezone
36
- from pathlib import Path
37
- from typing import Optional
38
-
39
- # Filenames detected at the project root (Phase 5.1 step 1).
40
- LEGACY_YAML_FILES: tuple[str, ...] = (".agent-settings.yml", ".agent-user.yml")
41
- LEGACY_TOOL_DIRS: tuple[str, ...] = (".augment", ".claude", ".cursor")
42
- SNAPSHOT_DIRNAME = ".legacy-pre-global-only"
43
- MANIFEST_NAME = "manifest.json"
44
-
45
-
46
- def _import_install():
47
- here = Path(__file__).resolve().parents[2]
48
- if str(here) not in sys.path:
49
- sys.path.insert(0, str(here))
50
- from scripts import install as install_mod # noqa: PLC0415
51
- return install_mod
52
-
53
-
54
- def _import_lint_global_paths():
55
- here = Path(__file__).resolve().parents[2]
56
- if str(here) not in sys.path:
57
- sys.path.insert(0, str(here))
58
- from scripts import lint_global_paths as lgp # noqa: PLC0415
59
- return lgp
60
-
61
-
62
- def _resolve_installed_version(install_mod) -> str:
63
- """Return the current package version via the install module's lock helper."""
64
- try:
65
- lock_mod = install_mod._load_installed_lock_module()
66
- return lock_mod.current_package_version()
67
- except Exception: # noqa: BLE001 — best-effort version resolution.
68
- return "unknown"
69
-
70
-
71
- def _consumer_bridge_marker_abs(project: Path, install_mod) -> Path:
72
- """Resolve ``agents/.event4u-bridge.yml`` for ``project``."""
73
- relpath = install_mod.CONSUMER_BRIDGE_MARKER_RELPATH
74
- return project / relpath
75
-
76
-
77
- def _is_non_empty(path: Path) -> bool:
78
- try:
79
- return path.is_file() and path.read_text(encoding="utf-8").strip() != ""
80
- except OSError:
81
- return False
82
-
83
-
84
- def _parse_yaml(path: Path) -> tuple[bool, str]:
85
- try:
86
- import yaml # type: ignore[import-not-found]
87
- except ImportError:
88
- return True, "" # No PyYAML available — defer validation.
89
- try:
90
- text = path.read_text(encoding="utf-8")
91
- yaml.safe_load(text)
92
- return True, ""
93
- except (OSError, Exception) as exc: # noqa: BLE001 — argparse-style error.
94
- return False, str(exc)
95
-
96
-
97
- def _stamp() -> str:
98
- return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
99
-
100
-
101
- def _resolve_yaml_sources(project: Path, install_mod) -> dict[str, Path]:
102
- """Map ``.agent-settings.yml`` / ``.agent-user.yml`` to their on-disk
103
- source — typed ``settings/`` subdir wins over the legacy flat path."""
104
- out: dict[str, Path] = {}
105
- for name in LEGACY_YAML_FILES:
106
- candidate_typed = project / "settings" / name
107
- candidate_flat = project / name
108
- if candidate_typed.is_file():
109
- out[name] = candidate_typed
110
- elif candidate_flat.is_file():
111
- out[name] = candidate_flat
112
- return out
113
-
114
-
115
- def _yaml_destination(install_mod, name: str) -> Path:
116
- if name == ".agent-settings.yml":
117
- return install_mod.GLOBAL_AGENT_SETTINGS_PATH
118
- if name == ".agent-user.yml":
119
- return install_mod.GLOBAL_USER_SETTINGS_PATH
120
- raise ValueError(f"unknown YAML target: {name}")
121
-
122
-
123
- def _copy_yaml(src: Path, dst: Path) -> None:
124
- dst.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
125
- shutil.copy2(src, dst)
126
- try:
127
- os.chmod(dst, 0o600)
128
- except OSError:
129
- pass
130
-
131
-
132
- def _verify_yaml(path: Path) -> tuple[bool, str]:
133
- """Verify the global YAML copy: exists, parses, mode ``0600``."""
134
- if not path.is_file():
135
- return False, f"missing after copy: {path}"
136
- ok, err = _parse_yaml(path)
137
- if not ok:
138
- return False, f"reparse failed: {err}"
139
- try:
140
- mode = path.stat().st_mode & 0o777
141
- except OSError as exc:
142
- return False, f"stat failed: {exc}"
143
- if mode != 0o600:
144
- return False, f"mode {oct(mode)} (expected 0o600)"
145
- return True, ""
146
-
147
-
148
- def _move_into_snapshot(src: Path, snapshot_root: Path, project: Path) -> Path:
149
- """Move ``src`` under ``snapshot_root`` preserving project-relative layout.
150
- Returns the new path inside the snapshot."""
151
- rel = src.relative_to(project)
152
- dst = snapshot_root / rel
153
- dst.parent.mkdir(parents=True, exist_ok=True)
154
- shutil.move(str(src), str(dst))
155
- return dst
156
-
157
-
158
-
159
- def _run_perms_gate(out) -> int:
160
- """Run the Phase 5.0 entry-gate; return ``lint`` exit code."""
161
- lgp = _import_lint_global_paths()
162
- return lgp.lint(lgp.DEFAULT_POLICY, quiet=True)
163
-
164
-
165
- def _build_plan(project: Path, install_mod) -> dict:
166
- """Return a plan describing every detected legacy artefact."""
167
- yaml_sources = _resolve_yaml_sources(project, install_mod)
168
- yaml_plan: list[dict] = []
169
- for name, src in yaml_sources.items():
170
- dst = _yaml_destination(install_mod, name)
171
- yaml_plan.append({
172
- "name": name,
173
- "src": str(src),
174
- "dst": str(dst),
175
- "global_existed_non_empty": _is_non_empty(dst),
176
- })
177
- dir_plan: list[dict] = []
178
- for name in LEGACY_TOOL_DIRS:
179
- p = project / name
180
- if p.is_dir() and not p.is_symlink():
181
- dir_plan.append({"name": name, "src": str(p)})
182
- return {"yaml": yaml_plan, "dirs": dir_plan}
183
-
184
-
185
- def _format_plan(plan: dict, dry_run: bool, out) -> None:
186
- n_yaml = len(plan["yaml"])
187
- n_dirs = len(plan["dirs"])
188
- if n_yaml + n_dirs == 0:
189
- print("✅ nothing to migrate — no legacy artefacts detected.", file=out)
190
- return
191
- verb = "would migrate" if dry_run else "migrating"
192
- print(f"📦 {verb} {n_yaml} YAML file(s) + {n_dirs} directory(ies):", file=out)
193
- for entry in plan["yaml"]:
194
- flag = " (would overwrite)" if entry["global_existed_non_empty"] else ""
195
- print(f" - copy {entry['src']} → {entry['dst']}{flag}", file=out)
196
- for entry in plan["dirs"]:
197
- print(f" - move {entry['src']} → snapshot", file=out)
198
-
199
-
200
- def _do_migrate(project: Path, force: bool, install_mod, out) -> int:
201
- plan = _build_plan(project, install_mod)
202
- if not plan["yaml"] and not plan["dirs"]:
203
- print("✅ nothing to migrate — no legacy artefacts detected.", file=out)
204
- return 0
205
-
206
- for entry in plan["yaml"]:
207
- if entry["global_existed_non_empty"] and not force:
208
- print(f"❌ {entry['dst']} is non-empty — pass --force to overwrite.",
209
- file=sys.stderr)
210
- return 1
211
-
212
- for entry in plan["yaml"]:
213
- ok, err = _parse_yaml(Path(entry["src"]))
214
- if not ok:
215
- print(f"❌ {entry['src']}: cannot parse as YAML: {err}",
216
- file=sys.stderr)
217
- return 1
218
-
219
- # COPY
220
- copied: list[tuple[Path, Path]] = []
221
- for entry in plan["yaml"]:
222
- src, dst = Path(entry["src"]), Path(entry["dst"])
223
- _copy_yaml(src, dst)
224
- copied.append((src, dst))
225
-
226
- # VERIFY
227
- for _src, dst in copied:
228
- ok, err = _verify_yaml(dst)
229
- if not ok:
230
- print(f"❌ verify failed for {dst}: {err}", file=sys.stderr)
231
- print(" aborting migration — local originals untouched.",
232
- file=sys.stderr)
233
- return 1
234
-
235
- # MOVE — only after verify passes.
236
- snapshot_root = project / SNAPSHOT_DIRNAME / _stamp()
237
- snapshot_root.mkdir(parents=True, exist_ok=True)
238
- moved_yaml: list[tuple[str, str]] = []
239
- moved_dirs: list[tuple[str, str]] = []
240
- for entry in plan["yaml"]:
241
- src = Path(entry["src"])
242
- if src.is_file():
243
- dst_snap = _move_into_snapshot(src, snapshot_root, project)
244
- moved_yaml.append((str(src), str(dst_snap)))
245
- for entry in plan["dirs"]:
246
- src = Path(entry["src"])
247
- if src.is_dir():
248
- dst_snap = _move_into_snapshot(src, snapshot_root, project)
249
- moved_dirs.append((str(src), str(dst_snap)))
250
-
251
- manifest = {
252
- "schema": "event4u-migrate-snapshot/v1",
253
- "stamp": _stamp(),
254
- "project_root": str(project),
255
- "global_root": str(install_mod.GLOBAL_ROOT),
256
- "moved_yaml": moved_yaml,
257
- "moved_dirs": moved_dirs,
258
- "global_copies": [str(dst) for _src, dst in copied],
259
- }
260
- (snapshot_root / MANIFEST_NAME).write_text(
261
- json.dumps(manifest, indent=2) + "\n", encoding="utf-8",
262
- )
263
-
264
- # BRIDGE (last)
265
- version = _resolve_installed_version(install_mod)
266
- marker = install_mod._write_consumer_bridge_marker(project, version)
267
-
268
- print(f"✅ migrated — snapshot at {snapshot_root}", file=out)
269
- for src, dst in copied:
270
- print(f" - copied {src} → {dst}", file=out)
271
- for src, dst in moved_yaml:
272
- print(f" - moved {src} → {dst}", file=out)
273
- for src, dst in moved_dirs:
274
- print(f" - moved {src} → {dst}", file=out)
275
- if marker is not None:
276
- print(f" - bridge {marker}", file=out)
277
- return 0
278
-
279
-
280
-
281
- def _find_latest_snapshot(project: Path) -> Optional[Path]:
282
- root = project / SNAPSHOT_DIRNAME
283
- if not root.is_dir():
284
- return None
285
- stamps = sorted(
286
- (p for p in root.iterdir() if p.is_dir() and (p / MANIFEST_NAME).is_file()),
287
- key=lambda p: p.name,
288
- )
289
- return stamps[-1] if stamps else None
290
-
291
-
292
- def _do_rollback(project: Path, dry_run: bool, install_mod, out) -> int:
293
- snapshot = _find_latest_snapshot(project)
294
- if snapshot is None:
295
- print(f"❌ no snapshot under {project / SNAPSHOT_DIRNAME} — nothing to roll back.",
296
- file=sys.stderr)
297
- return 1
298
- try:
299
- manifest = json.loads((snapshot / MANIFEST_NAME).read_text(encoding="utf-8"))
300
- except (OSError, json.JSONDecodeError) as exc:
301
- print(f"❌ cannot read manifest {snapshot / MANIFEST_NAME}: {exc}",
302
- file=sys.stderr)
303
- return 1
304
-
305
- moved_yaml = manifest.get("moved_yaml", [])
306
- moved_dirs = manifest.get("moved_dirs", [])
307
- global_copies = manifest.get("global_copies", [])
308
-
309
- if dry_run:
310
- print(f"🔁 would roll back from {snapshot}", file=out)
311
- for original, snap in moved_yaml + moved_dirs:
312
- print(f" - restore {snap} → {original}", file=out)
313
- for path in global_copies:
314
- print(f" - delete {path}", file=out)
315
- print(f" - remove {_consumer_bridge_marker_abs(project, install_mod)}", file=out)
316
- return 0
317
-
318
- # Pre-flight: every restore target must be vacant.
319
- for original, _snap in moved_yaml + moved_dirs:
320
- if Path(original).exists():
321
- print(f"❌ restore target already exists: {original}", file=sys.stderr)
322
- print(" aborting — manual cleanup required.", file=sys.stderr)
323
- return 1
324
-
325
- # Restore moved originals.
326
- for original, snap in moved_yaml + moved_dirs:
327
- src, dst = Path(snap), Path(original)
328
- dst.parent.mkdir(parents=True, exist_ok=True)
329
- shutil.move(str(src), str(dst))
330
-
331
- # Delete global copies this migration created.
332
- for path in global_copies:
333
- p = Path(path)
334
- try:
335
- if p.is_file():
336
- p.unlink()
337
- except OSError as exc:
338
- print(f"⚠️ could not delete {p}: {exc}", file=sys.stderr)
339
-
340
- # Drop the bridge marker.
341
- marker = _consumer_bridge_marker_abs(project, install_mod)
342
- try:
343
- if marker.is_file():
344
- marker.unlink()
345
- except OSError as exc:
346
- print(f"⚠️ could not remove bridge marker {marker}: {exc}", file=sys.stderr)
347
-
348
- # Archive the consumed snapshot directory so a second rollback cleanly
349
- # surfaces "no snapshot found" rather than re-restoring stale data.
350
- consumed = snapshot.with_name(snapshot.name + ".consumed")
351
- try:
352
- snapshot.rename(consumed)
353
- except OSError:
354
- pass
355
-
356
- print(f"✅ rolled back — originals restored, global copies removed.", file=out)
357
- return 0
358
-
359
-
360
- def main(argv: Optional[list[str]] = None) -> int:
361
- parser = argparse.ArgumentParser(
362
- prog="agent-config migrate-to-global",
363
- description="Lift legacy project-local config into ~/.event4u/agent-config/.",
364
- )
365
- parser.add_argument(
366
- "--from", dest="project", type=Path, default=Path.cwd(),
367
- help="Project root to migrate (default: cwd).",
368
- )
369
- parser.add_argument(
370
- "--dry-run", action="store_true",
371
- help="Print the plan without touching disk.",
372
- )
373
- parser.add_argument(
374
- "--force", action="store_true",
375
- help="Overwrite non-empty global files.",
376
- )
377
- parser.add_argument(
378
- "--rollback", action="store_true",
379
- help="Reverse the latest snapshot under .legacy-pre-global-only/.",
380
- )
381
- parser.add_argument(
382
- "--skip-perms-gate", action="store_true",
383
- help="Skip the Phase 5.0 permissions audit (NOT recommended).",
384
- )
385
- args = parser.parse_args(argv)
386
-
387
- project = args.project.resolve()
388
- if not project.is_dir():
389
- print(f"❌ not a directory: {project}", file=sys.stderr)
390
- return 2
391
-
392
- install_mod = _import_install()
393
- out = sys.stdout
394
-
395
- if args.rollback:
396
- return _do_rollback(project, args.dry_run, install_mod, out)
397
-
398
- if not args.skip_perms_gate:
399
- rc = _run_perms_gate(out)
400
- if rc != 0:
401
- print("❌ permissions audit failed — refusing to migrate.", file=sys.stderr)
402
- print(" run `agent-config doctor` (or `python3 scripts/lint_global_paths.py`) for details.",
403
- file=sys.stderr)
404
- return rc
405
-
406
- if args.dry_run:
407
- plan = _build_plan(project, install_mod)
408
- _format_plan(plan, dry_run=True, out=out)
409
- return 0
410
-
411
- return _do_migrate(project, args.force, install_mod, out)
412
-
413
-
414
- if __name__ == "__main__": # pragma: no cover
415
- sys.exit(main())