@event4u/agent-config 5.6.1 → 5.8.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 (225) hide show
  1. package/.agent-src/commands/agent-handoff.md +1 -1
  2. package/.agent-src/commands/agent-status.md +1 -1
  3. package/.agent-src/commands/agents/audit.md +1 -1
  4. package/.agent-src/commands/agents/init.md +1 -1
  5. package/.agent-src/commands/agents/user/accept.md +3 -3
  6. package/.agent-src/commands/agents/user/init.md +4 -4
  7. package/.agent-src/commands/agents/user/show.md +3 -3
  8. package/.agent-src/commands/agents/user/update.md +3 -3
  9. package/.agent-src/commands/agents/user.md +1 -1
  10. package/.agent-src/commands/agents.md +1 -1
  11. package/.agent-src/commands/analytics/prune.md +1 -1
  12. package/.agent-src/commands/analytics/show.md +1 -1
  13. package/.agent-src/commands/analytics.md +1 -1
  14. package/.agent-src/commands/bug-fix.md +1 -1
  15. package/.agent-src/commands/challenge-me.md +1 -1
  16. package/.agent-src/commands/chat-history/import.md +1 -1
  17. package/.agent-src/commands/chat-history/learn.md +1 -1
  18. package/.agent-src/commands/chat-history/show.md +1 -1
  19. package/.agent-src/commands/chat-history.md +1 -1
  20. package/.agent-src/commands/check-current-md.md +1 -1
  21. package/.agent-src/commands/condense.md +1 -1
  22. package/.agent-src/commands/context.md +1 -1
  23. package/.agent-src/commands/cost-report.md +13 -8
  24. package/.agent-src/commands/council.md +3 -3
  25. package/.agent-src/commands/create-pr/description-only.md +1 -1
  26. package/.agent-src/commands/create-pr.md +1 -1
  27. package/.agent-src/commands/e2e-heal.md +1 -1
  28. package/.agent-src/commands/e2e-plan.md +1 -1
  29. package/.agent-src/commands/feature.md +1 -1
  30. package/.agent-src/commands/fix/ci.md +1 -1
  31. package/.agent-src/commands/fix/portability.md +1 -1
  32. package/.agent-src/commands/fix/pr-bot-comments.md +1 -1
  33. package/.agent-src/commands/fix/pr-comments.md +1 -1
  34. package/.agent-src/commands/fix/pr-developer-comments.md +1 -1
  35. package/.agent-src/commands/fix/refs.md +1 -1
  36. package/.agent-src/commands/fix/seeder.md +1 -1
  37. package/.agent-src/commands/fix.md +1 -1
  38. package/.agent-src/commands/judge.md +1 -1
  39. package/.agent-src/commands/knowledge/cross-repo.md +1 -1
  40. package/.agent-src/commands/knowledge/forget.md +1 -1
  41. package/.agent-src/commands/knowledge/ingest.md +1 -1
  42. package/.agent-src/commands/knowledge/list.md +1 -1
  43. package/.agent-src/commands/knowledge.md +1 -1
  44. package/.agent-src/commands/memory/add.md +1 -1
  45. package/.agent-src/commands/memory/learn-low-impact.md +1 -1
  46. package/.agent-src/commands/memory/load.md +1 -1
  47. package/.agent-src/commands/memory/mine-session.md +1 -1
  48. package/.agent-src/commands/memory/promote.md +1 -1
  49. package/.agent-src/commands/memory/propose.md +1 -1
  50. package/.agent-src/commands/memory.md +1 -1
  51. package/.agent-src/commands/mode.md +1 -1
  52. package/.agent-src/commands/optimize/agents-dir.md +1 -1
  53. package/.agent-src/commands/optimize/augmentignore.md +1 -1
  54. package/.agent-src/commands/optimize/rtk.md +1 -1
  55. package/.agent-src/commands/optimize/skills.md +1 -1
  56. package/.agent-src/commands/optimize.md +1 -1
  57. package/.agent-src/commands/orchestrate.md +1 -1
  58. package/.agent-src/commands/override/create.md +1 -1
  59. package/.agent-src/commands/override/manage.md +1 -1
  60. package/.agent-src/commands/override.md +1 -1
  61. package/.agent-src/commands/package-reset.md +1 -1
  62. package/.agent-src/commands/prediction-pool.md +234 -0
  63. package/.agent-src/commands/profile/activate.md +81 -0
  64. package/.agent-src/commands/profile/deactivate.md +68 -0
  65. package/.agent-src/commands/profile/show.md +70 -0
  66. package/.agent-src/commands/profile.md +68 -0
  67. package/.agent-src/commands/project-health.md +1 -1
  68. package/.agent-src/commands/quality-fix.md +1 -1
  69. package/.agent-src/commands/roadmap/process-full.md +1 -1
  70. package/.agent-src/commands/roadmap/process-phase.md +1 -1
  71. package/.agent-src/commands/roadmap/process-step.md +1 -1
  72. package/.agent-src/commands/roadmap.md +1 -1
  73. package/.agent-src/commands/set-cost-profile.md +9 -9
  74. package/.agent-src/commands/skill/preview.md +3 -3
  75. package/.agent-src/commands/skill.md +1 -1
  76. package/.agent-src/commands/skills/discover.md +1 -1
  77. package/.agent-src/commands/skills.md +1 -1
  78. package/.agent-src/commands/sync-agent-settings.md +3 -3
  79. package/.agent-src/commands/sync-gitignore/fix.md +1 -1
  80. package/.agent-src/commands/sync-gitignore.md +1 -1
  81. package/.agent-src/commands/update-form-request-messages.md +1 -1
  82. package/.agent-src/presets/README.md +1 -1
  83. package/.agent-src/profiles/README.md +1 -1
  84. package/.agent-src/rules/non-destructive-by-default.md +2 -1
  85. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  86. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  87. package/.agent-src/skills/git-workflow/SKILL.md +1 -1
  88. package/.agent-src/skills/jira-integration/SKILL.md +1 -1
  89. package/.agent-src/skills/markitdown/SKILL.md +1 -1
  90. package/.agent-src/skills/prediction-pool-optimizer/SKILL.md +314 -0
  91. package/.agent-src/skills/prediction-pool-optimizer/evals/triggers.json +20 -0
  92. package/.agent-src/skills/prediction-pool-optimizer/reference/ev-fixtures.md +175 -0
  93. package/.agent-src/skills/prediction-pool-optimizer/reference/odds-and-bonus.md +109 -0
  94. package/.agent-src/skills/rtk-output-filtering/SKILL.md +1 -1
  95. package/.agent-src/skills/script-writing/SKILL.md +1 -1
  96. package/.agent-src/skills/token-optimizer/SKILL.md +1 -1
  97. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  98. package/.agent-src/templates/agent-settings.md +7 -7
  99. package/.agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  100. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +54 -6
  101. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +1 -1
  102. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +9 -7
  103. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +9 -10
  104. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +17 -4
  105. package/.claude-plugin/marketplace.json +370 -364
  106. package/CHANGELOG.md +108 -0
  107. package/README.md +2 -2
  108. package/config/agent-settings.template.yml +11 -2
  109. package/config/discovery/packs.yml +11 -0
  110. package/config/discovery/session-profiles.yml +37 -0
  111. package/config/discovery/workspaces.yml +1 -1
  112. package/config/profiles/balanced.ini +1 -1
  113. package/config/profiles/full.ini +1 -1
  114. package/config/profiles/minimal.ini +1 -1
  115. package/dist/discovery/deprecation-report.md +1 -1
  116. package/dist/discovery/discovery-manifest.json +254 -100
  117. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  118. package/dist/discovery/discovery-manifest.summary.md +4 -3
  119. package/dist/discovery/orphan-report.md +1 -1
  120. package/dist/discovery/packs.json +41 -6
  121. package/dist/discovery/trust-report.md +3 -3
  122. package/dist/discovery/workspaces.json +19 -6
  123. package/dist/mcp/registry-manifest.json +3 -3
  124. package/dist/server/io/substituteTemplate.js +3 -3
  125. package/dist/server/io/substituteTemplate.js.map +1 -1
  126. package/dist/server/routes/settings.js +2 -2
  127. package/dist/server/routes/settings.js.map +1 -1
  128. package/dist/server/schemas/settings.js +4 -2
  129. package/dist/server/schemas/settings.js.map +1 -1
  130. package/dist/ui/assets/{index-DVsyUMZe.js → index-5lFqAKL0.js} +2 -2
  131. package/dist/ui/assets/index-5lFqAKL0.js.map +1 -0
  132. package/dist/ui/index.html +1 -1
  133. package/docs/architecture/current-onboard-baseline.md +3 -3
  134. package/docs/architecture.md +2 -2
  135. package/docs/catalog.md +11 -5
  136. package/docs/contracts/adr-level-6-productization.md +1 -1
  137. package/docs/contracts/command-clusters.md +2 -0
  138. package/docs/contracts/config-presets.md +2 -2
  139. package/docs/contracts/cost-profile-defaults.md +5 -5
  140. package/docs/contracts/discovery-manifest.schema.json +1 -1
  141. package/docs/contracts/explain-trace.schema.json +3 -3
  142. package/docs/contracts/memory-visibility-v1.md +15 -7
  143. package/docs/contracts/profile-system.md +2 -2
  144. package/docs/contracts/session-profile-overlay.md +120 -0
  145. package/docs/contracts/settings-api.md +3 -3
  146. package/docs/contracts/value-report-schema.md +14 -1
  147. package/docs/customization.md +47 -5
  148. package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +47 -11
  149. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +16 -2
  150. package/docs/decisions/ADR-034-per-skill-model-recommendation-transport.md +1 -1
  151. package/docs/decisions/ADR-036-global-install-browser-wizard-handoff.md +106 -0
  152. package/docs/decisions/ADR-037-cost-profile-untangle.md +117 -0
  153. package/docs/decisions/ADR-038-canonical-settings-path.md +66 -0
  154. package/docs/decisions/ADR-039-claude-skills-untracked.md +139 -0
  155. package/docs/decisions/ADR-rule-kernel-and-router.md +1 -1
  156. package/docs/decisions/INDEX.md +4 -0
  157. package/docs/development.md +12 -0
  158. package/docs/getting-started.md +2 -2
  159. package/docs/guidelines/agent-infra/layered-settings.md +10 -4
  160. package/docs/installation.md +3 -3
  161. package/docs/setup/mcp-client-config.md +1 -1
  162. package/docs/skills-catalog.md +5 -1
  163. package/docs/value.md +9 -7
  164. package/docs/wizard.md +1 -1
  165. package/llms.txt +4 -0
  166. package/package.json +1 -1
  167. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  168. package/scripts/_cli/cmd_doctor.py +3 -2
  169. package/scripts/_cli/cmd_explain.py +1 -1
  170. package/scripts/_cli/cmd_versions.py +2 -2
  171. package/scripts/_cli/explain_last/inputs.py +11 -8
  172. package/scripts/_cli/explain_last/sections/inputs.py +1 -1
  173. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  175. package/scripts/_lib/agent_settings.py +54 -6
  176. package/scripts/_lib/agent_src.py +30 -0
  177. package/scripts/_lib/value_ladder.py +99 -2
  178. package/scripts/_lib/value_report.py +30 -16
  179. package/scripts/ai_council/modes.py +1 -1
  180. package/scripts/ai_council/session.py +5 -1
  181. package/scripts/audit_command_surface.py +7 -1
  182. package/scripts/audit_initial_context.py +26 -2
  183. package/scripts/check_gate_paths.py +117 -0
  184. package/scripts/check_references.py +51 -2
  185. package/scripts/check_skill_requires.py +143 -0
  186. package/scripts/check_test_coverage_diff.py +180 -0
  187. package/scripts/compile_router.py +5 -1
  188. package/scripts/condense.py +92 -4
  189. package/scripts/config/session_profiles.py +492 -0
  190. package/scripts/council_cli.py +5 -1
  191. package/scripts/first-run.sh +11 -11
  192. package/scripts/hook_manifest.yaml +15 -7
  193. package/scripts/hooks/dispatch_hook.py +8 -0
  194. package/scripts/install +14 -1
  195. package/scripts/install-hooks.sh +2 -1
  196. package/scripts/install.py +203 -433
  197. package/scripts/install_anthropic_key.sh +1 -1
  198. package/scripts/install_openai_key.sh +1 -1
  199. package/scripts/inventory_abstraction_budget.py +6 -1
  200. package/scripts/lint_agents_md.py +11 -4
  201. package/scripts/lint_discovery_vocabulary.py +5 -5
  202. package/scripts/lint_hook_concern_budget.py +5 -1
  203. package/scripts/lint_marketplace.py +18 -7
  204. package/scripts/lint_roadmap_ci_steps.py +5 -1
  205. package/scripts/lint_roadmap_complexity.py +5 -1
  206. package/scripts/lint_value_dashboard.py +1 -1
  207. package/scripts/mcp_server/prompts.py +5 -1
  208. package/scripts/prediction-pool/adapters/_schema.md +42 -0
  209. package/scripts/prediction-pool/adapters/kicktipp.yml +23 -0
  210. package/scripts/prediction-pool/poisson_sim.py +167 -0
  211. package/scripts/prediction-pool/pool_winsim.py +236 -0
  212. package/scripts/prediction-pool/score_ev.py +188 -0
  213. package/scripts/profile_staleness_hook.py +69 -0
  214. package/scripts/render_value_md.py +1 -0
  215. package/scripts/roadmap_progress_hook.py +56 -6
  216. package/scripts/schemas/agent-settings.schema.json +77 -0
  217. package/scripts/schemas/skill.schema.json +7 -0
  218. package/scripts/smoke_quickstart.py +7 -6
  219. package/scripts/sync_agent_settings.py +12 -5
  220. package/scripts/validate_agent_settings.py +124 -0
  221. package/scripts/validate_decision_engine.py +5 -1
  222. package/templates/minimal/.agent-settings.yml +1 -1
  223. package/dist/ui/assets/index-DVsyUMZe.js.map +0 -1
  224. package/scripts/measure_roadmap_trajectory.py +0 -112
  225. package/scripts/verify_roadmap_closure.py +0 -327
