@event4u/agent-config 5.6.0 → 5.7.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 (103) hide show
  1. package/.agent-src/commands/cost-report.md +12 -7
  2. package/.agent-src/commands/prediction-pool.md +215 -0
  3. package/.agent-src/commands/set-cost-profile.md +8 -8
  4. package/.agent-src/commands/sync-agent-settings.md +2 -2
  5. package/.agent-src/presets/README.md +1 -1
  6. package/.agent-src/profiles/README.md +1 -1
  7. package/.agent-src/rules/non-destructive-by-default.md +2 -1
  8. package/.agent-src/skills/prediction-pool-optimizer/SKILL.md +196 -0
  9. package/.agent-src/skills/prediction-pool-optimizer/evals/triggers.json +18 -0
  10. package/.agent-src/skills/prediction-pool-optimizer/reference/ev-fixtures.md +80 -0
  11. package/.agent-src/templates/agent-settings.md +7 -7
  12. package/.agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  13. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +2 -1
  14. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +1 -1
  15. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +9 -7
  16. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +9 -10
  17. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +17 -4
  18. package/.claude-plugin/marketplace.json +3 -1
  19. package/CHANGELOG.md +57 -0
  20. package/README.md +2 -2
  21. package/config/agent-settings.template.yml +11 -2
  22. package/config/discovery/packs.yml +11 -0
  23. package/config/discovery/workspaces.yml +1 -1
  24. package/config/profiles/balanced.ini +1 -1
  25. package/config/profiles/full.ini +1 -1
  26. package/config/profiles/minimal.ini +1 -1
  27. package/dist/discovery/deprecation-report.md +1 -1
  28. package/dist/discovery/discovery-manifest.json +80 -14
  29. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  30. package/dist/discovery/discovery-manifest.summary.md +3 -2
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +34 -3
  33. package/dist/discovery/trust-report.md +2 -2
  34. package/dist/discovery/workspaces.json +13 -4
  35. package/dist/mcp/registry-manifest.json +3 -3
  36. package/dist/server/io/substituteTemplate.js +3 -3
  37. package/dist/server/io/substituteTemplate.js.map +1 -1
  38. package/dist/server/routes/settings.js +2 -2
  39. package/dist/server/routes/settings.js.map +1 -1
  40. package/dist/server/schemas/settings.js +4 -2
  41. package/dist/server/schemas/settings.js.map +1 -1
  42. package/dist/ui/assets/{index-DVsyUMZe.js → index-5lFqAKL0.js} +2 -2
  43. package/dist/ui/assets/index-5lFqAKL0.js.map +1 -0
  44. package/dist/ui/index.html +1 -1
  45. package/docs/architecture/current-onboard-baseline.md +3 -3
  46. package/docs/architecture.md +2 -2
  47. package/docs/catalog.md +7 -5
  48. package/docs/contracts/adr-level-6-productization.md +1 -1
  49. package/docs/contracts/config-presets.md +2 -2
  50. package/docs/contracts/cost-profile-defaults.md +5 -5
  51. package/docs/contracts/discovery-manifest.schema.json +1 -1
  52. package/docs/contracts/explain-trace.schema.json +3 -3
  53. package/docs/contracts/memory-visibility-v1.md +15 -7
  54. package/docs/contracts/profile-system.md +2 -2
  55. package/docs/contracts/settings-api.md +3 -3
  56. package/docs/contracts/value-report-schema.md +14 -1
  57. package/docs/customization.md +21 -5
  58. package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +11 -11
  59. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +16 -2
  60. package/docs/decisions/ADR-034-per-skill-model-recommendation-transport.md +1 -1
  61. package/docs/decisions/ADR-036-global-install-browser-wizard-handoff.md +106 -0
  62. package/docs/decisions/ADR-037-cost-profile-untangle.md +117 -0
  63. package/docs/decisions/ADR-rule-kernel-and-router.md +1 -1
  64. package/docs/decisions/INDEX.md +2 -0
  65. package/docs/getting-started.md +2 -2
  66. package/docs/guidelines/agent-infra/layered-settings.md +2 -2
  67. package/docs/installation.md +3 -3
  68. package/docs/setup/mcp-client-config.md +1 -1
  69. package/docs/value.md +9 -7
  70. package/docs/wizard.md +1 -1
  71. package/package.json +1 -1
  72. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  73. package/scripts/_cli/cmd_explain.py +1 -1
  74. package/scripts/_cli/explain_last/inputs.py +11 -8
  75. package/scripts/_cli/explain_last/sections/inputs.py +1 -1
  76. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  77. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  78. package/scripts/_lib/agent_settings.py +2 -1
  79. package/scripts/_lib/value_ladder.py +99 -2
  80. package/scripts/_lib/value_report.py +30 -16
  81. package/scripts/ai_council/modes.py +1 -1
  82. package/scripts/audit_initial_context.py +16 -0
  83. package/scripts/check_skill_requires.py +143 -0
  84. package/scripts/condense.py +13 -2
  85. package/scripts/first-run.sh +11 -11
  86. package/scripts/install +14 -1
  87. package/scripts/install.py +127 -428
  88. package/scripts/install_anthropic_key.sh +1 -1
  89. package/scripts/install_openai_key.sh +1 -1
  90. package/scripts/lint_discovery_vocabulary.py +5 -5
  91. package/scripts/lint_value_dashboard.py +1 -1
  92. package/scripts/pack_mcp_content.py +1 -1
  93. package/scripts/prediction-pool/adapters/_schema.md +42 -0
  94. package/scripts/prediction-pool/adapters/kicktipp.yml +23 -0
  95. package/scripts/prediction-pool/poisson_sim.py +167 -0
  96. package/scripts/render_value_md.py +1 -0
  97. package/scripts/schemas/agent-settings.schema.json +77 -0
  98. package/scripts/schemas/skill.schema.json +7 -0
  99. package/scripts/smoke_quickstart.py +4 -4
  100. package/scripts/sync_agent_settings.py +4 -2
  101. package/scripts/validate_agent_settings.py +120 -0
  102. package/templates/minimal/.agent-settings.yml +1 -1
  103. package/dist/ui/assets/index-DVsyUMZe.js.map +0 -1
