@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.
- package/.agent-src/commands/agent-handoff.md +1 -1
- package/.agent-src/commands/agent-status.md +1 -1
- package/.agent-src/commands/agents/audit.md +1 -1
- package/.agent-src/commands/agents/init.md +1 -1
- package/.agent-src/commands/agents/user/accept.md +3 -3
- package/.agent-src/commands/agents/user/init.md +4 -4
- package/.agent-src/commands/agents/user/show.md +3 -3
- package/.agent-src/commands/agents/user/update.md +3 -3
- package/.agent-src/commands/agents/user.md +1 -1
- package/.agent-src/commands/agents.md +1 -1
- package/.agent-src/commands/analytics/prune.md +1 -1
- package/.agent-src/commands/analytics/show.md +1 -1
- package/.agent-src/commands/analytics.md +1 -1
- package/.agent-src/commands/bug-fix.md +1 -1
- package/.agent-src/commands/challenge-me.md +1 -1
- package/.agent-src/commands/chat-history/import.md +1 -1
- package/.agent-src/commands/chat-history/learn.md +1 -1
- package/.agent-src/commands/chat-history/show.md +1 -1
- package/.agent-src/commands/chat-history.md +1 -1
- package/.agent-src/commands/check-current-md.md +1 -1
- package/.agent-src/commands/condense.md +1 -1
- package/.agent-src/commands/context.md +1 -1
- package/.agent-src/commands/cost-report.md +13 -8
- package/.agent-src/commands/council.md +3 -3
- package/.agent-src/commands/create-pr/description-only.md +1 -1
- package/.agent-src/commands/create-pr.md +1 -1
- package/.agent-src/commands/e2e-heal.md +1 -1
- package/.agent-src/commands/e2e-plan.md +1 -1
- package/.agent-src/commands/feature.md +1 -1
- package/.agent-src/commands/fix/ci.md +1 -1
- package/.agent-src/commands/fix/portability.md +1 -1
- package/.agent-src/commands/fix/pr-bot-comments.md +1 -1
- package/.agent-src/commands/fix/pr-comments.md +1 -1
- package/.agent-src/commands/fix/pr-developer-comments.md +1 -1
- package/.agent-src/commands/fix/refs.md +1 -1
- package/.agent-src/commands/fix/seeder.md +1 -1
- package/.agent-src/commands/fix.md +1 -1
- package/.agent-src/commands/judge.md +1 -1
- package/.agent-src/commands/knowledge/cross-repo.md +1 -1
- package/.agent-src/commands/knowledge/forget.md +1 -1
- package/.agent-src/commands/knowledge/ingest.md +1 -1
- package/.agent-src/commands/knowledge/list.md +1 -1
- package/.agent-src/commands/knowledge.md +1 -1
- package/.agent-src/commands/memory/add.md +1 -1
- package/.agent-src/commands/memory/learn-low-impact.md +1 -1
- package/.agent-src/commands/memory/load.md +1 -1
- package/.agent-src/commands/memory/mine-session.md +1 -1
- package/.agent-src/commands/memory/promote.md +1 -1
- package/.agent-src/commands/memory/propose.md +1 -1
- package/.agent-src/commands/memory.md +1 -1
- package/.agent-src/commands/mode.md +1 -1
- package/.agent-src/commands/optimize/agents-dir.md +1 -1
- package/.agent-src/commands/optimize/augmentignore.md +1 -1
- package/.agent-src/commands/optimize/rtk.md +1 -1
- package/.agent-src/commands/optimize/skills.md +1 -1
- package/.agent-src/commands/optimize.md +1 -1
- package/.agent-src/commands/orchestrate.md +1 -1
- package/.agent-src/commands/override/create.md +1 -1
- package/.agent-src/commands/override/manage.md +1 -1
- package/.agent-src/commands/override.md +1 -1
- package/.agent-src/commands/package-reset.md +1 -1
- package/.agent-src/commands/prediction-pool.md +234 -0
- package/.agent-src/commands/profile/activate.md +81 -0
- package/.agent-src/commands/profile/deactivate.md +68 -0
- package/.agent-src/commands/profile/show.md +70 -0
- package/.agent-src/commands/profile.md +68 -0
- package/.agent-src/commands/project-health.md +1 -1
- package/.agent-src/commands/quality-fix.md +1 -1
- package/.agent-src/commands/roadmap/process-full.md +1 -1
- package/.agent-src/commands/roadmap/process-phase.md +1 -1
- package/.agent-src/commands/roadmap/process-step.md +1 -1
- package/.agent-src/commands/roadmap.md +1 -1
- package/.agent-src/commands/set-cost-profile.md +9 -9
- package/.agent-src/commands/skill/preview.md +3 -3
- package/.agent-src/commands/skill.md +1 -1
- package/.agent-src/commands/skills/discover.md +1 -1
- package/.agent-src/commands/skills.md +1 -1
- package/.agent-src/commands/sync-agent-settings.md +3 -3
- package/.agent-src/commands/sync-gitignore/fix.md +1 -1
- package/.agent-src/commands/sync-gitignore.md +1 -1
- package/.agent-src/commands/update-form-request-messages.md +1 -1
- 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/check-refs/SKILL.md +1 -1
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/.agent-src/skills/git-workflow/SKILL.md +1 -1
- package/.agent-src/skills/jira-integration/SKILL.md +1 -1
- package/.agent-src/skills/markitdown/SKILL.md +1 -1
- package/.agent-src/skills/prediction-pool-optimizer/SKILL.md +314 -0
- package/.agent-src/skills/prediction-pool-optimizer/evals/triggers.json +20 -0
- package/.agent-src/skills/prediction-pool-optimizer/reference/ev-fixtures.md +175 -0
- package/.agent-src/skills/prediction-pool-optimizer/reference/odds-and-bonus.md +109 -0
- package/.agent-src/skills/rtk-output-filtering/SKILL.md +1 -1
- package/.agent-src/skills/script-writing/SKILL.md +1 -1
- package/.agent-src/skills/token-optimizer/SKILL.md +1 -1
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- 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 +54 -6
- 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 +370 -364
- package/CHANGELOG.md +108 -0
- package/README.md +2 -2
- package/config/agent-settings.template.yml +11 -2
- package/config/discovery/packs.yml +11 -0
- package/config/discovery/session-profiles.yml +37 -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 +254 -100
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +41 -6
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +19 -6
- 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 +11 -5
- package/docs/contracts/adr-level-6-productization.md +1 -1
- package/docs/contracts/command-clusters.md +2 -0
- 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/session-profile-overlay.md +120 -0
- package/docs/contracts/settings-api.md +3 -3
- package/docs/contracts/value-report-schema.md +14 -1
- package/docs/customization.md +47 -5
- package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +47 -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-038-canonical-settings-path.md +66 -0
- package/docs/decisions/ADR-039-claude-skills-untracked.md +139 -0
- package/docs/decisions/ADR-rule-kernel-and-router.md +1 -1
- package/docs/decisions/INDEX.md +4 -0
- package/docs/development.md +12 -0
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/layered-settings.md +10 -4
- package/docs/installation.md +3 -3
- package/docs/setup/mcp-client-config.md +1 -1
- package/docs/skills-catalog.md +5 -1
- package/docs/value.md +9 -7
- package/docs/wizard.md +1 -1
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +3 -2
- package/scripts/_cli/cmd_explain.py +1 -1
- package/scripts/_cli/cmd_versions.py +2 -2
- 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 +54 -6
- package/scripts/_lib/agent_src.py +30 -0
- 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/ai_council/session.py +5 -1
- package/scripts/audit_command_surface.py +7 -1
- package/scripts/audit_initial_context.py +26 -2
- package/scripts/check_gate_paths.py +117 -0
- package/scripts/check_references.py +51 -2
- package/scripts/check_skill_requires.py +143 -0
- package/scripts/check_test_coverage_diff.py +180 -0
- package/scripts/compile_router.py +5 -1
- package/scripts/condense.py +92 -4
- package/scripts/config/session_profiles.py +492 -0
- package/scripts/council_cli.py +5 -1
- package/scripts/first-run.sh +11 -11
- package/scripts/hook_manifest.yaml +15 -7
- package/scripts/hooks/dispatch_hook.py +8 -0
- package/scripts/install +14 -1
- package/scripts/install-hooks.sh +2 -1
- package/scripts/install.py +203 -433
- package/scripts/install_anthropic_key.sh +1 -1
- package/scripts/install_openai_key.sh +1 -1
- package/scripts/inventory_abstraction_budget.py +6 -1
- package/scripts/lint_agents_md.py +11 -4
- package/scripts/lint_discovery_vocabulary.py +5 -5
- package/scripts/lint_hook_concern_budget.py +5 -1
- package/scripts/lint_marketplace.py +18 -7
- package/scripts/lint_roadmap_ci_steps.py +5 -1
- package/scripts/lint_roadmap_complexity.py +5 -1
- package/scripts/lint_value_dashboard.py +1 -1
- package/scripts/mcp_server/prompts.py +5 -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/prediction-pool/pool_winsim.py +236 -0
- package/scripts/prediction-pool/score_ev.py +188 -0
- package/scripts/profile_staleness_hook.py +69 -0
- package/scripts/render_value_md.py +1 -0
- package/scripts/roadmap_progress_hook.py +56 -6
- package/scripts/schemas/agent-settings.schema.json +77 -0
- package/scripts/schemas/skill.schema.json +7 -0
- package/scripts/smoke_quickstart.py +7 -6
- package/scripts/sync_agent_settings.py +12 -5
- package/scripts/validate_agent_settings.py +124 -0
- package/scripts/validate_decision_engine.py +5 -1
- package/templates/minimal/.agent-settings.yml +1 -1
- package/dist/ui/assets/index-DVsyUMZe.js.map +0 -1
- package/scripts/measure_roadmap_trajectory.py +0 -112
- package/scripts/verify_roadmap_closure.py +0 -327
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,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
|
-
|
|
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": "
|
|
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
|
|
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"
|
|
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
|
-
"""
|
|
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``
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
Returns the v2 entries on a
|
|
584
|
-
|
|
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 ``
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
906
|
-
fail(f"Template is missing 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("
|
|
607
|
+
if profile_values.get("rule_loading_tier") != profile:
|
|
911
608
|
fail(
|
|
912
|
-
f"Profile preset {profile_source.name} has
|
|
913
|
-
f"{profile_values.get('
|
|
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 (
|
|
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 ``
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
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
|
-
#
|
|
4972
|
-
#
|
|
4973
|
-
#
|
|
4974
|
-
#
|
|
4975
|
-
|
|
4976
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
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()``
|
|
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()
|