@event4u/agent-config 3.0.0 → 3.1.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 (207) hide show
  1. package/.agent-src/commands/install-via-agent.md +129 -0
  2. package/.agent-src/commands/video/from-script.md +1 -1
  3. package/.agent-src/commands/video.md +1 -1
  4. package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
  5. package/.agent-src/rules/caveman-speak.md +2 -2
  6. package/.agent-src/rules/context-hygiene.md +36 -0
  7. package/.agent-src/rules/engineering-safety-floor.md +102 -0
  8. package/.agent-src/rules/finance-safety-floor.md +114 -0
  9. package/.agent-src/rules/git-history-discipline.md +1 -1
  10. package/.agent-src/rules/no-cheap-questions.md +34 -32
  11. package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
  12. package/.agent-src/rules/strategy-safety-floor.md +114 -0
  13. package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
  14. package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
  15. package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
  16. package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
  17. package/.agent-src/skills/readme-writing/SKILL.md +52 -4
  18. package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
  19. package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
  20. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
  22. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
  23. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
  24. package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
  25. package/.claude-plugin/marketplace.json +2 -1
  26. package/AGENTS.md +10 -8
  27. package/CHANGELOG.md +223 -125
  28. package/README.md +165 -553
  29. package/config/agent-settings.template.yml +0 -7
  30. package/config/discovery/packs.yml +20 -0
  31. package/config/discovery/unassigned-artefacts.yml +2 -0
  32. package/config/gitignore-block.txt +19 -3
  33. package/dist/cli/commands/uiServe.js +13 -4
  34. package/dist/cli/commands/uiServe.js.map +1 -1
  35. package/dist/cli/registry.js +2 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/discovery/deprecation-report.md +7 -0
  38. package/dist/discovery/discovery-manifest.json +2107 -1409
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +9 -9
  41. package/dist/discovery/orphan-report.md +10 -0
  42. package/dist/discovery/packs.json +1002 -0
  43. package/dist/discovery/trust-report.md +26 -0
  44. package/dist/discovery/workspaces.json +705 -0
  45. package/dist/mcp/registry-manifest.json +4 -4
  46. package/dist/router.json +1623 -0
  47. package/dist/server/app.js +11 -3
  48. package/dist/server/app.js.map +1 -1
  49. package/dist/server/io/atomicMultiWrite.js +3 -1
  50. package/dist/server/io/atomicMultiWrite.js.map +1 -1
  51. package/dist/server/io/yamlIO.js +22 -0
  52. package/dist/server/io/yamlIO.js.map +1 -1
  53. package/dist/server/routes/ping.js +8 -0
  54. package/dist/server/routes/ping.js.map +1 -1
  55. package/dist/server/routes/schema.js +2 -2
  56. package/dist/server/routes/schema.js.map +1 -1
  57. package/dist/server/routes/settings.js +104 -23
  58. package/dist/server/routes/settings.js.map +1 -1
  59. package/dist/server/routes/userMd.js +37 -27
  60. package/dist/server/routes/userMd.js.map +1 -1
  61. package/dist/server/routes/wizard.js +256 -20
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +0 -1
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/dist/server/token.js +10 -3
  66. package/dist/server/token.js.map +1 -1
  67. package/dist/server/writeRoot.js +28 -11
  68. package/dist/server/writeRoot.js.map +1 -1
  69. package/dist/server/writeRoot.test.js +22 -4
  70. package/dist/server/writeRoot.test.js.map +1 -1
  71. package/dist/shared/userMd/formAdapter.js +29 -51
  72. package/dist/shared/userMd/formAdapter.js.map +1 -1
  73. package/dist/shared/userMd/schema.js +32 -104
  74. package/dist/shared/userMd/schema.js.map +1 -1
  75. package/dist/shared/userMd/utils.js +64 -50
  76. package/dist/shared/userMd/utils.js.map +1 -1
  77. package/dist/ui/assets/index-D-DY1ywI.js +35 -0
  78. package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
  79. package/dist/ui/index.html +1 -1
  80. package/docs/adrs/router/0001-three-tier-routing.md +5 -5
  81. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
  82. package/docs/architecture.md +3 -3
  83. package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
  84. package/docs/catalog.md +30 -26
  85. package/docs/contracts/CHANGELOG-conventions.md +1 -1
  86. package/docs/contracts/agent-user-schema.md +6 -9
  87. package/docs/contracts/consumer-bridge.md +79 -0
  88. package/docs/contracts/discovery-manifest.md +209 -0
  89. package/docs/contracts/discovery-manifest.schema.json +77 -4
  90. package/docs/contracts/explain-trace.schema.json +1 -1
  91. package/docs/contracts/file-ownership-matrix.json +197 -13
  92. package/docs/contracts/frontmatter-contract.md +140 -0
  93. package/docs/contracts/gui-wizard.md +223 -0
  94. package/docs/contracts/installer-agent-mode.md +137 -0
  95. package/docs/contracts/kernel-membership.md +1 -1
  96. package/docs/contracts/mcp-tool-inventory.md +9 -9
  97. package/docs/contracts/namespace.md +6 -6
  98. package/docs/contracts/provider-lifecycle.md +5 -5
  99. package/docs/contracts/rule-router.md +4 -4
  100. package/docs/contracts/settings-api.md +53 -6
  101. package/docs/contracts/smoke-contracts.md +3 -3
  102. package/docs/contracts/trust-and-safety.md +144 -0
  103. package/docs/customization.md +2 -2
  104. package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
  105. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
  106. package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
  107. package/docs/decisions/ADR-016-installer-architecture.md +189 -0
  108. package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
  109. package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
  110. package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
  111. package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
  112. package/docs/decisions/ADR-021-deployment-shape.md +153 -0
  113. package/docs/decisions/INDEX.md +7 -0
  114. package/docs/deploy/connector-setup.md +129 -0
  115. package/docs/deploy/env-vars.md +70 -0
  116. package/docs/deploy/policy-cookbook.md +130 -0
  117. package/docs/deploy/quickstart.md +112 -0
  118. package/docs/distribution/public-install-smoke.md +68 -0
  119. package/docs/distribution/registries.md +55 -0
  120. package/docs/distribution/telemetry-privacy.md +128 -0
  121. package/docs/distribution/telemetry-schema.md +174 -0
  122. package/docs/featured-skills.md +95 -0
  123. package/docs/getting-started-by-role.md +19 -1
  124. package/docs/getting-started.md +2 -2
  125. package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
  126. package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
  127. package/docs/installation.md +27 -14
  128. package/docs/maintainers/dev-mode.md +105 -0
  129. package/docs/setup/per-ide/claude-desktop.md +3 -2
  130. package/docs/wizard.md +39 -4
  131. package/package.json +18 -1
  132. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  133. package/scripts/_cli/cmd_doctor.py +150 -2
  134. package/scripts/_cli/cmd_explain.py +2 -1
  135. package/scripts/_cli/cmd_migrate_to_global.py +415 -0
  136. package/scripts/_cli/cmd_settings_migrate.py +146 -0
  137. package/scripts/_cli/explain_last/route.py +2 -1
  138. package/scripts/_dispatch.bash +36 -3
  139. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  141. package/scripts/_lib/agent_settings.py +4 -1
  142. package/scripts/_lib/agent_src.py +157 -0
  143. package/scripts/agent-config +17 -6
  144. package/scripts/audit_skill_descriptions.py +18 -6
  145. package/scripts/build_discovery_manifest.py +373 -17
  146. package/scripts/check_artefact_checksums.py +104 -0
  147. package/scripts/check_cluster_patterns.py +20 -4
  148. package/scripts/check_command_count_messaging.py +33 -14
  149. package/scripts/check_council_references.py +43 -4
  150. package/scripts/check_overlay_cascade_subdirs.py +7 -3
  151. package/scripts/check_references.py +5 -2
  152. package/scripts/check_reply_consistency.py +32 -9
  153. package/scripts/check_template_pin_drift.py +24 -7
  154. package/scripts/check_token_optimizer_freshness.py +18 -3
  155. package/scripts/compile_router.py +34 -2
  156. package/scripts/compress.py +162 -44
  157. package/scripts/config/presets.py +19 -1
  158. package/scripts/config/profiles.py +16 -1
  159. package/scripts/discovery_stats.py +70 -0
  160. package/scripts/expected_perms.json +47 -0
  161. package/scripts/generate_index.py +78 -46
  162. package/scripts/generate_ownership_matrix.py +98 -43
  163. package/scripts/generate_pack_manifests.py +183 -0
  164. package/scripts/install +18 -1
  165. package/scripts/install.py +934 -59
  166. package/scripts/install.sh +27 -9
  167. package/scripts/lint_agents_layout.py +93 -13
  168. package/scripts/lint_agents_md.py +1 -1
  169. package/scripts/lint_archived_skills.py +32 -16
  170. package/scripts/lint_bench_corpus.py +14 -2
  171. package/scripts/lint_command_tiers.py +15 -2
  172. package/scripts/lint_featured_skills.py +139 -0
  173. package/scripts/lint_framework_leakage.py +33 -6
  174. package/scripts/lint_global_paths.py +147 -0
  175. package/scripts/lint_orchestration_dsl.py +6 -3
  176. package/scripts/lint_pack_boundaries.py +147 -0
  177. package/scripts/lint_pack_first_win.py +103 -0
  178. package/scripts/lint_readme_jargon.py +131 -0
  179. package/scripts/lint_readme_size.py +33 -0
  180. package/scripts/lint_rule_interactions.py +23 -5
  181. package/scripts/lint_rule_tiers.py +12 -3
  182. package/scripts/lint_trust_coherence.py +212 -0
  183. package/scripts/measure_rule_budget.py +22 -4
  184. package/scripts/move_artefact.py +143 -0
  185. package/scripts/new_skill.py +148 -0
  186. package/scripts/plan_physical_move.py +353 -0
  187. package/scripts/refine_ticket_detect.py +30 -7
  188. package/scripts/schemas/command.schema.json +4 -0
  189. package/scripts/skill_linter.py +248 -118
  190. package/scripts/skill_trigger_eval.py +28 -8
  191. package/scripts/smoke/kernel.sh +1 -1
  192. package/scripts/smoke/router.sh +24 -5
  193. package/scripts/smoke/skills.sh +15 -7
  194. package/scripts/smoke_quickstart.py +11 -2
  195. package/scripts/snapshot_agent_outputs.py +144 -0
  196. package/scripts/update_counts.py +45 -17
  197. package/scripts/validate_decision_engine.py +9 -1
  198. package/scripts/validate_discovery_manifest.py +94 -0
  199. package/scripts/validate_frontmatter.py +39 -20
  200. package/scripts/verify_physical_move.py +185 -0
  201. package/templates/agent-user.md +0 -1
  202. package/templates/agent-user.yml +21 -0
  203. package/templates/minimal/agents-overrides-readme.md +46 -0
  204. package/templates/minimal/overrides-gitkeep +2 -0
  205. package/dist/ui/assets/index-BTRcKDlB.js +0 -39
  206. package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
  207. package/templates/minimal/agents-gitkeep +0 -2