@@ -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,16 +50,10 @@ 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"
@@ -65,7 +61,7 @@ LEGACY_BACKUP_FILE = ".agent-settings.backup.key-value"
65
61
  # Maps legacy flat keys (.agent-settings, key=value) to the new dotted YAML
66
62
  # paths in .agent-settings.yml. Applied once during auto-migration.
67
63
  LEGACY_RENAME_MAP = {
68
- "cost_profile": "cost_profile",
64
+ "cost_profile": "rule_loading_tier",
69
65
  "ide": "personal.ide",
70
66
  "open_edited_files": "personal.open_edited_files",
71
67
  "user_name": "personal.user_name",
@@ -190,222 +186,38 @@ def detect_package_type_for_project(project_root: Path, package_root: Path) -> s
190
186
  return detect_package_type(package_root)
191
187
 
192
188
 
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"
189
+ # --- Conflict resolution ---
190
+ #
191
+ # A setup deploys our own files into our own layout. Every path the
192
+ # installer writes comes from our source tree, so a deliberate run
193
+ # always lays down the current package content — there is no "foreign"
194
+ # file to protect (a path missing from the manifest is simply our own
195
+ # file from an older install). User configuration (.agent-settings.yml)
196
+ # is merged by the settings layer, never clobbered here.
275
197
 
276
198
 
277
199
  def _is_interactive() -> bool:
200
+ """True when stdin AND stdout are TTYs (interactive prompts are safe)."""
278
201
  try:
279
202
  return sys.stdin.isatty() and sys.stdout.isatty()
280
203
  except (AttributeError, ValueError): # pragma: no cover — closed streams
281
204
  return False
282
205
 
283
206
 
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
207
  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``
208
+ """Whole-file deploy is always an overwrite of OUR own content.
209
+
210
+ Every ``target`` reaching this function comes from
211
+ :func:`_copy_dir_dereferencing_symlinks` a file we ship being
212
+ written into our own layout. A setup the user deliberately ran
213
+ applies the latest package content unconditionally, so there is no
214
+ "skip" or "abort": the user runs the installer because they want it
215
+ applied. ``force_hint`` is retained for call-site stability but no
216
+ longer gates the write.
384
217
  """
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
- )
218
+ del force_hint # deploys always overwrite; the flag never gates a write
219
+ del target # existence no longer changes the decision
220
+ return "write"
409
221
 