@@ -12,13 +12,15 @@ format in `.agent-settings.yml`, leaves a one-shot backup as
12
12
  exactly once; subsequent runs are idempotent.
13
13
 
14
14
  Usage:
15
- python3 scripts/install.py # defaults: cost_profile=balanced
16
- python3 scripts/install.py --profile=minimal # set cost_profile=minimal (kernel only)
17
- python3 scripts/install.py --force # overwrite existing files
15
+ python3 scripts/install.py # defaults: rule_loading_tier=balanced
16
+ python3 scripts/install.py --profile=minimal # set rule_loading_tier=minimal (kernel only)
17
+ python3 scripts/install.py --force # accepted (no-op): installs always overwrite
18
18
  python3 scripts/install.py --skip-bridges # only create .agent-settings.yml
19
19
  python3 scripts/install.py --project <dir> # override project root
20
20
 
21
- Idempotent — safe to run multiple times. Never overwrites files without --force.
21
+ Idempotent — safe to run multiple times. A run always refreshes every
22
+ deployed file with the current package content; user configuration
23
+ (.agent-settings.yml) is merged by the settings layer, never clobbered.
22
24
  Zero dependencies — standard library only.
23
25
  """
24
26
 
@@ -48,24 +50,40 @@ except ImportError: # pragma: no cover — alt sys.path layout
48
50
 
49
51
  DEFAULT_PROFILE = "balanced"
50
52
  SUPPORTED_PROFILES = ("minimal", "balanced", "full")
51
- COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
53
+ RULE_LOADING_TIER_PLACEHOLDER = "__RULE_LOADING_TIER__"
52
54
  USER_TYPE_PLACEHOLDER = "__USER_TYPE__"
53
55
  USER_TYPES_DIR = "user-types"
54
56
 
55
- # Env-var equivalent of --force for CI / scripted installs (P3.4).
56
- # When set to "1" the install run treats every conflict as
57
- # force-overwrite; never enabled by default to keep destructive writes
58
- # explicit.
59
- ALLOW_OVERWRITE_ENV = "AGENT_CONFIG_ALLOW_OVERWRITE"
60
-
61
57
  SETTINGS_FILE = ".agent-settings.yml"
62
58
  LEGACY_SETTINGS_FILE = ".agent-settings"
63
59
  LEGACY_BACKUP_FILE = ".agent-settings.backup.key-value"
64
60
 
61
+ # Canonical project settings live in the settings layer (agents/settings/),
62
+ # not at the repo root (ADR-038). The repo-root .agent-settings.yml is a
63
+ # back-compat read-fallback that install migrates into the canonical
64
+ # location. Kept inline (no package import) — install.py runs standalone.
65
+ SETTINGS_SUBDIR = ("agents", "settings")
66
+
67
+
68
+ def _canonical_settings_target(project_root: Path) -> Path:
69
+ """Canonical write target: <root>/agents/settings/.agent-settings.yml."""
70
+ return project_root.joinpath(*SETTINGS_SUBDIR, SETTINGS_FILE)
71
+
72
+
73
+ def _resolve_settings_read(project_root: Path) -> Path:
74
+ """Canonical if present, else legacy repo-root file if present, else canonical."""
75
+ canonical = _canonical_settings_target(project_root)
76
+ if canonical.exists():
77
+ return canonical
78
+ legacy = project_root / SETTINGS_FILE
79
+ if legacy.exists():
80
+ return legacy
81
+ return canonical
82
+
65
83
  # Maps legacy flat keys (.agent-settings, key=value) to the new dotted YAML
66
84
  # paths in .agent-settings.yml. Applied once during auto-migration.
67
85
  LEGACY_RENAME_MAP = {
68
- "cost_profile": "cost_profile",
86
+ "cost_profile": "rule_loading_tier",
69
87
  "ide": "personal.ide",
70
88
  "open_edited_files": "personal.open_edited_files",
71
89
  "user_name": "personal.user_name",
@@ -190,222 +208,38 @@ def detect_package_type_for_project(project_root: Path, package_root: Path) -> s
190
208
  return detect_package_type(package_root)
191
209
 
192
210
 
193
- # --- Conflict detection (P3.1 / P3.3) ---
194
-
195
- class ConflictAbort(SystemExit):
196
- """Raised when a conflict resolution chose 'abort'.
197
-
198
- Inherits ``SystemExit`` so an unhandled abort terminates the
199
- install with a non-zero exit code without an opaque traceback.
200
- """
201
-
202
- def __init__(self, message: str):
203
- super().__init__(1)
204
- self.message = message
205
-
206
-
207
- class ConflictPolicy:
208
- """Per-install conflict resolution policy (P3.1).
209
-
210
- Aggregates the inputs the resolver needs to decide whether to
211
- overwrite a target that exists on disk:
212
-
213
- * ``force`` — true when ``--force`` was passed OR the
214
- ``AGENT_CONFIG_ALLOW_OVERWRITE=1`` env-var is set (P3.4).
215
- * ``interactive`` — true when stdin AND stdout are TTYs; the
216
- only context where the 3-option prompt is meaningful.
217
- * ``known_paths`` — absolute path strings recorded as ours by
218
- the project-scope manifest (``files[]`` entries). A target at a
219
- known path is **not** a foreign collision — we own it and the
220
- existing skip/force behaviour applies.
221
- * ``known_pointers``— ``(file_label, json_pointer)`` pairs we
222
- previously merged into shared JSON files (P3.3). A pointer in
223
- this set is ours; one not in it that exists in the target is a
224
- foreign merge collision.
225
- """
226
-
227
- __slots__ = ("force", "interactive", "known_paths", "known_pointers")
228
-
229
- def __init__(
230
- self,
231
- *,
232
- force: bool,
233
- interactive: bool,
234
- known_paths: set[str],
235
- known_pointers: set[tuple[str, str]],
236
- ) -> None:
237
- self.force = force
238
- self.interactive = interactive
239
- self.known_paths = known_paths
240
- self.known_pointers = known_pointers
241
-
242
-
243
- # Module-level singleton: configured once in main() (after --force +
244
- # env-var resolution), consulted by every writer. When ``None`` the
245
- # install runs in **legacy mode**: writers honor their local ``force``
246
- # flag and skip-otherwise, no foreign-pointer detection. Set only by
247
- # :func:`main` after loading the manifest so test callers that exercise
248
- # writers directly keep the pre-P3 contract.
249
- _CONFLICT_POLICY: Optional[ConflictPolicy] = None
250
-
251
-
252
- def _conflict_policy_active() -> bool:
253
- return _CONFLICT_POLICY is not None
254
-
255
-
256
- def _get_conflict_policy() -> ConflictPolicy:
257
- if _CONFLICT_POLICY is None:
258
- # Legacy-mode fallback: no manifest loaded, no foreign detection
259
- # surface. ``force=False`` here so the local ``force_hint`` from
260
- # the caller is the only signal; ``known_*`` stay empty.
261
- return ConflictPolicy(
262
- force=False, interactive=False,
263
- known_paths=set(), known_pointers=set(),
264
- )
265
- return _CONFLICT_POLICY
266
-
267
-
268
- def _set_conflict_policy(policy: Optional[ConflictPolicy]) -> None:
269
- global _CONFLICT_POLICY
270
- _CONFLICT_POLICY = policy
271
-
272
-
273
- def _allow_overwrite_env() -> bool:
274
- return os.environ.get(ALLOW_OVERWRITE_ENV) == "1"
211
+ # --- Conflict resolution ---
212
+ #
213
+ # A setup deploys our own files into our own layout. Every path the
214
+ # installer writes comes from our source tree, so a deliberate run
215
+ # always lays down the current package content — there is no "foreign"
216
+ # file to protect (a path missing from the manifest is simply our own
217
+ # file from an older install). User configuration (.agent-settings.yml)
218
+ # is merged by the settings layer, never clobbered here.
275
219
 
276
220
 
277
221
  def _is_interactive() -> bool:
222
+ """True when stdin AND stdout are TTYs (interactive prompts are safe)."""
278
223
  try:
279
224
  return sys.stdin.isatty() and sys.stdout.isatty()
280
225
  except (AttributeError, ValueError): # pragma: no cover — closed streams
281
226
  return False
282
227
 
283
228
 
284
- def _load_conflict_policy(project_root: Path, force: bool) -> ConflictPolicy:
285
- """Build a :class:`ConflictPolicy` from the on-disk manifest.
286
-
287
- Reads ``agents/installed-tools.lock`` once and folds every recorded
288
- ``files[].path`` into ``known_paths`` and every
289
- ``merged_keys[].{file, json_pointer}`` into ``known_pointers``. The
290
- manifest is the only source of truth for "this is ours"; if it's
291
- missing both sets stay empty and every existing target is treated
292
- as foreign.
293
- """
294
- known_paths: set[str] = set()
295
- known_pointers: set[tuple[str, str]] = set()
296
- try:
297
- tools_mod = _load_installed_tools_module()
298
- target = tools_mod.manifest_path(project_root)
299
- existing = tools_mod.read_manifest(target) or {}
300
- for tool in existing.get("tools", []) or []:
301
- for entry in tool.get("files", []) or []:
302
- path_val = entry.get("path")
303
- if isinstance(path_val, str) and path_val:
304
- # Manifest paths may be absolute (production writers
305
- # use ``str(Path)`` for ``files[].path``) or relative
306
- # (portable manifests). Writers always pass absolute
307
- # ``Path`` objects to ``_resolve_file_conflict``, so
308
- # normalise here against ``project_root`` to keep the
309
- # known-path silent-skip branch reachable.
310
- p = Path(path_val)
311
- if not p.is_absolute():
312
- p = (project_root / p).resolve()
313
- known_paths.add(str(p))
314
- for entry in tool.get("merged_keys", []) or []:
315
- file_label = entry.get("file")
316
- pointer = entry.get("json_pointer")
317
- if isinstance(file_label, str) and isinstance(pointer, str):
318
- known_pointers.add((file_label, pointer))
319
- except Exception: # pragma: no cover — fail-open on corrupt manifest
320
- # Don't block the install if the manifest is malformed; just
321
- # report nothing as ours so foreign-file detection stays strict.
322
- pass
323
- return ConflictPolicy(
324
- force=force or _allow_overwrite_env(),
325
- interactive=_is_interactive(),
326
- known_paths=known_paths,
327
- known_pointers=known_pointers,
328
- )
329
-
330
-
331
- def prompt_file_conflict_choice(path: Path) -> str:
332
- """3-option resolution prompt for a foreign file at ``path``.
333
-
334
- Returns ``"force"`` / ``"skip"`` / ``"abort"``. Mirrors
335
- :func:`prompt_collision_choice` (loops on invalid input, aborts on
336
- EOF or 3 invalid replies). Only called when the policy is
337
- interactive AND ``--force`` was not specified.
338
- """
339
- print()
340
- warn(f"Foreign file at {path}")
341
- info("This path exists but is not recorded as ours in the manifest.")
342
- info("Choose how to handle the conflict:")
343
- print(" 1) Force — overwrite the file with our content")
344
- print(" 2) Skip — leave the file untouched, continue install")
345
- print(" 3) Abort — stop the install, exit non-zero")
346
- print()
347
- attempts = 0
348
- while attempts < 3:
349
- try:
350
- reply = _read_line("Choose [1/2/3]: ")
351
- except EOFError:
352
- fail(f"File-conflict prompt aborted (EOF on stdin) for {path}")
353
- if reply in ("1", "force", "f"):
354
- return "force"
355
- if reply in ("2", "skip", "s"):
356
- return "skip"
357
- if reply in ("3", "abort", "a"):
358
- return "abort"
359
- attempts += 1
360
- warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
361
- fail(f"File-conflict prompt aborted (3 invalid replies) for {path}")
362
- return "abort" # unreachable
363
-
364
-
365
229
  def _resolve_file_conflict(target: Path, *, force_hint: bool) -> str:
366
- """Decide what to do when ``target`` already exists on disk.
367
-
368
- Returns ``"write"`` (proceed with overwrite), ``"skip"`` (leave the
369
- target alone), or raises :class:`ConflictAbort`. ``force_hint`` is
370
- the caller's local ``force`` flag typically the install-level
371
- ``--force``; we OR it with the global policy's ``force`` to honor
372
- ``AGENT_CONFIG_ALLOW_OVERWRITE=1`` in callers that have not yet
373
- been refactored to read the policy directly.
374
-
375
- Decision matrix:
376
-
377
- * target does not exist → ``"write"``
378
- * target IS in ``known_paths`` → ``"write"`` if force else ``"skip"``
379
- (legacy behaviour — we own it, skip silently without --force)
380
- * target NOT in ``known_paths`` (foreign):
381
- * force → ``"write"`` (overwrite)
382
- * interactive → prompt → translate to write/skip/abort
383
- * non-interactive → raise ``ConflictAbort``
230
+ """Whole-file deploy is always an overwrite of OUR own content.
231
+
232
+ Every ``target`` reaching this function comes from
233
+ :func:`_copy_dir_dereferencing_symlinks` a file we ship being
234
+ written into our own layout. A setup the user deliberately ran
235
+ applies the latest package content unconditionally, so there is no
236
+ "skip" or "abort": the user runs the installer because they want it
237
+ applied. ``force_hint`` is retained for call-site stability but no
238
+ longer gates the write.
384
239
  """