@@ -0,0 +1,415 @@
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())
@@ -0,0 +1,146 @@
1
+ """``agent-config settings:migrate`` — lift project-local settings into the global store.
2
+
3
+ Phase 2.4 of ``agents/roadmaps/road-to-global-only-install.md``. Copies
4
+ an existing project-local ``.agent-settings.yml`` / ``.agent-user.yml``
5
+ into ``~/.event4u/agent-config/`` so the global-only consumer surface
6
+ (ADR-020) can take over. Read-only on the source — the destructive
7
+ ``move`` step is owned by the Phase 5 ``migrate-to-global`` subcommand.
8
+
9
+ Idempotent — refuses to overwrite a non-empty global file without
10
+ ``--force``. ``--dry-run`` lists intended copies; zero writes; exit 0.
11
+
12
+ Exit codes:
13
+
14
+ * ``0`` — success or no-op (nothing to migrate / already migrated).
15
+ * ``1`` — at least one global file is non-empty and ``--force`` was
16
+ not passed, or a source file failed YAML parse.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import os
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+
28
+ def _import_install():
29
+ """Lazy import so ``--help`` works without the package on sys.path."""
30
+ here = Path(__file__).resolve().parents[2]
31
+ if str(here) not in sys.path:
32
+ sys.path.insert(0, str(here))
33
+ from scripts import install as install_mod # noqa: PLC0415
34
+ return install_mod
35
+
36
+
37
+ def _is_non_empty_yaml(path: Path) -> bool:
38
+ """Return True when the file exists and has non-whitespace content."""
39
+ if not path.is_file():
40
+ return False
41
+ try:
42
+ text = path.read_text(encoding="utf-8")
43
+ except OSError:
44
+ return False
45
+ return text.strip() != ""
46
+
47
+
48
+ def _parse_yaml_or_fail(path: Path, out) -> bool:
49
+ """Soft-parse a YAML file; print the error and return False on failure."""
50
+ try:
51
+ import yaml # type: ignore[import-not-found]
52
+ except ImportError:
53
+ return True # No PyYAML — defer the validation to the consumer.
54
+ try:
55
+ text = path.read_text(encoding="utf-8")
56
+ yaml.safe_load(text)
57
+ return True
58
+ except (OSError, yaml.YAMLError) as exc:
59
+ print(f"❌ {path}: cannot parse as YAML: {exc}", file=out)
60
+ return False
61
+
62
+
63
+ def _copy(src: Path, dst: Path, *, dry_run: bool, out) -> str:
64
+ """Copy `src` to `dst` with mode 0600. Returns a one-line summary."""
65
+ if dry_run:
66
+ return f"would copy {src} → {dst}"
67
+ dst.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
68
+ shutil.copy2(src, dst)
69
+ try:
70
+ os.chmod(dst, 0o600)
71
+ except OSError:
72
+ pass
73
+ return f"copied {src} → {dst}"
74
+
75
+
76
+ def main(argv: Optional[list[str]] = None) -> int:
77
+ parser = argparse.ArgumentParser(
78
+ prog="agent-config settings:migrate",
79
+ description=(
80
+ "Lift project-local .agent-settings.yml / .agent-user.yml into "
81
+ "~/.event4u/agent-config/ (the global-only consumer surface)."
82
+ ),
83
+ )
84
+ parser.add_argument("--from", dest="from_dir", default=None,
85
+ help="project root to read from (default: cwd)")
86
+ parser.add_argument("--force", action="store_true",
87
+ help="overwrite a non-empty global file")
88
+ parser.add_argument("--dry-run", action="store_true",
89
+ help="list intended copies; zero writes; exit 0")
90
+ opts = parser.parse_args(argv)
91
+
92
+ install_mod = _import_install()
93
+ project = Path(opts.from_dir).resolve() if opts.from_dir else Path.cwd()
94
+
95
+ # Source candidates — typed subdir wins over the legacy flat path.
96
+ src_settings = project / "settings" / install_mod.SETTINGS_FILE
97
+ if not src_settings.is_file():
98
+ src_settings = project / install_mod.SETTINGS_FILE
99
+ src_user = project / "settings" / ".agent-user.yml"
100
+ if not src_user.is_file():
101
+ src_user = project / ".agent-user.yml"
102
+
103
+ dst_settings = install_mod.GLOBAL_AGENT_SETTINGS_PATH
104
+ dst_user = install_mod.GLOBAL_USER_SETTINGS_PATH
105
+
106
+ plan: list[tuple[Path, Path]] = []
107
+ skipped: list[str] = []
108
+
109
+ for src, dst, label in (
110
+ (src_settings, dst_settings, "settings"),
111
+ (src_user, dst_user, "user"),
112
+ ):
113
+ if not src.is_file():
114
+ skipped.append(f"{label}: source absent ({src})")
115
+ continue
116
+ if _is_non_empty_yaml(dst) and not opts.force:
117
+ print(f"❌ {dst} is non-empty — pass --force to overwrite.",
118
+ file=sys.stderr)
119
+ return 1
120
+ if not _parse_yaml_or_fail(src, sys.stderr):
121
+ return 1
122
+ plan.append((src, dst))
123
+
124
+ if not plan:
125
+ print("✅ nothing to migrate — no project-local settings detected.")
126
+ for line in skipped:
127
+ print(f" - {line}")
128
+ return 0
129
+
130
+ summary: list[str] = []
131
+ for src, dst in plan:
132
+ summary.append(_copy(src, dst, dry_run=opts.dry_run, out=sys.stdout))
133
+
134
+ verb = "would migrate" if opts.dry_run else "migrated"
135
+ print(f"✅ {verb} {len(plan)} file(s):")
136
+ for line in summary:
137
+ print(f" - {line}")
138
+ for line in skipped:
139
+ print(f" - {line}")
140
+ if opts.dry_run:
141
+ print("\n Re-run without --dry-run to apply.")
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__": # pragma: no cover
146
+ sys.exit(main())
@@ -14,10 +14,11 @@ from typing import Any
14
14
  from scripts._cli.explain_last.scrubber import scrub_string
15
15
 
16
16
  ROUTER_FILENAME = "router.json"
17
+ ROUTER_RELATIVE = Path("dist") / ROUTER_FILENAME
17
18
 
18
19
 
19
20
  def _load_router(project_root: Path) -> dict[str, Any] | None:
20
- path = project_root / ROUTER_FILENAME
21
+ path = project_root / ROUTER_RELATIVE
21
22
  if not path.exists():
22
23
  return None
23
24
  try:
@@ -134,7 +134,16 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
134
134
  settings:check Validate .agent-settings.yml against the YAML-subset contract
135
135
  (docs/contracts/settings-sync-yaml-subset.md). Read-only.
136
136
  Exit 0 clean, 1 finding(s), 2 file absent / unreadable.
137
- hooks:install Install the pre-commit roadmap-progress hook
137
+ settings:migrate Lift project-local .agent-settings.yml / .agent-user.yml into
138
+ ~/.event4u/agent-config/ (the global-only consumer surface,
139
+ ADR-020). Idempotent; --force overwrites a non-empty global
140
+ file, --dry-run lists intended copies with zero writes.
141
+ migrate-to-global One-shot legacy → global-only migration (Phase 5,
142
+ road-to-global-only-install.md). Copy → verify → move →
143
+ bridge. Runs lint_global_paths.py first. Flags:
144
+ --dry-run, --force, --rollback, --skip-perms-gate.
145
+ hooks:install Install the combined pre-commit hook (roadmap-progress
146
+ + ADR-013 artefact frontmatter lint).
138
147
  (use --print to dump it, --force to overwrite an existing hook)
139
148
  hooks:status Print the runtime hook matrix (per-platform install + bindings)
140
149
  Flags: --format json|table, --strict (CI), --project-root <path>
@@ -557,14 +566,16 @@ cmd_hooks_install() {
557
566
  --print) print_only=true ;;
558
567
  -h|--help)
559
568
  cat <<'HELP'
560
- agent-config hooks:install — install the pre-commit roadmap-progress hook.
569
+ agent-config hooks:install — install the combined pre-commit hook
570
+ (roadmap-progress + ADR-013 artefact frontmatter lint).
561
571
 
562
572
  Usage:
563
573
  ./agent-config hooks:install [--force] [--print]
564
574
 
565
575
  Without flags: copies the hook to .git/hooks/pre-commit. Refuses to
566
576
  overwrite an existing pre-commit hook unless --force is given (the
567
- existing hook may already chain other tooling).
577
+ existing hook may already chain other tooling). Each concern only
578
+ runs when relevant files are staged — zero overhead otherwise.
568
579
 
569
580
  --print dump the hook script to stdout (for manual chaining into an
570
581
  existing pre-commit script, husky, lefthook, etc.)
@@ -717,6 +728,26 @@ cmd_settings_check() {
717
728
  exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_settings_check "$@"
718
729
  }
719
730
 
731
+ # `agent-config settings:migrate` — lift project-local
732
+ # .agent-settings.yml / .agent-user.yml into ~/.event4u/agent-config/.
733
+ # Phase 2.4 of road-to-global-only-install.md. Read-only on the source —
734
+ # the destructive move step is owned by `migrate-to-global` (Phase 5).
735
+ # Exit 0 success / no-op, 1 non-empty global without --force or parse error.
736
+ cmd_settings_migrate() {
737
+ require_python3
738
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_settings_migrate "$@"
739
+ }
740
+
741
+ # `agent-config migrate-to-global` — Phase 5.1 + 5.3 + 5.5 of
742
+ # road-to-global-only-install.md. Order: copy → verify → move → bridge.
743
+ # Runs the lint_global_paths.py permissions gate first (Phase 5.0 / A7).
744
+ # Flags: --dry-run (zero writes), --force (overwrite non-empty global),
745
+ # --rollback (reverse the latest .legacy-pre-global-only/<stamp>/ snapshot).
746
+ cmd_migrate_to_global() {
747
+ require_python3
748
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_migrate_to_global "$@"
749
+ }
750
+
720
751
  # `agent-config uninstall` — remove bridge markers (project) or lockfile
721
752
  # entries (global). Idempotent. Pass `--purge` to also delete deployed
722
753
  # content directories under user-scope anchors (destructive). See
@@ -809,6 +840,8 @@ main() {
809
840
  sync) cmd_sync "$@" ;;
810
841
  validate) cmd_validate "$@" ;;
811
842
  settings:check) cmd_settings_check "$@" ;;
843
+ settings:migrate) cmd_settings_migrate "$@" ;;
844
+ migrate-to-global) cmd_migrate_to_global "$@" ;;
812
845
  uninstall) cmd_uninstall "$@" ;;
813
846
  prune) cmd_prune "$@" ;;
814
847
  doctor) cmd_doctor "$@" ;;
@@ -99,11 +99,14 @@ ANCHOR_GIT = "git"
99
99
 
100
100
  #: Marker subpaths that qualify a bare ``agents/`` directory as a project
101
101
  #: anchor (D1). Any one is sufficient. Bare ``agents/`` without a marker
102
- #: is **not** an anchor.
102
+ #: is **not** an anchor. ``.event4u-bridge.yml`` is the global-only
103
+ #: consumer anchor (ADR-020 § Phase 4.2) — a clean consumer repo only
104
+ #: ever ships ``agents/overrides/`` plus this marker.
103
105
  _AGENTS_DIR_MARKERS: tuple[str, ...] = (
104
106
  "roadmaps",
105
107
  "settings/.ai-council.yml",
106
108
  "roadmaps-progress.md",
109
+ ".event4u-bridge.yml",
107
110
  )
108
111
 
109
112
  #: Kill-switch (D5). When set to ``"1"``, :func:`find_project_root` and