410
222
 
411
223
  # --- File utilities ---
@@ -447,143 +259,24 @@ def deep_merge(base: dict, overlay: dict) -> dict:
447
259
  return result
448
260
 
449
261
 
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
262
  def merge_json_file(
570
263
  path: Path, new_data: dict, force: bool, label: str,
571
264
  ) -> list[dict[str, Any]]:
572
265
  """Merge ``new_data`` into ``path``; return v2 ``merged_keys[]`` entries.
573
266
 
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.
267
+ P1.5 of road-to-multi-package-coexistence: every JSON pointer the
268
+ install writes lands in the manifest so uninstall can subtract it
269
+ cleanly. The merge uses leaf-level pointer-replace semantics
270
+ (``deep_merge`` recurses into dicts, replaces at leaves) so sibling
271
+ keys owned by neighbour packages survive untouched — our overlay
272
+ only ever carries our own keys. A deliberate setup always applies
273
+ those keys (no ``--force`` gate, no abort); ``force`` is retained
274
+ for call-site compatibility only.
275
+
276
+ Returns the v2 entries on a create / update; ``[]`` when the file
277
+ was already in sync.
586
278
  """
279
+ del force # our keys are always applied; the flag never gates a write
587
280
  new_entries = build_merge_entries(label, new_data)
588
281
 
589
282
  if not path.exists():