385
- if not target.exists():
386
- return "write"
387
- if not _conflict_policy_active():
388
- # Legacy mode (no manifest loaded): preserve the pre-P3 contract
389
- # — force overwrites, otherwise skip. No prompt, no abort.
390
- return "write" if force_hint else "skip"
391
- policy = _get_conflict_policy()
392
- effective_force = force_hint or policy.force
393
- if str(target) in policy.known_paths:
394
- return "write" if effective_force else "skip"
395
- if effective_force:
396
- return "write"
397
- if policy.interactive:
398
- choice = prompt_file_conflict_choice(target)
399
- if choice == "force":
400
- return "write"
401
- if choice == "skip":
402
- return "skip"
403
- raise ConflictAbort(f"User aborted on foreign file at {target}")
404
- raise ConflictAbort(
405
- f"Foreign file at {target}: refusing to overwrite. "
406
- f"Re-run with --force or set {ALLOW_OVERWRITE_ENV}=1 to allow. "
407
- f"Run `agent-config doctor` to inspect orphaned files first."
408
- )
240
+ del force_hint # deploys always overwrite; the flag never gates a write
241
+ del target # existence no longer changes the decision
242
+ return "write"
409
243
 
410
244
 
411
245
  # --- File utilities ---
@@ -447,143 +281,24 @@ def deep_merge(base: dict, overlay: dict) -> dict:
447
281
  return result
