@event4u/agent-config 1.37.0 → 1.39.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.
@@ -0,0 +1,109 @@
1
+ # MCP Cloud Endpoints — URL shapes & DNS
2
+
3
+ Public URL shapes for the hosted `agent-config-mcp` Worker. Governed
4
+ by `docs/contracts/mcp-cloud-scope.md` (A0-cloud) and Phase 5.2 of
5
+ `agents/roadmaps/road-to-cloudflare-mcp-hosting.md`.
6
+
7
+ ## Stability
8
+
9
+ **Experimental.** Inherits the label from `mcp-phase-1-scope.md`. URL
10
+ shapes below are pinned for the lifetime of the *experimental* window;
11
+ breaking changes require a stability-label bump.
12
+
13
+ ## Scope — Lite surface
14
+
15
+ The hosted endpoint exposes the **read-only governance surface**
16
+ (skills, commands, rules, guidelines, contexts) as MCP prompts +
17
+ resources. `tools/list` returns two **deprecated stubs**
18
+ (`lint_skills`, `chat_history_append`) that point at their local-stdio
19
+ successors; `tools/call` against either returns `isError=true`. No
20
+ script execution, no FS access, no shell.
21
+
22
+ Full power — the ~112 Python scripts (linters, audits, `task ci`,
23
+ work-engine hooks) — requires the local install. See
24
+ [`../contracts/mcp-cloud-scope.md`](../contracts/mcp-cloud-scope.md)
25
+ for the execution-safety boundary and the Phase-7-DEFERRED gate that
26
+ governs any future tool restoration.
27
+
28
+ ## URL shapes (pinned)
29
+
30
+ Two surfaces, both serve identical wire contracts (JSON-RPC over POST,
31
+ SSE on GET — A0-cloud invariant 1):
32
+
33
+ | Shape | Resolves to | Use case |
34
+ |---|---|---|
35
+ | `https://mcp.<domain>/latest/sse` | the release currently pointed at by `releases/latest.txt` in R2 | client wants the rolling cutting-edge build |
36
+ | `https://mcp.<domain>/v<X.Y.Z>/sse` | the immutable release `<X.Y.Z>` from the R2 archive | client wants a pinned, reproducible build |
37
+
38
+ The `<domain>` placeholder is operator-configured; the package itself
39
+ does not own DNS. Pin the chosen domain in your fork's
40
+ `mcp-cloud-scope.md` § Bucket / DNS.
41
+
42
+ For JSON-RPC, drop the `/sse` suffix:
43
+
44
+ | JSON-RPC | SSE |
45
+ |---|---|
46
+ | `POST https://mcp.<domain>/latest` | `GET https://mcp.<domain>/latest/sse` |
47
+ | `POST https://mcp.<domain>/v1.37.0` | `GET https://mcp.<domain>/v1.37.0/sse` |
48
+
49
+ The Worker reads its bundled blob at module init (per A0-cloud
50
+ invariant 2); the path prefix in MVP-1 is a routing artefact, not a
51
+ content selector. Multi-version routing lands in MVP-2.
52
+
53
+ ## DNS setup (operator-side)
54
+
55
+ One-time, requires Cloudflare account + zone access:
56
+
57
+ ```sh
58
+ # 1. Add the Worker custom domain in Cloudflare dashboard:
59
+ # Workers & Pages → agent-config-mcp → Settings → Domains & Routes
60
+ # → Add Custom Domain → "mcp.<your-domain>"
61
+ #
62
+ # 2. Cloudflare creates the AAAA + A records automatically. No manual
63
+ # CNAME — Custom Domains is the supported path (not "Routes").
64
+ #
65
+ # 3. Verify:
66
+ curl -s -X POST https://mcp.<your-domain>/ \
67
+ -H "content-type: application/json" \
68
+ -d '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'
69
+ ```
70
+
71
+ After DNS is live, uncomment the `routes` block in
72
+ `workers/mcp/wrangler.toml` and redeploy via `wrangler deploy` (or let
73
+ the GitHub Action pick it up on the next release).
74
+
75
+ The fallback `*.workers.dev` URL stays live for free; the custom
76
+ domain is only the public stability promise.
77
+
78
+ ## Health probe
79
+
80
+ Every URL accepts `GET /` with no body and returns release identity:
81
+
82
+ ```json
83
+ {
84
+ "ok": true,
85
+ "name": "agent-config-mcp",
86
+ "release_key": "v1.37.0-2fc5084",
87
+ "package_version": "1.37.0",
88
+ "signature": "35bc3c5e8b83",
89
+ "schema_version": 1
90
+ }
91
+ ```
92
+
93
+ The `signature` field is the wire-surface `skillSetSignature` — same
94
+ hash that MCP clients see under `_meta.skillSetSignature` on the
95
+ identity surface.
96
+
97
+ ## Parity smoke
98
+
99
+ Post-deploy CI runs `scripts/mcp_parity_smoke.py` against the new
100
+ deployment with `--target https://mcp.<domain>`. A non-zero exit
101
+ aborts the `latest.txt` repoint, so the previous release keeps
102
+ serving on `/latest/`.
103
+
104
+ ## See also
105
+
106
+ - Per-client config snippets: [`mcp-client-config.md`](mcp-client-config.md)
107
+ - A0-cloud contract: `docs/contracts/mcp-cloud-scope.md`
108
+ - R2 bootstrap: `docs/setup/mcp-r2-bootstrap.md`
109
+ - Local stdio fallback: `scripts/mcp_server/` (unchanged)
@@ -0,0 +1,99 @@
1
+ # MCP Registry Listing — submission package
2
+
3
+ Single source of truth for every MCP-registry submission of the hosted
4
+ `agent-config-mcp` Worker. Copy-paste sections from this file into the
5
+ target registry's template; do not maintain per-registry forks.
6
+
7
+ Phase 6.1 of `agents/roadmaps/road-to-cloudflare-mcp-hosting.md`.
8
+
9
+ ## One-liner
10
+
11
+ > Read-only governance surface for AI coding agents — 174 skills, 104
12
+ > commands, 60 rules, 100 guidelines + contexts. Hosted MCP bridge
13
+ > over the `event4u/agent-config` package.
14
+
15
+ ## Endpoints
16
+
17
+ | Shape | URL | Use |
18
+ |---|---|---|
19
+ | Rolling latest | `https://mcp.<operator-domain>/latest/sse` | clients tracking the live build |
20
+ | Pinned release | `https://mcp.<operator-domain>/v<X.Y.Z>/sse` | clients pinning a reproducible version |
21
+ | Liveness | `GET https://mcp.<operator-domain>/` | release identity + signature |
22
+
23
+ The `<operator-domain>` placeholder reflects the package's design —
24
+ every operator hosts their own Worker. The package upstream does not
25
+ run a public reference deployment; consumers point their Worker at
26
+ their own R2 bucket per `docs/setup/mcp-r2-bootstrap.md`.
27
+
28
+ ## Wire surface (MVP-1)
29
+
30
+ | Method | Status |
31
+ |---|---|
32
+ | `initialize` | implemented |
33
+ | `ping` | implemented |
34
+ | `prompts/list`, `prompts/get` | implemented |
35
+ | `resources/list`, `resources/read` | implemented |
36
+ | `tools/list` | implemented (returns deprecated stubs only) |
37
+ | `tools/call` | **not implemented** — returns `-32601 Method not found` |
38
+ | `notifications/*` | not implemented |
39
+
40
+ No mutation, no auth, no subrequests at runtime. Full contract:
41
+ `docs/contracts/mcp-cloud-scope.md` § A0-cloud.
42
+
43
+ ## Stability
44
+
45
+ **Experimental.** Wire surface, URL shapes, and the `_meta.signature`
46
+ field are pinned for the lifetime of the *experimental* window.
47
+ Breaking changes require a stability-label bump (see
48
+ `docs/contracts/mcp-phase-1-scope.md`).
49
+
50
+ ## Identity & reproducibility
51
+
52
+ Every response carries `_meta.skillSetSignature` — a 12-char SHA-256
53
+ prefix over the sorted `(uri, body)` pairs of the bundled content.
54
+ Identical content → identical signature, across machines and builds.
55
+ R2 archives every release indefinitely under
56
+ `releases/v<X.Y.Z>-<sha>/`.
57
+
58
+ ## Categories (for registry tagging)
59
+
60
+ - `governance`
61
+ - `meta-prompting`
62
+ - `skills`
63
+ - `agent-infrastructure`
64
+ - `code-review`
65
+ - `experimental`
66
+
67
+ ## License & contact
68
+
69
+ | Field | Value |
70
+ |---|---|
71
+ | License | MIT |
72
+ | Source | `https://github.com/event4u-app/agent-config` |
73
+ | Maintainer | event4u-app (org) |
74
+ | Contact | GitHub issues |
75
+
76
+ ## Links to upstream contracts
77
+
78
+ - A0-cloud safety contract: `docs/contracts/mcp-cloud-scope.md`
79
+ - Phase-1 scope (inherited): `docs/contracts/mcp-phase-1-scope.md`
80
+ - URL shapes & DNS: `docs/setup/mcp-cloud-endpoints.md`
81
+ - Local stdio kernel (predecessor): `scripts/mcp_server/`
82
+
83
+ ## Submission targets
84
+
85
+ | Target | Status | Notes |
86
+ |---|---|---|
87
+ | [`awesome-mcp-servers`](https://github.com/punkpeye/awesome-mcp-servers) | ready for PR | low-friction listing, accepts experimental |
88
+ | [`modelcontextprotocol.io` catalog](https://modelcontextprotocol.io/servers) | ready for PR after `awesome-mcp-servers` merges | needs evidence of community uptake |
89
+
90
+ Both submissions reuse the **One-liner**, **Endpoints**, **Wire
91
+ surface**, **Stability**, and **License & contact** sections verbatim
92
+ from this file.
93
+
94
+ ## Out of scope for this roadmap
95
+
96
+ npm-launcher listing (`npx @event4u/agent-config-mcp`) targets the
97
+ **local stdio** server, not the hosted Worker. Different transport,
98
+ different installation pattern, different audience — captured in
99
+ `agents/roadmaps/road-to-mcp-server.md` if revived.
@@ -0,0 +1,152 @@
1
+ # Cloudflare MCP — Operator Setup
2
+
3
+ One-stop landing for onboarding a Cloudflare account to host the
4
+ `agent-config-mcp` Worker. Combines bucket bootstrap, DNS, GitHub
5
+ secrets, and troubleshooting in one place.
6
+
7
+ Governed by [`docs/contracts/mcp-cloud-scope.md`](../contracts/mcp-cloud-scope.md)
8
+ (A0-cloud). Deploys run **only** in CI
9
+ ([`.github/workflows/deploy-mcp-worker.yml`](../../.github/workflows/deploy-mcp-worker.yml))
10
+ — per A0-cloud invariant 7, never `wrangler deploy` from a developer
11
+ machine.
12
+
13
+ ## TL;DR — the happy path
14
+
15
+ ```sh
16
+ task mcp:cloud:login # one-time, opens browser
17
+ task mcp:cloud:setup # check → r2-create → r2-verify → whoami
18
+ # Copy account id from output → GitHub Secrets (see § GitHub Secrets)
19
+ # Create scoped API token (see § API Token) → GitHub Secrets
20
+ # Done. First `release: published` triggers the deploy.
21
+ ```
22
+
23
+ ## Prerequisites
24
+
25
+ | Tool | Version | Install |
26
+ |---|---|---|
27
+ | Node.js | ≥ 20 | <https://nodejs.org/> or `nvm install 20` |
28
+ | `npx`/`wrangler` | wrangler ≥ 4.0 (auto-fetched on first run) | bundled with Node |
29
+ | `gh` CLI | latest | <https://cli.github.com/> (only for manual `deploy-dispatch`) |
30
+ | Cloudflare account | any plan | <https://dash.cloudflare.com/sign-up> |
31
+
32
+ Run `task mcp:cloud:check` to verify all four in one shot.
33
+
34
+ ## Step 1 — Cloudflare login
35
+
36
+ ```sh
37
+ task mcp:cloud:login
38
+ ```
39
+
40
+ Opens a browser to authorize wrangler against your Cloudflare account.
41
+ One-time per developer machine; the token is stored in
42
+ `~/Library/Preferences/.wrangler/` (macOS) or `~/.wrangler/` (Linux).
43
+
44
+ ## Step 2 — Enable R2
45
+
46
+ Cloudflare requires a one-time plan activation before R2 buckets can
47
+ be created. **You will hit error `code: 10042` on the first attempt
48
+ if you skip this.**
49
+
50
+ 1. <https://dash.cloudflare.com/?to=/:account/r2/overview>
51
+ 2. Click **Purchase R2 Plan**
52
+ 3. Select **Free Tier** — 10 GB storage / 1 M Class-A / 10 M Class-B
53
+ ops per month at **$0** (credit card required, $0 within quota,
54
+ $0 egress)
55
+ 4. Confirm; wait ~30 s for activation
56
+
57
+ ## Step 3 — Create the R2 bucket
58
+
59
+ ```sh
60
+ task mcp:cloud:r2-create # idempotent — safe to re-run
61
+ task mcp:cloud:r2-verify # ✅ if present
62
+ ```
63
+
64
+ The bucket is named `agent-config-mcp` and configured per
65
+ [`docs/setup/mcp-r2-bootstrap.md`](mcp-r2-bootstrap.md) (private,
66
+ indefinite retention, Worker reads via binding).
67
+
68
+ `task mcp:cloud:setup` chains steps 1, 3, and the account-id readout
69
+ in one shot.
70
+
71
+ ## Step 4 — API Token
72
+
73
+ The CI deploy pipeline needs a **scoped** token — never reuse the
74
+ Global API Key or a production-tenant token.
75
+
76
+ Dashboard → **My Profile → API Tokens → Create Token → Custom token**:
77
+
78
+ | Permission | Resource | Access |
79
+ |---|---|---|
80
+ | Account · Workers Scripts | your account | Edit |
81
+ | Account · Workers R2 Storage | your account | Edit |
82
+ | User · User Details | — | Read |
83
+
84
+ If you uncomment the `routes` block in `workers/mcp/wrangler.toml`
85
+ (custom domain cutover, Phase 5.2), add **Zone · DNS · Edit** on the
86
+ relevant zone.
87
+
88
+ Copy the generated token immediately — Cloudflare shows it once.
89
+
90
+ ## Step 5 — GitHub Secrets
91
+
92
+ Repository → **Settings → Secrets and variables → Actions → New
93
+ repository secret**:
94
+
95
+ | Secret | Value | Source |
96
+ |---|---|---|
97
+ | `CLOUDFLARE_API_TOKEN` | scoped token from Step 4 | dashboard |
98
+ | `CLOUDFLARE_ACCOUNT_ID` | account id | `task mcp:cloud:whoami` |
99
+
100
+ Set them in **Actions** secrets, not **Codespaces** or **Dependabot**
101
+ scopes.
102
+
103
+ ## Step 6 — Validate
104
+
105
+ Trigger the deploy workflow manually against an existing tag — no
106
+ release event needed:
107
+
108
+ ```sh
109
+ task mcp:cloud:deploy-dispatch TAG=v1.37.0
110
+ gh run watch
111
+ ```
112
+
113
+ Green smoke step → `latest.txt` repoints → release is live on
114
+ `*.workers.dev`. A red smoke step leaves `latest.txt` on the previous
115
+ release.
116
+
117
+ ## Step 7 — DNS (optional, Phase 5.2)
118
+
119
+ Custom domain `mcp.<your-domain>` setup lives in
120
+ [`docs/setup/mcp-cloud-endpoints.md`](mcp-cloud-endpoints.md) § DNS
121
+ setup. Until cutover, the Worker serves on the free
122
+ `agent-config-mcp.<account>.workers.dev` URL.
123
+
124
+ ## Troubleshooting
125
+
126
+ | Symptom | Cause | Fix |
127
+ |---|---|---|
128
+ | `code: 10042` on bucket create | R2 not enabled on account | Step 2 — Enable R2 |
129
+ | `wrangler whoami` shows no Account ID | not logged in | `task mcp:cloud:login` |
130
+ | Workflow fails on `wrangler deploy` with auth error | secret missing/wrong scope | re-check Step 4 token permissions |
131
+ | Smoke step red after deploy | bundle vs. SDK mismatch | check `compatibility_date` in `wrangler.toml` |
132
+ | Bucket create returns `already exists` | bucket present | not an error — `task mcp:cloud:r2-verify` to confirm |
133
+
134
+ ## Available tasks
135
+
136
+ | Task | Purpose |
137
+ |---|---|
138
+ | `task mcp:cloud:check` | Preflight — tools + login status |
139
+ | `task mcp:cloud:login` | Interactive wrangler login |
140
+ | `task mcp:cloud:whoami` | Print account id for GitHub Secret |
141
+ | `task mcp:cloud:r2-create` | Create R2 bucket (idempotent) |
142
+ | `task mcp:cloud:r2-verify` | Verify R2 bucket exists |
143
+ | `task mcp:cloud:setup` | Full chain — check → r2 → whoami |
144
+ | `task mcp:cloud:dev` | Local `wrangler dev` on :8787 |
145
+ | `task mcp:cloud:deploy-dispatch TAG=v…` | Manual workflow trigger |
146
+
147
+ ## See also
148
+
149
+ - [`docs/contracts/mcp-cloud-scope.md`](../contracts/mcp-cloud-scope.md) — A0-cloud contract
150
+ - [`docs/setup/mcp-r2-bootstrap.md`](mcp-r2-bootstrap.md) — R2 layout & break-glass
151
+ - [`docs/setup/mcp-cloud-endpoints.md`](mcp-cloud-endpoints.md) — URL shapes & DNS
152
+ - [`workers/mcp/README.md`](../../workers/mcp/README.md) — Worker source overview
@@ -0,0 +1,82 @@
1
+ # R2 Bootstrap — agent-config MCP
2
+
3
+ One-time setup for the R2 bucket that holds the Worker's release archive.
4
+ Owned by `mcp-cloud-scope.md` §3.3 and consumed by the deploy pipeline
5
+ (`.github/workflows/deploy-mcp-worker.yml`).
6
+
7
+ ## Bucket
8
+
9
+ | Field | Value |
10
+ |---|---|
11
+ | Bucket name | `agent-config-mcp` |
12
+ | Location hint | `auto` (Cloudflare-chosen) |
13
+ | Public access | **off** — Worker reads via binding, never via signed URL |
14
+ | Object retention | indefinite — every release stays for forensics |
15
+
16
+ ## Layout
17
+
18
+ ```
19
+ agent-config-mcp/
20
+ ├─ releases/
21
+ │ ├─ v<X.Y.Z>-<sha>/
22
+ │ │ ├─ content.json.gz # gzipped content blob (R2 archival copy)
23
+ │ │ └─ manifest.json # uncompressed manifest sidecar
24
+ │ └─ latest.txt # one-line: v<X.Y.Z>-<sha>
25
+ └─ (no other prefixes)
26
+ ```
27
+
28
+ `latest.txt` is the only mutable object. Every `releases/v*/` directory
29
+ is **immutable** once written; the pipeline refuses to overwrite an
30
+ existing release prefix.
31
+
32
+ ## Bootstrap — one-time
33
+
34
+ Requires `wrangler` ≥ 4.0 and a Cloudflare API token with R2 admin scope.
35
+
36
+ ```sh
37
+ # 1. Authenticate (interactive, opens browser).
38
+ npx wrangler login
39
+
40
+ # 2. Create the bucket.
41
+ npx wrangler r2 bucket create agent-config-mcp
42
+
43
+ # 3. Verify.
44
+ npx wrangler r2 bucket list | grep agent-config-mcp
45
+ ```
46
+
47
+ The Worker binding is declared in `workers/mcp/wrangler.toml` under
48
+ `[[r2_buckets]]`. The pipeline reads/writes via the wrangler CLI in CI,
49
+ not via the Worker — A0-cloud invariant 2 forbids the Worker from
50
+ issuing R2 writes.
51
+
52
+ ## Secrets in CI
53
+
54
+ The deploy pipeline needs two GitHub secrets:
55
+
56
+ | Secret | Scope |
57
+ |---|---|
58
+ | `CLOUDFLARE_API_TOKEN` | `Workers Scripts:Edit` + `Workers R2 Storage:Edit` |
59
+ | `CLOUDFLARE_ACCOUNT_ID` | account id (not a token) |
60
+
61
+ The token must be scoped to **this account only**; do not reuse a
62
+ production-tenant token.
63
+
64
+ ## Manual fixes — break-glass
65
+
66
+ If `latest.txt` ends up pointing to a broken release, the recovery is to
67
+ repoint it manually:
68
+
69
+ ```sh
70
+ # Repoint latest to a known-good release.
71
+ echo -n "v1.36.0-abc1234" | \
72
+ npx wrangler r2 object put agent-config-mcp/releases/latest.txt --pipe
73
+ ```
74
+
75
+ Past `releases/v*/` directories are not deleted in recovery — leave the
76
+ forensics intact.
77
+
78
+ ## Terraform note
79
+
80
+ The repo does not yet manage CF resources via Terraform. When TF lands,
81
+ this bucket should move into a `cloudflare_r2_bucket` resource and this
82
+ doc shrinks to a pointer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,168 @@
1
+ """Centralized loader for ``.agent-settings.yml`` with user-global fallback.
2
+
3
+ Phase 1 of road-to-portable-dev-preferences. Single source of truth for
4
+ how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
5
+
6
+ Resolution order (project wins, user-global fills gaps for whitelisted
7
+ keys only):
8
+
9
+ 1. Project ``./.agent-settings.yml`` (full file, all keys)
10
+ 2. ``~/.config/agent-config/agent-settings.yml`` (whitelist only)
11
+ 3. Built-in defaults (currently empty)
12
+
13
+ Whitelisted keys (``MERGEABLE_KEYS``) are exact dotted paths. A
14
+ non-whitelisted key in the user-global file is silently ignored — the
15
+ ``verbose=True`` flag surfaces ignored paths via ``logging.info`` for
16
+ debugging.
17
+
18
+ Contract — pure, read-only, tolerant:
19
+
20
+ * Lazy PyYAML import; no yaml installed → defaults returned.
21
+ * Missing project file → user-global + defaults.
22
+ * Missing user-global file → project + defaults.
23
+ * Both missing → defaults.
24
+ * Malformed YAML / unreadable file → defaults, logged at WARNING.
25
+ * No file is ever created or written by this module.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ DEFAULT_PROJECT_FILE = ".agent-settings.yml"
36
+ DEFAULT_USER_GLOBAL_FILE = (
37
+ Path.home() / ".config" / "agent-config" / "agent-settings.yml"
38
+ )
39
+
40
+ #: Exact dotted paths allowed to cascade from user-global into the merged
41
+ #: settings. Anything not listed here is silently ignored when present in
42
+ #: the user-global file. Adding a key requires an ADR — see
43
+ #: ``agents/roadmaps/road-to-portable-dev-preferences.md``.
44
+ MERGEABLE_KEYS: tuple[str, ...] = (
45
+ "name",
46
+ "ide",
47
+ "cost_profile",
48
+ "personal.bot_icon",
49
+ "personal.autonomy",
50
+ "caveman.speak_scope",
51
+ )
52
+
53
+ _DEFAULTS: dict[str, Any] = {}
54
+
55
+
56
+ def load_agent_settings(
57
+ project_path: Path | str | None = None,
58
+ user_global_path: Path | str | None = None,
59
+ verbose: bool = False,
60
+ ) -> dict[str, Any]:
61
+ """Return the merged settings dict.
62
+
63
+ ``project_path`` defaults to ``./.agent-settings.yml`` (CWD-relative).
64
+ ``user_global_path`` defaults to
65
+ ``~/.config/agent-config/agent-settings.yml``. Both arguments accept
66
+ ``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
67
+ user-global that are not on the whitelist.
68
+ """
69
+ project = _read_yaml(
70
+ Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE),
71
+ ) or {}
72
+ user_global_raw = _read_yaml(
73
+ Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE,
74
+ ) or {}
75
+
76
+ user_global_filtered, ignored = _filter_whitelist(
77
+ user_global_raw, MERGEABLE_KEYS,
78
+ )
79
+ if verbose and ignored:
80
+ logger.info(
81
+ "agent_settings: ignored non-whitelisted user-global keys: %s",
82
+ sorted(ignored),
83
+ )
84
+
85
+ merged: dict[str, Any] = _deep_copy_defaults(_DEFAULTS)
86
+ _deep_merge(merged, user_global_filtered)
87
+ _deep_merge(merged, project)
88
+ return merged
89
+
90
+
91
+ def _read_yaml(path: Path) -> dict[str, Any] | None:
92
+ """Best-effort YAML read; never raises. Returns ``None`` on any failure."""
93
+ if not path.is_file():
94
+ return None
95
+ try:
96
+ import yaml # type: ignore[import-untyped]
97
+ except ImportError:
98
+ return None
99
+ try:
100
+ with path.open(encoding="utf-8") as fh:
101
+ data = yaml.safe_load(fh) or {}
102
+ except (OSError, yaml.YAMLError):
103
+ logger.warning("agent_settings: unreadable or malformed YAML at %s", path)
104
+ return None
105
+ return data if isinstance(data, dict) else None
106
+
107
+
108
+ def _filter_whitelist(
109
+ raw: dict[str, Any], allowed: tuple[str, ...],
110
+ ) -> tuple[dict[str, Any], list[str]]:
111
+ """Return ``(filtered_dict, ignored_paths)`` from a user-global blob."""
112
+ filtered: dict[str, Any] = {}
113
+ for dotted in allowed:
114
+ value = _get_dotted(raw, dotted)
115
+ if value is not None:
116
+ _set_dotted(filtered, dotted, value)
117
+ ignored = [p for p in _leaf_paths(raw) if p not in allowed]
118
+ return filtered, ignored
119
+
120
+
121
+ def _get_dotted(data: dict[str, Any], dotted: str) -> Any:
122
+ cursor: Any = data
123
+ for part in dotted.split("."):
124
+ if not isinstance(cursor, dict) or part not in cursor:
125
+ return None
126
+ cursor = cursor[part]
127
+ return cursor
128
+
129
+
130
+ def _set_dotted(target: dict[str, Any], dotted: str, value: Any) -> None:
131
+ parts = dotted.split(".")
132
+ cursor = target
133
+ for part in parts[:-1]:
134
+ nxt = cursor.setdefault(part, {})
135
+ if not isinstance(nxt, dict):
136
+ nxt = {}
137
+ cursor[part] = nxt
138
+ cursor = nxt
139
+ cursor[parts[-1]] = value
140
+
141
+
142
+ def _leaf_paths(data: dict[str, Any], prefix: str = "") -> list[str]:
143
+ paths: list[str] = []
144
+ for key, value in data.items():
145
+ path = f"{prefix}.{key}" if prefix else key
146
+ if isinstance(value, dict) and value:
147
+ paths.extend(_leaf_paths(value, path))
148
+ else:
149
+ paths.append(path)
150
+ return paths
151
+
152
+
153
+ def _deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> None:
154
+ """Merge ``src`` into ``dst`` in-place; nested dicts are merged recursively."""
155
+ for key, value in src.items():
156
+ if (
157
+ isinstance(value, dict)
158
+ and isinstance(dst.get(key), dict)
159
+ ):
160
+ _deep_merge(dst[key], value)
161
+ else:
162
+ dst[key] = value
163
+
164
+
165
+ def _deep_copy_defaults(src: dict[str, Any]) -> dict[str, Any]:
166
+ out: dict[str, Any] = {}
167
+ _deep_merge(out, src)
168
+ return out