@@ -598,24 +291,6 @@ def merge_json_file(
598
291
  skip(f"{label} already configured")
599
292
  return new_entries
600
293
 
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
294
  write_json_file(path, merged)
620
295
  success(f"{label} updated")
621
296
  return new_entries
@@ -858,7 +533,7 @@ def _validate_user_type(package_root: Path, value: str) -> str:
858
533
  def _inject_packs(body: str, packs: "list[str]") -> str:
859
534
  """Insert a top-level ``packs:`` block into a rendered settings body.
860
535
 
861
- Inserted directly after the ``cost_profile:`` line so the active pack
536
+ Inserted directly after the ``rule_loading_tier:`` line so the active pack
862
537
  selection sits beside the other install-time knobs. No-op when ``packs``
863
538
  is empty — non-pack installs stay byte-identical to the template render.
864
539
  """
@@ -870,13 +545,13 @@ def _inject_packs(body: str, packs: "list[str]") -> str:
870
545
  inserted = False
871
546
  for line in lines:
872
547
  out.append(line)
873
- if not inserted and line.startswith("cost_profile:"):
548
+ if not inserted and line.startswith("rule_loading_tier:"):
874
549
  if not line.endswith("\n"):
875
550
  out[-1] = line + "\n"
876
551
  out.append(block)
877
552
  inserted = True
878
553
  if not inserted:
879
- # No cost_profile anchor (unexpected) — append at the end so the
554
+ # No rule_loading_tier anchor (unexpected) — append at the end so the
880
555
  # selection is still recorded rather than silently dropped.
881
556
  if out and not out[-1].endswith("\n"):
882
557
  out[-1] = out[-1] + "\n"
@@ -902,15 +577,15 @@ def ensure_agent_settings(
902
577
  fail(f"Missing settings template: {template_source}")
903
578
 
904
579
  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}")
580
+ if RULE_LOADING_TIER_PLACEHOLDER not in template:
581
+ fail(f"Template is missing placeholder {RULE_LOADING_TIER_PLACEHOLDER}")
907
582
  if USER_TYPE_PLACEHOLDER not in template:
908
583
  fail(f"Template is missing placeholder {USER_TYPE_PLACEHOLDER}")
909
584
  profile_values = _parse_profile_ini(profile_source)
910
- if profile_values.get("cost_profile") != profile:
585
+ if profile_values.get("rule_loading_tier") != profile:
911
586
  fail(
912
- f"Profile preset {profile_source.name} has cost_profile="
913
- f"{profile_values.get('cost_profile')!r} but --profile={profile}"
587
+ f"Profile preset {profile_source.name} has rule_loading_tier="
588
+ f"{profile_values.get('rule_loading_tier')!r} but --profile={profile}"
914
589
  )
915
590
  # Inject runtime-only values (not part of the .ini profile presets).
916
591
  profile_values["user_type"] = _validate_user_type(package_root, user_type)
@@ -939,7 +614,7 @@ def ensure_agent_settings(
939
614
  write_file(target, template_body)
940
615
  user_type_value = profile_values.get("user_type", "")
941
616
  suffix = f", user_type={user_type_value}" if user_type_value else ""
942
- success(f"{SETTINGS_FILE} created (cost_profile={profile}{suffix})")
617
+ success(f"{SETTINGS_FILE} created (rule_loading_tier={profile}{suffix})")
943
618
 
944
619
 
945
620
  def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) -> None:
@@ -2499,11 +2174,11 @@ def _load_yaml_doc(path: Path) -> dict:
2499
2174
  def _load_default_settings(package_root: Path) -> dict:
2500
2175
  """Parse the rendered settings template into a defaults dict.
2501
2176
 
2502
- The template carries ``__COST_PROFILE__`` / ``__USER_TYPE__``
2177
+ The template carries ``__RULE_LOADING_TIER__`` / ``__USER_TYPE__``
2503
2178
  placeholders that PyYAML cannot parse as scalars. We substitute the
2504
2179
  most permissive defaults (``balanced`` + empty user_type) before
2505
2180
  parsing — the resulting tree is the *defaults* layer of the merge,
2506
- and downstream layers overwrite cost_profile / user_type as needed.
2181
+ and downstream layers overwrite rule_loading_tier / user_type as needed.
2507
2182
  """
2508
2183
  template_source = package_root / "config" / "agent-settings.template.yml"
2509
2184
  if not template_source.exists():
@@ -2512,7 +2187,7 @@ def _load_default_settings(package_root: Path) -> dict:
2512
2187
  text = template_source.read_text(encoding="utf-8")
2513
2188
  except OSError:
2514
2189
  return {}
2515
- rendered = text.replace(COST_PROFILE_PLACEHOLDER, DEFAULT_PROFILE).replace(
2190
+ rendered = text.replace(RULE_LOADING_TIER_PLACEHOLDER, DEFAULT_PROFILE).replace(
2516
2191
  USER_TYPE_PLACEHOLDER, ""
2517
2192
  )
2518
2193
  try:
@@ -3840,8 +3515,6 @@ def install_global(
3840
3515
  print(f" {tool_id:<15} → no user-scope convention; use `agent-config export --tool={tool_id}`")
3841
3516
  else:
3842
3517
  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
3518
 
3846
3519
  # Refresh the project-scope manifest when running inside a project tree
3847
3520
  # (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
@@ -3934,7 +3607,7 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3934
3607
  parser.add_argument(
3935
3608
  "--profile",
3936
3609
  default=DEFAULT_PROFILE,
3937
- help=f"cost_profile value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3610
+ help=f"rule_loading_tier value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3938
3611
  )
3939
3612
  parser.add_argument(
3940
3613
  "--user-type",
@@ -3947,7 +3620,7 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3947
3620
  "surfaces). Written to personal.user_type in .agent-settings.yml."
3948
3621
  ),
3949
3622
  )
3950
- parser.add_argument("--force", action="store_true", help="overwrite existing files")
3623
+ parser.add_argument("--force", action="store_true", help="accepted for back-compat (no-op): installs always overwrite deployed files")
3951
3624
  parser.add_argument("--skip-bridges", action="store_true", help="only create .agent-settings.yml")
3952
3625
  parser.add_argument(
3953
3626
  "--augment-user-hooks",
@@ -4617,13 +4290,23 @@ def _kill_stale_wizard_server() -> None:
4617
4290
  print("(Stopped the previous wizard server.)")
4618
4291
 
4619
4292
 
4620
- def _wizard_spawn(project_root: Path) -> int:
4293
+ def _wizard_spawn(project_root: Path, *, pass_project_root: bool = True) -> int:
4621
4294
  """Spawn the wizard, await readiness, hand off to the child.
4622
4295
 
4623
4296
  Returns the child's exit code on clean shutdown, 0 on
4624
4297
  readiness-timeout (install itself succeeded; wizard is best-effort).
4625
4298
  Never raises into the parent — every error surfaces as a printed
4626
4299
  fallback line and a 0 return.
4300
+
4301
+ ``pass_project_root`` forwards ``--project-root`` to the child so the
4302
+ wizard's write root is the consumer project. Set it ``False`` on the
4303
+ global install path: ``--project-root`` would override the write root
4304
+ to the project (project mode), so the wizard would neither read nor
4305
+ write the **global** ``~/.event4u/agent-config/settings/.agent-user.yml``
4306
+ (the saved identity) — and writing global content into the project
4307
+ tree violates ADR-020. Omitting it lets ``resolveWriteRoot`` pick the
4308
+ global root, so a returning user's name/language pre-fill from the
4309
+ saved identity instead of the OS account.
4627
4310
  """
4628
4311
  # Always start fresh: stop any server left running by a prior init.
4629
4312
  _kill_stale_wizard_server()
@@ -4641,7 +4324,9 @@ def _wizard_spawn(project_root: Path) -> int:
4641
4324
  # charge of the user-facing URL print (Tier 2 § 8 ordering) — the dead
4642
4325
  # `gui` subcommand + AGENT_CONFIG_GUI_NO_OPEN env were retired in
4643
4326
  # road-to-single-install-source-of-truth § Phase 4.
4644
- cmd = ["node", str(cli), "install", "--no-open", "--project-root", str(project_root)]
4327
+ cmd = ["node", str(cli), "install", "--no-open"]
4328
+ if pass_project_root:
4329
+ cmd += ["--project-root", str(project_root)]
4645
4330
  env = os.environ.copy()
4646
4331
 
4647
4332
  try:
@@ -4733,6 +4418,17 @@ def _wizard_await_ready(
4733
4418
 
4734
4419
  print()
4735
4420
  print(f"Setup wizard ready: {matched_url}")
4421
+ # Actually open the browser. The child is spawned with --no-open so the
4422
+ # Python parent owns the URL print (ordering); it must also own the
4423
+ # open, or the wizard never surfaces and the user is left staring at a
4424
+ # URL. Best-effort: webbrowser.open returns False on a headless host
4425
+ # (no DISPLAY / no default handler) — we keep the printed URL as the
4426
+ # fallback and never raise into the install flow.
4427
+ try:
4428
+ import webbrowser
4429
+ webbrowser.open(matched_url)
4430
+ except Exception: # noqa: BLE001 - opening is best-effort, never fatal
4431
+ pass
4736
4432
  print("(Wizard runs in the background; close the tab or press Ctrl-C to stop.)")
4737
4433
  try:
4738
4434
  return child.wait()
@@ -4862,9 +4558,10 @@ def main(argv: list[str]) -> int:
4862
4558
  # commit, not by install.py.
4863
4559
  settings = payload.get("settings") or {}
4864
4560
  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
4561
+ # Legacy fallback: pre-untangle installs carry cost_profile.
4562
+ rule_loading_tier = settings.get("rule_loading_tier") or settings.get("cost_profile")
4563
+ if isinstance(rule_loading_tier, str) and rule_loading_tier:
4564
+ opts.profile = rule_loading_tier
4868
4565
  personal = settings.get("personal")
4869
4566
  if isinstance(personal, dict):
4870
4567
  user_type = personal.get("user_type")
@@ -4968,48 +4665,51 @@ def main(argv: list[str]) -> int:
4968
4665
  tools_was_all = _tools_was_all(opts.tools)
4969
4666
  parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
4970
4667
 
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))
4668
+ # When the install hands off to the browser wizard, the run is
4669
+ # zero-terminal-interaction by contract ("run the command, it installs,
4670
+ # then the wizard opens for packages + settings"): the legacy migration
4671
+ # runs without the [Y/n] gate (the wizard recreates fresh config), and
4672
+ # the GUI is the settings + package surface. Gate is the single source
4673
+ # of truth (TTY / CI / --no-ui / explicit --tools).
4674
+ wizard_handoff = _wizard_should_launch(opts)[0]
4979
4675
 
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)
4676
+ if scope == "global":
4677
+ # First-run hook: sweep legacy project-local artefacts via the
4678
+ # unified `agent-config migrate` before laying down the global
4679
+ # surface (see docs/contracts/migrate-command.md). On the
4680
+ # wizard-handoff path this runs without the [Y/n] gate; the
4681
+ # terminal prompt only fires when no wizard will launch.
4682
+ artefacts = _detect_legacy_for_migration(detect_root)
4683
+ if artefacts and (wizard_handoff or _prompt_migrate_to_global(detect_root, artefacts)):
4684
+ rc = _run_migrate_to_global(detect_root)
4685
+ if rc != 0:
4686
+ return rc
4687
+ # Pass detect_root so the manifest refresh runs when --global is
4688
+ # invoked from within a project tree (ADR-008 Phase 3.2).
4689
+ rc = install_global(parsed_tools, opts.force, project_root=detect_root)
5005
4690
  _emit_progress_terminal(rc)
4691
+ # Browser-wizard parity with the project path: an interactive global
4692
+ # install (init / global / upgrade / refresh --global) hands off to
4693
+ # the GUI instead of the terminal. No --project-root → the wizard
4694
+ # resolves the GLOBAL write root, so it reads/writes
4695
+ # ~/.event4u/agent-config (saved identity → name/language pre-fill)
4696
+ # and never lands global content in the project tree (ADR-020).
4697
+ # Best-effort: install already succeeded, so a wizard boot failure
4698
+ # returns 0.
4699
+ if rc == 0 and wizard_handoff:
4700
+ return _wizard_spawn(detect_root, pass_project_root=False)
5006
4701
  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)