448
282
 
449
283
 
450
- def _pointer_target_exists(doc: dict, pointer: str) -> bool:
451
- """Return True when ``pointer`` resolves to an existing key in ``doc``.
452
-
453
- Walks the RFC-6901 segments without descending into lists (per the
454
- array-index ban in :mod:`scripts._lib.json_pointers`). Missing
455
- intermediate segments short-circuit to False.
456
- """
457
- if not pointer.startswith("/"):
458
- return False
459
- cursor: Any = doc
460
- segments = pointer.split("/")[1:]
461
- segments = [s.replace("~1", "/").replace("~0", "~") for s in segments]
462
- for seg in segments[:-1]:
463
- if not isinstance(cursor, dict) or seg not in cursor:
464
- return False
465
- cursor = cursor[seg]
466
- if not isinstance(cursor, dict):
467
- return False
468
- return segments[-1] in cursor
469
-
470
-
471
- def _detect_foreign_pointers(
472
- existing: dict,
473
- overlay_entries: list[dict[str, Any]],
474
- label: str,
475
- policy: ConflictPolicy,
476
- ) -> list[str]:
477
- """Return overlay pointers that exist in ``existing`` but aren't ours.
478
-
479
- P3.3 — pointer-level foreign-merge detection. A pointer is foreign
480
- when it would overwrite a value already on disk that the manifest
481
- does NOT record as ours (``(label, pointer) not in known_pointers``).
482
- Returns the list of foreign pointer strings (sorted, deduped) for
483
- use in the conflict-resolution prompt. In legacy mode (no manifest
484
- loaded) the function returns an empty list so callers fall back to
485
- the pre-P3 update flow.
486
- """
487
- if not _conflict_policy_active():
488
- return []
489
- foreign: list[str] = []
490
- seen: set[str] = set()
491
- for entry in overlay_entries:
492
- pointer = entry.get("json_pointer")
493
- if not isinstance(pointer, str) or pointer in seen:
494
- continue
495
- seen.add(pointer)
496
- if not _pointer_target_exists(existing, pointer):
497
- continue
498
- if (label, pointer) in policy.known_pointers:
499
- continue
500
- foreign.append(pointer)
501
- foreign.sort()
502
- return foreign
503
-
504
-
505
- def prompt_json_conflict_choice(path: Path, foreign: list[str]) -> str:
506
- """3-option resolution prompt for foreign JSON pointers at ``path``.
507
-
508
- Returns ``"force"`` / ``"skip"`` / ``"abort"``. Shows the foreign
509
- pointer list so the user knows what will be overwritten.
510
- """
511
- print()
512
- warn(f"Foreign JSON keys at {path}")
513
- info("The following pointers exist in the file but are not recorded as ours:")
514
- for pointer in foreign[:10]:
515
- print(f" {pointer}")
516
- if len(foreign) > 10:
517
- print(f" ... and {len(foreign) - 10} more")
518
- info("Choose how to handle the conflict:")
519
- print(" 1) Force — overwrite the listed pointers with our values")
520
- print(" 2) Skip — leave the file untouched, continue install")
521
- print(" 3) Abort — stop the install, exit non-zero")
522
- print()
523
- attempts = 0
524
- while attempts < 3:
525
- try:
526
- reply = _read_line("Choose [1/2/3]: ")
527
- except EOFError:
528
- fail(f"JSON-conflict prompt aborted (EOF on stdin) for {path}")
529
- if reply in ("1", "force", "f"):
530
- return "force"
531
- if reply in ("2", "skip", "s"):
532
- return "skip"
533
- if reply in ("3", "abort", "a"):
534
- return "abort"
535
- attempts += 1
536
- warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
537
- fail(f"JSON-conflict prompt aborted (3 invalid replies) for {path}")
538
- return "abort" # unreachable
539
-
540
-
541
- def _resolve_json_conflict(
542
- path: Path, label: str, foreign: list[str], *, force_hint: bool,
543
- ) -> str:
544
- """Decide what to do when ``label`` has foreign pointers (P3.3).
545
-
546
- Returns ``"write"`` or ``"skip"``; raises :class:`ConflictAbort`.
547
- Same resolution matrix as :func:`_resolve_file_conflict` but with a
548
- pointer-aware prompt.
549
- """
550
- policy = _get_conflict_policy()
551
- effective_force = force_hint or policy.force
552
- if effective_force:
553
- return "write"
554
- if policy.interactive:
555
- choice = prompt_json_conflict_choice(path, foreign)
556
- if choice == "force":
557
- return "write"
558
- if choice == "skip":
559
- return "skip"
560
- raise ConflictAbort(f"User aborted on foreign JSON pointers at {path}")
561
- raise ConflictAbort(
562
- f"Foreign JSON pointers at {path}: refusing to overwrite "
563
- f"({len(foreign)} key(s)). Re-run with --force or set "
564
- f"{ALLOW_OVERWRITE_ENV}=1 to allow. "
565
- f"Run `agent-config doctor` to inspect orphaned pointers first."
566
- )
567
-
568
-
569
284
  def merge_json_file(
570
285
  path: Path, new_data: dict, force: bool, label: str,
571
286
  ) -> list[dict[str, Any]]:
