@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.
- package/.agent-src/commands/cost-report.md +12 -7
- package/.agent-src/commands/prediction-pool.md +215 -0
- package/.agent-src/commands/set-cost-profile.md +8 -8
- package/.agent-src/commands/sync-agent-settings.md +2 -2
- package/.agent-src/presets/README.md +1 -1
- package/.agent-src/profiles/README.md +1 -1
- package/.agent-src/rules/non-destructive-by-default.md +2 -1
- package/.agent-src/skills/prediction-pool-optimizer/SKILL.md +196 -0
- package/.agent-src/skills/prediction-pool-optimizer/evals/triggers.json +18 -0
- package/.agent-src/skills/prediction-pool-optimizer/reference/ev-fixtures.md +80 -0
- package/.agent-src/templates/agent-settings.md +7 -7
- package/.agent-src/templates/agents/agent-project-settings.example.yml +2 -2
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +2 -1
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +1 -1
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +9 -7
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +9 -10
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +17 -4
- package/.claude-plugin/marketplace.json +3 -1
- package/CHANGELOG.md +57 -0
- package/README.md +2 -2
- package/config/agent-settings.template.yml +11 -2
- package/config/discovery/packs.yml +11 -0
- package/config/discovery/workspaces.yml +1 -1
- package/config/profiles/balanced.ini +1 -1
- package/config/profiles/full.ini +1 -1
- package/config/profiles/minimal.ini +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +80 -14
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -2
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +34 -3
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/server/io/substituteTemplate.js +3 -3
- package/dist/server/io/substituteTemplate.js.map +1 -1
- package/dist/server/routes/settings.js +2 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-DVsyUMZe.js → index-5lFqAKL0.js} +2 -2
- package/dist/ui/assets/index-5lFqAKL0.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/architecture/current-onboard-baseline.md +3 -3
- package/docs/architecture.md +2 -2
- package/docs/catalog.md +7 -5
- package/docs/contracts/adr-level-6-productization.md +1 -1
- package/docs/contracts/config-presets.md +2 -2
- package/docs/contracts/cost-profile-defaults.md +5 -5
- package/docs/contracts/discovery-manifest.schema.json +1 -1
- package/docs/contracts/explain-trace.schema.json +3 -3
- package/docs/contracts/memory-visibility-v1.md +15 -7
- package/docs/contracts/profile-system.md +2 -2
- package/docs/contracts/settings-api.md +3 -3
- package/docs/contracts/value-report-schema.md +14 -1
- package/docs/customization.md +21 -5
- package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +11 -11
- package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +16 -2
- package/docs/decisions/ADR-034-per-skill-model-recommendation-transport.md +1 -1
- package/docs/decisions/ADR-036-global-install-browser-wizard-handoff.md +106 -0
- package/docs/decisions/ADR-037-cost-profile-untangle.md +117 -0
- package/docs/decisions/ADR-rule-kernel-and-router.md +1 -1
- package/docs/decisions/INDEX.md +2 -0
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/layered-settings.md +2 -2
- package/docs/installation.md +3 -3
- package/docs/setup/mcp-client-config.md +1 -1
- package/docs/value.md +9 -7
- package/docs/wizard.md +1 -1
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_explain.py +1 -1
- package/scripts/_cli/explain_last/inputs.py +11 -8
- package/scripts/_cli/explain_last/sections/inputs.py +1 -1
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/agent_settings.py +2 -1
- package/scripts/_lib/value_ladder.py +99 -2
- package/scripts/_lib/value_report.py +30 -16
- package/scripts/ai_council/modes.py +1 -1
- package/scripts/audit_initial_context.py +16 -0
- package/scripts/check_skill_requires.py +143 -0
- package/scripts/condense.py +13 -2
- package/scripts/first-run.sh +11 -11
- package/scripts/install +14 -1
- package/scripts/install.py +127 -428
- package/scripts/install_anthropic_key.sh +1 -1
- package/scripts/install_openai_key.sh +1 -1
- package/scripts/lint_discovery_vocabulary.py +5 -5
- package/scripts/lint_value_dashboard.py +1 -1
- package/scripts/pack_mcp_content.py +1 -1
- package/scripts/prediction-pool/adapters/_schema.md +42 -0
- package/scripts/prediction-pool/adapters/kicktipp.yml +23 -0
- package/scripts/prediction-pool/poisson_sim.py +167 -0
- package/scripts/render_value_md.py +1 -0
- package/scripts/schemas/agent-settings.schema.json +77 -0
- package/scripts/schemas/skill.schema.json +7 -0
- package/scripts/smoke_quickstart.py +4 -4
- package/scripts/sync_agent_settings.py +4 -2
- package/scripts/validate_agent_settings.py +120 -0
- package/templates/minimal/.agent-settings.yml +1 -1
- package/dist/ui/assets/index-DVsyUMZe.js.map +0 -1
package/scripts/install.py
CHANGED
|
@@ -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:
|
|
16
|
-
python3 scripts/install.py --profile=minimal # set
|
|
17
|
-
python3 scripts/install.py --force #
|
|
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.
|
|
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
|
-
|
|
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": "
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
"""
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
Returns the v2 entries on a
|
|
584
|
-
|
|
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 ``
|
|
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("
|
|
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
|
|
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
|
|
906
|
-
fail(f"Template is missing 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("
|
|
585
|
+
if profile_values.get("rule_loading_tier") != profile:
|
|
911
586
|
fail(
|
|
912
|
-
f"Profile preset {profile_source.name} has
|
|
913
|
-
f"{profile_values.get('
|
|
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 (
|
|
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 ``
|
|
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
|
|
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(
|
|
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"
|
|
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
|
|
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"
|
|
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
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
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
|
-
#
|
|
4972
|
-
#
|
|
4973
|
-
#
|
|
4974
|
-
#
|
|
4975
|
-
|
|
4976
|
-
|
|
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
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
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
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
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()``
|
|
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()
|