4702
+
4703
+ project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
4704
+ is_first_run = not (project_root / SETTINGS_FILE).exists()
4705
+ rc = _main_project_install(opts, project_root, parsed_tools, is_first_run)
4706
+ # Interactive post-install prompt (step-12 Phase 3, forward-compatible
4707
+ # stub). Runs only after a successful install so the local config
4708
+ # never ships ahead of the bridge files it parameterizes.
4709
+ if rc == 0 and getattr(opts, "interactive", False):
4710
+ run_interactive_init(project_root, opts.force)
4711
+ _emit_progress_terminal(rc)
4712
+ return rc
5013
4713
 
5014
4714
 
5015
4715
  def _propose_modules_config(project_root: Path, is_first_run: bool) -> None:
@@ -5079,8 +4779,7 @@ def _main_project_install(
5079
4779
  ) -> int:
5080
4780
  """Project-scope install body extracted from :func:`main`.
5081
4781
 
5082
- Kept as a private helper so ``main()`` can wrap the entire install
5083
- in a ``try/except ConflictAbort`` without rewriting indentation.
4782
+ Kept as a private helper so ``main()`` stays a thin scope dispatcher.
5084
4783
  """
5085
4784
  if opts.package:
5086
4785
  package_root = Path(opts.package).resolve()