572
287
  """Merge ``new_data`` into ``path``; return v2 ``merged_keys[]`` entries.
573
288
 
574
- P1.5 + P3.2 + P3.3 of road-to-multi-package-coexistence: every JSON
575
- pointer the install writes lands in the manifest so uninstall can
576
- subtract it cleanly. The merge uses leaf-level pointer-replace
577
- semantics (``deep_merge`` recurses into dicts, replaces at leaves)
578
- so sibling keys owned by neighbour packages survive. Before any
579
- write that would overwrite a pre-existing pointer NOT recorded as
580
- ours, the conflict policy is consulted (force / interactive prompt
581
- / non-interactive abort).
582
-
583
- Returns the v2 entries on a successful create / update; returns
584
- ``[]`` when the file was already in sync or the update was
585
- suppressed without ``--force`` / on a skip choice.
289
+ P1.5 of road-to-multi-package-coexistence: every JSON pointer the
290
+ install writes lands in the manifest so uninstall can subtract it
291
+ cleanly. The merge uses leaf-level pointer-replace semantics
292
+ (``deep_merge`` recurses into dicts, replaces at leaves) so sibling
293
+ keys owned by neighbour packages survive untouched — our overlay
294
+ only ever carries our own keys. A deliberate setup always applies
295
+ those keys (no ``--force`` gate, no abort); ``force`` is retained
296
+ for call-site compatibility only.
297
+
298
+ Returns the v2 entries on a create / update; ``[]`` when the file
299
+ was already in sync.
586
300
  """
301
+ del force # our keys are always applied; the flag never gates a write
587
302
  new_entries = build_merge_entries(label, new_data)
588
303
 
589
304
  if not path.exists():
@@ -598,24 +313,6 @@ def merge_json_file(
598
313
  skip(f"{label} already configured")
599
314
  return new_entries
600
315
 
601
- policy = _get_conflict_policy()
602
- foreign = _detect_foreign_pointers(existing, new_entries, label, policy)
603
-
604
- if foreign:
605
- # Foreign-pointer collision: ask the policy. On "write" we fall
606
- # through and let deep_merge produce the leaf-level pointer-
607
- # replace; on "skip" we bail without changing the file.
608
- decision = _resolve_json_conflict(path, label, foreign, force_hint=force)
609
- if decision == "skip":
610
- skip(f"{label} has foreign keys, skipped")
611
- return []
612
- elif not (force or policy.force):
613
- # No foreign collision but file needs an update — preserve the
614
- # legacy "needs --force" contract so existing test expectations
615
- # and the project-bridge flow stay intact.
616
- skip(f"{label} exists, needs update (use --force)")
617
- return []
618
-
619
316
  write_json_file(path, merged)
620
317
  success(f"{label} updated")
621
318
  return new_entries
@@ -858,7 +555,7 @@ def _validate_user_type(package_root: Path, value: str) -> str:
858
555
  def _inject_packs(body: str, packs: "list[str]") -> str:
859
556
  """Insert a top-level ``packs:`` block into a rendered settings body.
860
557
 
861
- Inserted directly after the ``cost_profile:`` line so the active pack
558
+ Inserted directly after the ``rule_loading_tier:`` line so the active pack
862
559
  selection sits beside the other install-time knobs. No-op when ``packs``
863
560
  is empty — non-pack installs stay byte-identical to the template render.
864
561
  """
@@ -870,13 +567,13 @@ def _inject_packs(body: str, packs: "list[str]") -> str:
870
567
  inserted = False
871
568
  for line in lines:
872
569
  out.append(line)
873
- if not inserted and line.startswith("cost_profile:"):
570
+ if not inserted and line.startswith("rule_loading_tier:"):
874
571
  if not line.endswith("\n"):
875
572
  out[-1] = line + "\n"
876
573
  out.append(block)
877
574
  inserted = True
878
575
  if not inserted:
879
- # No cost_profile anchor (unexpected) — append at the end so the
576
+ # No rule_loading_tier anchor (unexpected) — append at the end so the
880
577
  # selection is still recorded rather than silently dropped.
881
578
  if out and not out[-1].endswith("\n"):
882
579
  out[-1] = out[-1] + "\n"
@@ -892,7 +589,7 @@ def ensure_agent_settings(
892
589
  user_type: str = "",
893
590
  packs: "list[str] | None" = None,
894
591
  ) -> None:
895
- target = project_root / SETTINGS_FILE
592
+ target = _canonical_settings_target(project_root)
896
593
  profile_source = package_root / "config" / "profiles" / f"{profile}.ini"
897
594
  template_source = package_root / "config" / "agent-settings.template.yml"
898
595
 
@@ -902,21 +599,32 @@ def ensure_agent_settings(
902
599
  fail(f"Missing settings template: {template_source}")
903
600
 
904
601
  template = template_source.read_text(encoding="utf-8")
905
- if COST_PROFILE_PLACEHOLDER not in template:
906
- fail(f"Template is missing placeholder {COST_PROFILE_PLACEHOLDER}")
602
+ if RULE_LOADING_TIER_PLACEHOLDER not in template:
603
+ fail(f"Template is missing placeholder {RULE_LOADING_TIER_PLACEHOLDER}")
907
604
  if USER_TYPE_PLACEHOLDER not in template:
908
605
  fail(f"Template is missing placeholder {USER_TYPE_PLACEHOLDER}")
909
606
  profile_values = _parse_profile_ini(profile_source)
910
- if profile_values.get("cost_profile") != profile:
607
+ if profile_values.get("rule_loading_tier") != profile:
911
608
  fail(
912
- f"Profile preset {profile_source.name} has cost_profile="
913
- f"{profile_values.get('cost_profile')!r} but --profile={profile}"
609
+ f"Profile preset {profile_source.name} has rule_loading_tier="
610
+ f"{profile_values.get('rule_loading_tier')!r} but --profile={profile}"
914
611
  )
915
612
  # Inject runtime-only values (not part of the .ini profile presets).
916
613
  profile_values["user_type"] = _validate_user_type(package_root, user_type)
917
614
  template_body = _render_template(template, profile_values)
918
615
  template_body = _inject_packs(template_body, packs or [])
919
616
 
617
+ # ADR-038: relocate an existing repo-root .agent-settings.yml into the
618
+ # canonical agents/settings/ location, preserving the developer's content
619
+ # (never clobber an already-canonical file).
620
+ legacy_root = project_root / SETTINGS_FILE
621
+ if legacy_root.is_file() and not target.exists():
622
+ target.parent.mkdir(parents=True, exist_ok=True)
623
+ target.write_text(legacy_root.read_text(encoding="utf-8"), encoding="utf-8")
624
+ legacy_root.unlink()
625
+ success(f"Migrated {SETTINGS_FILE} → agents/settings/{SETTINGS_FILE} (ADR-038)")
626
+ return
627
+
920
628
  legacy_target = project_root / LEGACY_SETTINGS_FILE
921
629
  if legacy_target.is_file() and target.exists():
922
630
  warn(
@@ -936,10 +644,11 @@ def ensure_agent_settings(
936
644
  skip(f"{SETTINGS_FILE} already exists")
937
645
  return
938
646
 
647
+ target.parent.mkdir(parents=True, exist_ok=True)
939
648
  write_file(target, template_body)
940
649
  user_type_value = profile_values.get("user_type", "")
941
650
  suffix = f", user_type={user_type_value}" if user_type_value else ""
942
- success(f"{SETTINGS_FILE} created (cost_profile={profile}{suffix})")
651
+ success(f"{SETTINGS_FILE} created (rule_loading_tier={profile}{suffix})")
943
652
 
944
653
 
945
654
  def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) -> None:
@@ -2453,6 +2162,41 @@ def _enforce_consumer_global_only(scope: str) -> None:
2453
2162
  )
2454
2163
 
2455
2164
 
2165
+ def _enforce_not_source_repo(scope: str, project_root: Path) -> None:
2166
+ """Refuse a non-global install into the agent-config source repo.
2167
+
2168
+ Python-side mirror of the bash orchestrator's "Source-repo guard"
2169
+ (``scripts/install``). The bash guard only covers the headless
2170
+ ``init`` front door; the wizard reaches the apply engine directly via
2171
+ ``install.py --apply-payload`` (and maintainers can call
2172
+ ``python3 scripts/install.py``), so without this the GUI path could
2173
+ write the ``.augment/`` / ``.claude/`` / ``.cursor/`` projection trees
2174
+ back into the checkout. Both front doors converge on ``main()`` here, so
2175
+ this is the single chokepoint that makes the floor uniform.
2176
+
2177
+ ``--global`` only writes user-scope paths and never the source tree, so
2178
+ it is exempt. Unlike :func:`_enforce_consumer_global_only`, dev-mode does
2179
+ NOT lift this guard — dogfooding bridges into the source tree is an
2180
+ explicit action gated by its own ``AGENT_CONFIG_ALLOW_SELF_INSTALL=1``
2181
+ override (same flag the bash guard honours).
2182
+ """
2183
+ if scope == "global":
2184
+ return
2185
+ if os.environ.get("AGENT_CONFIG_ALLOW_SELF_INSTALL") == "1":
2186
+ return
2187
+ is_source, signature = _is_agent_config_source_repo(project_root)
2188
+ if not is_source:
2189
+ return
2190
+ fail(
2191
+ "Refusing to install agent-config into its own source checkout "
2192
+ f"(detected: {signature}). The source repo is global-only — a "
2193
+ "project-scope install would recreate the .augment/ .claude/ .cursor/ "
2194
+ "projection trees in the repo (double token cost). Run `task sync` to "
2195
+ "regenerate them from .agent-src.uncondensed/ instead, or set "
2196
+ "AGENT_CONFIG_ALLOW_SELF_INSTALL=1 to force."
2197
+ )
2198
+
2199
+
2456
2200
  # --- road-to-global-only-install § Phase 2.2 — three-layer settings reader ---
2457
2201
  #
2458
2202
  # Merge order (per ADR-020 / D9):
@@ -2499,11 +2243,11 @@ def _load_yaml_doc(path: Path) -> dict:
2499
2243
  def _load_default_settings(package_root: Path) -> dict:
2500
2244
  """Parse the rendered settings template into a defaults dict.
2501
2245
 
2502
- The template carries ``__COST_PROFILE__`` / ``__USER_TYPE__``
2246
+ The template carries ``__RULE_LOADING_TIER__`` / ``__USER_TYPE__``
2503
2247
  placeholders that PyYAML cannot parse as scalars. We substitute the
2504
2248
  most permissive defaults (``balanced`` + empty user_type) before
2505
2249
  parsing — the resulting tree is the *defaults* layer of the merge,
2506
- and downstream layers overwrite cost_profile / user_type as needed.
2250
+ and downstream layers overwrite rule_loading_tier / user_type as needed.
2507
2251
  """
2508
2252
  template_source = package_root / "config" / "agent-settings.template.yml"
2509
2253
  if not template_source.exists():
@@ -2512,7 +2256,7 @@ def _load_default_settings(package_root: Path) -> dict:
2512
2256
  text = template_source.read_text(encoding="utf-8")
2513
2257
  except OSError:
2514
2258
  return {}
2515
- rendered = text.replace(COST_PROFILE_PLACEHOLDER, DEFAULT_PROFILE).replace(
2259
+ rendered = text.replace(RULE_LOADING_TIER_PLACEHOLDER, DEFAULT_PROFILE).replace(
2516
2260
  USER_TYPE_PLACEHOLDER, ""
2517
2261
  )
2518
2262
  try:
@@ -2544,7 +2288,7 @@ def read_layered_settings(
2544
2288
  merged = _load_default_settings(package_root)
2545
2289
  merged = deep_merge(merged, _load_yaml_doc(GLOBAL_AGENT_SETTINGS_PATH))
2546
2290
  if project_root is not None:
2547
- project_file = project_root / SETTINGS_FILE
2291
+ project_file = _resolve_settings_read(project_root)
2548
2292
  merged = deep_merge(merged, _load_yaml_doc(project_file))
2549
2293
  return merged
2550
2294
 
@@ -2679,7 +2423,7 @@ def detect_scope(cwd: Path) -> tuple[str, str]:
2679
2423
  ``.git/`` is explicitly NOT a signal — monorepos, dotfile managers,
2680
2424
  and non-Git workspaces all break it. Pure function; no side effects.
2681
2425
  """
2682
- if (cwd / SETTINGS_FILE).exists():
2426
+ if _resolve_settings_read(cwd).exists():
2683
2427
  return "project", f"existing {SETTINGS_FILE}"
2684
2428
 
2685
2429
  has_manifest = next(
@@ -3840,8 +3584,6 @@ def install_global(
3840
3584
  print(f" {tool_id:<15} → no user-scope convention; use `agent-config export --tool={tool_id}`")
3841
3585
  else:
3842
3586
  print(f" {tool_id:<15} → no global-scope content yet (project-scope install supported)")
3843
- if not force and any(s > 0 for _, s, _, _ in deploy_results.values()):
3844
- info(" Re-run with --force to overwrite existing files.")
3845
3587
 
3846
3588
  # Refresh the project-scope manifest when running inside a project tree
3847
3589
  # (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
@@ -3851,7 +3593,7 @@ def install_global(
3851
3593
  # `.agent-settings.yml` and the manifest would be untracked noise.
3852
3594
  if (
3853
3595
  project_root is not None
3854
- and (project_root / SETTINGS_FILE).exists()
3596
+ and _resolve_settings_read(project_root).exists()
3855
3597
  and not (project_root / ".agent-src.uncondensed").is_dir()
3856
3598
  ):
3857
3599
  # Collect deployed/marker paths per tool so the manifest records
@@ -3934,7 +3676,7 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3934
3676
  parser.add_argument(
3935
3677
  "--profile",
3936
3678
  default=DEFAULT_PROFILE,
3937
- help=f"cost_profile value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3679
+ help=f"rule_loading_tier value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3938
3680
  )
3939
3681
  parser.add_argument(
3940
3682
  "--user-type",
@@ -3947,7 +3689,7 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3947
3689
  "surfaces). Written to personal.user_type in .agent-settings.yml."
3948
3690
  ),
3949
3691
  )
3950
- parser.add_argument("--force", action="store_true", help="overwrite existing files")
3692
+ parser.add_argument("--force", action="store_true", help="accepted for back-compat (no-op): installs always overwrite deployed files")
3951
3693
  parser.add_argument("--skip-bridges", action="store_true", help="only create .agent-settings.yml")
3952
3694
  parser.add_argument(
3953
3695
  "--augment-user-hooks",
@@ -4316,7 +4058,7 @@ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
4316
4058
  # the source of truth per ADR-020 § D2; a fresh `--minimal` run
4317
4059
  # without user_type does not write a project-local settings file.
4318
4060
  if user_type:
4319
- settings_dst = target_root / SETTINGS_FILE
4061
+ settings_dst = _canonical_settings_target(target_root)
4320
4062
  if settings_dst.exists() and not force:
4321
4063
  skip(f"{SETTINGS_FILE} already exists (use --force to overwrite)")
4322
4064
  else:
@@ -4325,6 +4067,7 @@ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
4325
4067
  "personal:\n"
4326
4068
  f" user_type: {user_type}\n"
4327
4069
  )
4070
+ settings_dst.parent.mkdir(parents=True, exist_ok=True)
4328
4071
  settings_dst.write_text(body, encoding="utf-8")
4329
4072
  success(f"Wrote {SETTINGS_FILE} (user_type={user_type})")
4330
4073
 
@@ -4617,13 +4360,23 @@ def _kill_stale_wizard_server() -> None:
4617
4360
  print("(Stopped the previous wizard server.)")
4618
4361
 
4619
4362
 
4620
- def _wizard_spawn(project_root: Path) -> int:
4363
+ def _wizard_spawn(project_root: Path, *, pass_project_root: bool = True) -> int:
4621
4364
  """Spawn the wizard, await readiness, hand off to the child.
4622
4365
 
4623
4366
  Returns the child's exit code on clean shutdown, 0 on
4624
4367
  readiness-timeout (install itself succeeded; wizard is best-effort).
4625
4368
  Never raises into the parent — every error surfaces as a printed
4626
4369
  fallback line and a 0 return.
4370
+
4371
+ ``pass_project_root`` forwards ``--project-root`` to the child so the
4372
+ wizard's write root is the consumer project. Set it ``False`` on the
4373
+ global install path: ``--project-root`` would override the write root
4374
+ to the project (project mode), so the wizard would neither read nor
4375
+ write the **global** ``~/.event4u/agent-config/settings/.agent-user.yml``
4376
+ (the saved identity) — and writing global content into the project
4377
+ tree violates ADR-020. Omitting it lets ``resolveWriteRoot`` pick the
4378
+ global root, so a returning user's name/language pre-fill from the
4379
+ saved identity instead of the OS account.
4627
4380
  """
4628
4381
  # Always start fresh: stop any server left running by a prior init.
4629
4382
  _kill_stale_wizard_server()
@@ -4641,7 +4394,9 @@ def _wizard_spawn(project_root: Path) -> int:
4641
4394
  # charge of the user-facing URL print (Tier 2 § 8 ordering) — the dead
4642
4395
  # `gui` subcommand + AGENT_CONFIG_GUI_NO_OPEN env were retired in
4643
4396
  # road-to-single-install-source-of-truth § Phase 4.
4644
- cmd = ["node", str(cli), "install", "--no-open", "--project-root", str(project_root)]
4397
+ cmd = ["node", str(cli), "install", "--no-open"]
4398
+ if pass_project_root:
4399
+ cmd += ["--project-root", str(project_root)]
4645
4400
  env = os.environ.copy()
4646
4401
 
4647
4402
  try:
@@ -4733,6 +4488,17 @@ def _wizard_await_ready(
4733
4488
 
4734
4489
  print()
4735
4490
  print(f"Setup wizard ready: {matched_url}")
4491
+ # Actually open the browser. The child is spawned with --no-open so the
4492
+ # Python parent owns the URL print (ordering); it must also own the
4493
+ # open, or the wizard never surfaces and the user is left staring at a
4494
+ # URL. Best-effort: webbrowser.open returns False on a headless host
4495
+ # (no DISPLAY / no default handler) — we keep the printed URL as the
4496
+ # fallback and never raise into the install flow.
4497
+ try:
4498
+ import webbrowser
4499
+ webbrowser.open(matched_url)
4500
+ except Exception: # noqa: BLE001 - opening is best-effort, never fatal
4501
+ pass
4736
4502
  print("(Wizard runs in the background; close the tab or press Ctrl-C to stop.)")
4737
4503
  try:
4738
4504
  return child.wait()
@@ -4862,9 +4628,10 @@ def main(argv: list[str]) -> int:
4862
4628
  # commit, not by install.py.
4863
4629
  settings = payload.get("settings") or {}
4864
4630
  if isinstance(settings, dict):
4865
- cost_profile = settings.get("cost_profile")
4866
- if isinstance(cost_profile, str) and cost_profile:
4867
- opts.profile = cost_profile
4631
+ # Legacy fallback: pre-untangle installs carry cost_profile.
4632
+ rule_loading_tier = settings.get("rule_loading_tier") or settings.get("cost_profile")
4633
+ if isinstance(rule_loading_tier, str) and rule_loading_tier:
4634
+ opts.profile = rule_loading_tier
4868
4635
  personal = settings.get("personal")
4869
4636
  if isinstance(personal, dict):
4870
4637
  user_type = personal.get("user_type")
@@ -4960,6 +4727,7 @@ def main(argv: list[str]) -> int:
4960
4727
  custom_path: Path | None = Path(opts.custom_path).resolve() if opts.custom_path else None
4961
4728
  scope = _resolve_scope(opts, detected, detect_reason, custom_path)
4962
4729
  _enforce_consumer_global_only(scope)
4730
+ _enforce_not_source_repo(scope, detect_root)
4963
4731
 
4964
4732
  # Scope validation runs before filesystem / package detection so
4965
4733
  # --tools=X / --scope conflicts fail fast with a directive error
@@ -4968,48 +4736,51 @@ def main(argv: list[str]) -> int:
4968
4736
  tools_was_all = _tools_was_all(opts.tools)
4969
4737
  parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
4970
4738
 
4971
- # Conflict policy: load known paths/pointers from the manifest once
4972
- # so every writer can ask "is this ours?" before clobbering (P3.1 /
4973
- # P3.3). Built from the project-scope manifest because that's the
4974
- # only on-disk source of truth across both install scopes.
4975
- policy_root = detect_root if scope == "global" else (
4976
- custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
4977
- )
4978
- _set_conflict_policy(_load_conflict_policy(policy_root, opts.force))
4739
+ # When the install hands off to the browser wizard, the run is
4740
+ # zero-terminal-interaction by contract ("run the command, it installs,
4741
+ # then the wizard opens for packages + settings"): the legacy migration
4742
+ # runs without the [Y/n] gate (the wizard recreates fresh config), and
4743
+ # the GUI is the settings + package surface. Gate is the single source
4744
+ # of truth (TTY / CI / --no-ui / explicit --tools).
4745
+ wizard_handoff = _wizard_should_launch(opts)[0]
4979
4746
 
4980
- try:
4981
- if scope == "global":
4982
- # First-run hook: when legacy artefacts live in the project tree,
4983
- # prompt before laying down the global surface so the user is
4984
- # not left with a dual-stack install. Delegates to the unified
4985
- # `agent-config migrate` (see docs/contracts/migrate-command.md).
4986
- artefacts = _detect_legacy_for_migration(detect_root)
4987
- if artefacts and _prompt_migrate_to_global(detect_root, artefacts):
4988
- rc = _run_migrate_to_global(detect_root)
4989
- if rc != 0:
4990
- return rc
4991
- # Pass detect_root so the manifest refresh runs when --global is
4992
- # invoked from within a project tree (ADR-008 Phase 3.2).
4993
- rc = install_global(parsed_tools, opts.force, project_root=detect_root)
4994
- _emit_progress_terminal(rc)
4995
- return rc
4996
-
4997
- project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
4998
- is_first_run = not (project_root / SETTINGS_FILE).exists()
4999
- rc = _main_project_install(opts, project_root, parsed_tools, is_first_run)
5000
- # Interactive post-install prompt (step-12 Phase 3, forward-compatible
5001
- # stub). Runs only after a successful install so the local config
5002
- # never ships ahead of the bridge files it parameterizes.
5003
- if rc == 0 and getattr(opts, "interactive", False):
5004
- run_interactive_init(project_root, opts.force)
4747
+ if scope == "global":
4748
+ # First-run hook: sweep legacy project-local artefacts via the
4749
+ # unified `agent-config migrate` before laying down the global
4750
+ # surface (see docs/contracts/migrate-command.md). On the
4751
+ # wizard-handoff path this runs without the [Y/n] gate; the
4752
+ # terminal prompt only fires when no wizard will launch.
4753
+ artefacts = _detect_legacy_for_migration(detect_root)
4754
+ if artefacts and (wizard_handoff or _prompt_migrate_to_global(detect_root, artefacts)):
4755
+ rc = _run_migrate_to_global(detect_root)
4756
+ if rc != 0:
4757
+ return rc
4758
+ # Pass detect_root so the manifest refresh runs when --global is
4759
+ # invoked from within a project tree (ADR-008 Phase 3.2).
4760
+ rc = install_global(parsed_tools, opts.force, project_root=detect_root)
5005
4761
  _emit_progress_terminal(rc)
4762
+ # Browser-wizard parity with the project path: an interactive global
4763
+ # install (init / global / upgrade / refresh --global) hands off to
4764
+ # the GUI instead of the terminal. No --project-root → the wizard
4765
+ # resolves the GLOBAL write root, so it reads/writes
4766
+ # ~/.event4u/agent-config (saved identity → name/language pre-fill)
4767
+ # and never lands global content in the project tree (ADR-020).
4768
+ # Best-effort: install already succeeded, so a wizard boot failure
4769
+ # returns 0.
4770
+ if rc == 0 and wizard_handoff:
4771
+ return _wizard_spawn(detect_root, pass_project_root=False)
5006
4772
  return rc
5007
- except ConflictAbort as exc:
5008
- warn(exc.message)
5009
- _emit_progress({"type": "error", "code": "E_CONFLICT_UNRESOLVED", "message": exc.message})
5010
- return 1
5011
- finally:
5012
- _set_conflict_policy(None)
4773
+
4774
+ project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
4775
+ is_first_run = not (project_root / SETTINGS_FILE).exists()
4776
+ rc = _main_project_install(opts, project_root, parsed_tools, is_first_run)
4777
+ # Interactive post-install prompt (step-12 Phase 3, forward-compatible
4778
+ # stub). Runs only after a successful install so the local config
4779
+ # never ships ahead of the bridge files it parameterizes.
4780
+ if rc == 0 and getattr(opts, "interactive", False):
4781
+ run_interactive_init(project_root, opts.force)
4782
+ _emit_progress_terminal(rc)
4783
+ return rc
5013
4784
 
5014
4785
 
5015
4786
  def _propose_modules_config(project_root: Path, is_first_run: bool) -> None:
@@ -5079,8 +4850,7 @@ def _main_project_install(
5079
4850
  ) -> int:
5080
4851
  """Project-scope install body extracted from :func:`main`.
5081
4852
 
5082
- Kept as a private helper so ``main()`` can wrap the entire install
5083
- in a ``try/except ConflictAbort`` without rewriting indentation.
4853
+ Kept as a private helper so ``main()`` stays a thin scope dispatcher.
5084
4854
  """
5085
4855
  if opts.package:
5086
4856
  package_root = Path(opts.package).resolve()