@event4u/agent-config 1.39.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.agent-src/commands/orchestrate.md +123 -0
  2. package/.agent-src/commands/sync-gitignore/fix.md +135 -0
  3. package/.agent-src/commands/sync-gitignore.md +31 -5
  4. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +30 -2
  5. package/.agent-src/skills/subagent-orchestration/SKILL.md +9 -0
  6. package/.agent-src/skills/using-git-worktrees/SKILL.md +25 -0
  7. package/.agent-src/templates/agent-settings.md +9 -0
  8. package/.agent-src/templates/scripts/work_engine/orchestration.py +168 -0
  9. package/.claude-plugin/marketplace.json +3 -1
  10. package/CHANGELOG.md +75 -0
  11. package/README.md +52 -26
  12. package/bin/install.php +13 -6
  13. package/config/agent-settings.template.yml +21 -0
  14. package/docs/DISTRIBUTION_CHECKLIST.md +169 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/catalog.md +5 -3
  17. package/docs/contracts/audit-log-v1.md +142 -0
  18. package/docs/contracts/command-clusters.md +2 -0
  19. package/docs/contracts/file-ownership-matrix.json +47 -0
  20. package/docs/contracts/mcp-discovery-phase-notice.md +56 -0
  21. package/docs/contracts/mcp-tool-stub-envelope.md +78 -0
  22. package/docs/contracts/orchestration-dsl-v1.md +152 -0
  23. package/docs/getting-started.md +1 -1
  24. package/docs/installation.md +132 -0
  25. package/docs/setup/mcp-client-config.md +94 -13
  26. package/docs/setup/mcp-cloud-setup.md +32 -1
  27. package/docs/setup/per-ide/aider.md +48 -0
  28. package/docs/setup/per-ide/claude-code.md +108 -0
  29. package/docs/setup/per-ide/claude-desktop.md +173 -0
  30. package/docs/setup/per-ide/cline.md +43 -0
  31. package/docs/setup/per-ide/codex.md +46 -0
  32. package/docs/setup/per-ide/copilot.md +80 -0
  33. package/docs/setup/per-ide/cursor.md +125 -0
  34. package/docs/setup/per-ide/gemini-cli.md +45 -0
  35. package/docs/setup/per-ide/windsurf.md +120 -0
  36. package/package.json +1 -1
  37. package/scripts/_lib/script_output.py +15 -11
  38. package/scripts/ai_council/session.py +14 -8
  39. package/scripts/chat_history.py +29 -53
  40. package/scripts/command_suggester/settings.py +15 -13
  41. package/scripts/compile_router.py +13 -9
  42. package/scripts/compress.py +175 -20
  43. package/scripts/council_cli.py +9 -3
  44. package/scripts/extract_audit_patterns.py +202 -0
  45. package/scripts/install +156 -1
  46. package/scripts/install.py +270 -10
  47. package/scripts/install.sh +52 -7
  48. package/scripts/lint_orchestration_dsl.py +214 -0
  49. package/scripts/mcp_parity_smoke.py +20 -2
  50. package/scripts/mcp_server/catalog.py +125 -0
  51. package/scripts/mcp_server/consumer_tool_catalog.json +275 -0
  52. package/scripts/mcp_server/telemetry.py +128 -0
  53. package/scripts/mcp_server/tools.py +474 -15
  54. package/scripts/mcp_telemetry_health.py +214 -0
  55. package/scripts/mcp_telemetry_query.py +203 -0
  56. package/scripts/mcp_telemetry_store.py +211 -0
  57. package/scripts/memory_signal.py +12 -10
  58. package/scripts/pack_mcp_content.py +18 -4
  59. package/scripts/skill_linter.py +9 -0
  60. package/scripts/sync_gitignore.py +56 -1
  61. package/templates/claude_desktop_config.json.template +22 -0
  62. package/templates/cursor-rule.mdc.j2 +7 -0
  63. package/templates/global-install-manifest.yml +91 -0
  64. package/templates/marketing-copy.yml +64 -0
  65. package/templates/windsurf-rule.md.j2 +7 -0
@@ -0,0 +1,80 @@
1
+ # GitHub Copilot Setup
2
+
3
+ GitHub Copilot Chat (VS Code, JetBrains, Neovim, `gh copilot` CLI)
4
+ reads `.github/copilot-instructions.md` for project-level guidance and
5
+ falls back to `AGENTS.md` where supported.
6
+
7
+ ## Prerequisites
8
+
9
+ - GitHub Copilot subscription (Individual, Business, or Enterprise).
10
+ - Copilot Chat enabled in your IDE.
11
+ - Node.js ≥ 18 for the install entrypoints.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npx @event4u/create-agent-config init --tools=copilot
17
+ ```
18
+
19
+ Populates:
20
+
21
+ - `.github/copilot-instructions.md` — Copilot's project-level prompt
22
+ - `AGENTS.md` — canonical agent self-orientation
23
+ - `.agent-settings.yml` — per-project knobs
24
+
25
+ The package keeps `.github/copilot-instructions.md` deliberately thin
26
+ (it points back to `AGENTS.md`) so all surfaces share a single source
27
+ of truth.
28
+
29
+ ## VS Code Copilot Chat
30
+
31
+ Auto-loads `.github/copilot-instructions.md` once you reload the VS
32
+ Code window after install. Verify in the Copilot Chat panel —
33
+ *"What is this repo?"* should answer using the AGENTS.md emergency
34
+ triage block.
35
+
36
+ ## JetBrains Copilot
37
+
38
+ JetBrains Copilot 1.5+ reads the same `.github/copilot-instructions.md`
39
+ file. No extra steps; reload the project after install.
40
+
41
+ ## Neovim Copilot
42
+
43
+ `copilot.lua` and `CopilotChat.nvim` honor
44
+ `.github/copilot-instructions.md`. No extra config needed.
45
+
46
+ ## `gh copilot` CLI
47
+
48
+ The `gh copilot` plugin (`gh extension install github/gh-copilot`)
49
+ reads the repo context including `AGENTS.md` and
50
+ `.github/copilot-instructions.md` when invoked from the repo root.
51
+
52
+ ## Suppressing Copilot PR review noise
53
+
54
+ Copilot's PR auto-review can flag the package's own kernel rules as
55
+ "unusual phrasing". The package ships a Copilot-suppression rule
56
+ ([`augment-portability`](../../../.augment/rules/augment-portability.md))
57
+ that documents this trade-off.
58
+
59
+ ## Verification
60
+
61
+ ```bash
62
+ test -f .github/copilot-instructions.md
63
+ test -f AGENTS.md
64
+ gh copilot --version # if you want CLI plugin
65
+ ```
66
+
67
+ ## Troubleshooting
68
+
69
+ | Symptom | Fix |
70
+ |---|---|
71
+ | Copilot ignores the file | Reload the IDE window after install. |
72
+ | File missing after install | Re-run `npx @event4u/create-agent-config init --tools=copilot`. |
73
+ | Copilot PR review too noisy | See the `copilot-config` skill for suppression patterns. |
74
+
75
+ ## Cross-references
76
+
77
+ - [`AGENTS.md`](../../../AGENTS.md) — canonical agent self-orientation.
78
+ - [`.augment/skills/copilot-config/SKILL.md`](../../../.augment/skills/copilot-config/SKILL.md)
79
+ — tuning Copilot output and suppressing review noise.
80
+ - [`docs/installation.md`](../../installation.md) — install matrix index.
@@ -0,0 +1,125 @@
1
+ # Cursor Setup
2
+
3
+ Cursor reads two rule formats:
4
+
5
+ - **Modern (`.mdc`)** — `.cursor/rules/<rule>.mdc` with YAML frontmatter
6
+ (`description`, `globs`, `alwaysApply`). Preferred for any 2025+
7
+ Cursor build.
8
+ - **Legacy (`.cursorrules`)** — single-file aggregate at the repo root.
9
+ Still read by older Cursor versions; the package keeps it for
10
+ backward compatibility.
11
+
12
+ The package ships **both** so you don't have to pick.
13
+
14
+ ## Prerequisites
15
+
16
+ - Cursor 0.45+ (any 2025/2026 build): <https://cursor.com>.
17
+ - Node.js ≥ 18.
18
+
19
+ ## Project install
20
+
21
+ ```bash
22
+ npx @event4u/create-agent-config init --tools=cursor
23
+ ```
24
+
25
+ This populates:
26
+
27
+ - `.cursor/rules/*.mdc` — one file per rule, modern frontmatter format
28
+ - `.cursor/commands/*.md` — slash commands mirrored from `.agent-src/commands/`
29
+ - `.cursorrules` — legacy single-file aggregate
30
+ - `.agent-settings.yml` — per-project knobs
31
+
32
+ Combine surfaces if you use both Cursor and Claude Code:
33
+
34
+ ```bash
35
+ npx @event4u/create-agent-config init --tools=cursor,claude-code
36
+ ```
37
+
38
+ ## Global install
39
+
40
+ ```bash
41
+ npx @event4u/agent-config global --tools=cursor
42
+ ```
43
+
44
+ Seeds `~/.cursor/rules/imported/event4u/` with the curated kernel +
45
+ top-N skills. Cursor merges global + workspace rules — workspace wins
46
+ on conflicts.
47
+
48
+ ## Modern `.mdc` frontmatter
49
+
50
+ Each `.mdc` file has the Cursor-shaped header:
51
+
52
+ ```mdc
53
+ ---
54
+ description: Scope control — no unsolicited architectural changes
55
+ globs:
56
+ alwaysApply: true
57
+ ---
58
+
59
+ # Scope Control
60
+ ...
61
+ ```
62
+
63
+ - `alwaysApply: true` ↔ source `type: "always"` (kernel rules).
64
+ - `alwaysApply: false` ↔ Cursor model decides per turn (auto rules).
65
+ - `globs:` is intentionally empty in the package's projection — apply
66
+ per-rule if you need path-scoped rules in your fork.
67
+
68
+ ## Cursor commands
69
+
70
+ `.cursor/commands/<slug>.md` mirrors `.claude/commands/`. Nested
71
+ clusters (e.g. `council/default.md`) flatten to `council-default.md` so
72
+ Cursor's command palette stays flat.
73
+
74
+ ## Marketplace install (planned — Phase 7 / S35)
75
+
76
+ The Cursor marketplace listing is filed in
77
+ `road-to-simplicity-and-everywhere.md` Phase 7. Once accepted you'll
78
+ be able to install via Cursor's Extensions panel without `npx`.
79
+
80
+ ## MCP block (when MCP Phase 3 ships)
81
+
82
+ Add to `.cursor/mcp.json` (Cursor's project-scoped MCP config):
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "event4u-agent-config": {
88
+ "command": "npx",
89
+ "args": ["-y", "@event4u/agent-config-mcp"]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ Track <https://github.com/event4u-app/agent-config> for the actual
96
+ release tag — until `road-to-mcp-full-coverage` Phase 3 ships, this
97
+ block is informational.
98
+
99
+ ## Verification
100
+
101
+ ```bash
102
+ ls -la .cursor/rules/ | head -5 # *.mdc files exist
103
+ ls -la .cursor/commands/| head -5 # *.md command files exist
104
+ test -f .cursorrules # legacy aggregate exists
105
+ ```
106
+
107
+ In Cursor itself: open the chat panel — settings should show the rules
108
+ under **Project Rules**.
109
+
110
+ ## Troubleshooting
111
+
112
+ | Symptom | Fix |
113
+ |---|---|
114
+ | Rules not picked up | Cursor < 0.45 — upgrade or rely on `.cursorrules`. |
115
+ | Modern + legacy duplicate triggers | Disable `.cursorrules` in Cursor settings. |
116
+ | Command missing in palette | `task generate-tools` then reload Cursor window. |
117
+ | Global rules ignored | Cursor needs `~/.cursor/rules/` — check OS path expansion. |
118
+
119
+ ## Cross-references
120
+
121
+ - [`docs/installation.md`](../../installation.md) — install matrix index.
122
+ - [`AGENTS.md`](../../../AGENTS.md) — package self-orientation; Cursor
123
+ reads it via the projected rules.
124
+ - [`templates/cursor-rule.mdc.j2`](../../../templates/cursor-rule.mdc.j2) —
125
+ template used by the projection generator.
@@ -0,0 +1,45 @@
1
+ # Gemini CLI Setup
2
+
3
+ Google's Gemini CLI reads `GEMINI.md` (which is a symlink to `AGENTS.md`
4
+ in the package's projection) for project context.
5
+
6
+ ## Prerequisites
7
+
8
+ - Gemini CLI installed: <https://github.com/google-gemini/gemini-cli>.
9
+ - Node.js ≥ 18 (for the install entrypoints).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npx @event4u/create-agent-config init --tools=gemini
15
+ ```
16
+
17
+ Populates:
18
+
19
+ - `GEMINI.md` → `AGENTS.md` — symlink so Gemini CLI loads the same
20
+ self-orientation as Codex / Aider / Augment.
21
+ - `AGENTS.md` — canonical content (single source of truth).
22
+ - `.agent-settings.yml` — per-project knobs.
23
+
24
+ ## Verification
25
+
26
+ ```bash
27
+ test -L GEMINI.md && readlink GEMINI.md # → AGENTS.md
28
+ gemini --version # confirm CLI installed
29
+ ```
30
+
31
+ In a Gemini CLI session: `GEMINI.md` informs every turn — verify by
32
+ asking *"what is this repo?"* and confirming the answer matches
33
+ `AGENTS.md`'s emergency-triage block.
34
+
35
+ ## Troubleshooting
36
+
37
+ | Symptom | Fix |
38
+ |---|---|
39
+ | Gemini CLI doesn't see `GEMINI.md` | Some Gemini versions require absolute paths — `gemini --context $(pwd)/GEMINI.md`. |
40
+ | Symlink broken on Windows | Re-run installer; on Windows the projection may emit a copy instead of a symlink. |
41
+
42
+ ## Cross-references
43
+
44
+ - [`AGENTS.md`](../../../AGENTS.md) — canonical agent self-orientation.
45
+ - [`docs/installation.md`](../../installation.md) — install matrix index.
@@ -0,0 +1,120 @@
1
+ # Windsurf Setup
2
+
3
+ Windsurf reads two rule formats:
4
+
5
+ - **Wave-8 (`.windsurf/rules/`)** — per-rule `.md` files with
6
+ `trigger`, `description`, `globs` frontmatter. Preferred for
7
+ Windsurf 1.5+.
8
+ - **Legacy (`.windsurfrules`)** — single-file aggregate at the repo
9
+ root. Older Windsurf builds and the Cascade chat fallback both still
10
+ read it.
11
+
12
+ The package ships **both**.
13
+
14
+ ## Prerequisites
15
+
16
+ - Windsurf 1.0+ (Codeium): <https://codeium.com/windsurf>.
17
+ - Node.js ≥ 18.
18
+
19
+ ## Project install
20
+
21
+ ```bash
22
+ npx @event4u/create-agent-config init --tools=windsurf
23
+ ```
24
+
25
+ Populates:
26
+
27
+ - `.windsurf/rules/*.md` — modern Wave-8 per-rule files
28
+ - `.windsurf/workflows/*.md` — slash-command workflows
29
+ - `.windsurfrules` — legacy single-file aggregate
30
+ - `.agent-settings.yml` — per-project knobs
31
+
32
+ Combine with other surfaces:
33
+
34
+ ```bash
35
+ npx @event4u/create-agent-config init --tools=windsurf,claude-code,cursor
36
+ ```
37
+
38
+ ## Global install
39
+
40
+ ```bash
41
+ npx @event4u/agent-config global --tools=windsurf
42
+ ```
43
+
44
+ Seeds `~/.codeium/windsurf/global_workflows/` with the curated
45
+ workflow set (see [`templates/global-install-manifest.yml`](../../../templates/global-install-manifest.yml)).
46
+ Available across every project; per-workspace `.windsurf/workflows/`
47
+ takes precedence on slug collisions.
48
+
49
+ ## Wave-8 frontmatter
50
+
51
+ Each rule under `.windsurf/rules/` has the Windsurf-shaped header:
52
+
53
+ ```md
54
+ ---
55
+ trigger: always_on
56
+ description: Scope control — no unsolicited architectural changes
57
+ globs:
58
+ ---
59
+
60
+ # Scope Control
61
+ ...
62
+ ```
63
+
64
+ - `trigger: always_on` ↔ source `type: "always"` (kernel rules).
65
+ - `trigger: model_decision` ↔ Cascade decides per turn (auto rules).
66
+ - `globs:` is intentionally empty in the package's projection — set
67
+ per-rule in your fork if you want path-scoped triggering.
68
+
69
+ ## Workflows
70
+
71
+ `.windsurf/workflows/<slug>.md` mirrors `.claude/commands/`. Cluster
72
+ commands flatten to `<cluster>-<name>.md`. Cascade lists all workflow
73
+ files in its workflow palette.
74
+
75
+ ## Cascade integration
76
+
77
+ Cascade (Windsurf's built-in agent) reads `.windsurf/rules/` and
78
+ `.windsurf/workflows/` automatically. No separate registration step is
79
+ needed once the files are on disk.
80
+
81
+ When Cascade asks a clarifying question, the package's `user-interaction`
82
+ rule (kernel, `always_on`) applies — Cascade will surface numbered
83
+ options with a single recommendation.
84
+
85
+ ## Workspace vs global precedence
86
+
87
+ | Layer | Path | Precedence |
88
+ |---|---|---|
89
+ | Workspace | `.windsurf/rules/` + `.windsurf/workflows/` | wins on conflicts |
90
+ | Global | `~/.codeium/windsurf/global_workflows/` | falls back when workspace silent |
91
+
92
+ Reuse the same `--tools=windsurf` flag for both — `init` writes
93
+ workspace, `global` writes user-level.
94
+
95
+ ## Verification
96
+
97
+ ```bash
98
+ ls .windsurf/rules/ | head -5 # *.md per-rule files
99
+ ls .windsurf/workflows/ | head -5 # *.md workflow files
100
+ test -f .windsurfrules # legacy aggregate exists
101
+ ```
102
+
103
+ In Windsurf itself: open Cascade → Workflows panel — listed workflows
104
+ should match `ls .windsurf/workflows/`.
105
+
106
+ ## Troubleshooting
107
+
108
+ | Symptom | Fix |
109
+ |---|---|
110
+ | Rules not picked up | Windsurf < 1.0 — upgrade or rely on `.windsurfrules`. |
111
+ | Workflow not in Cascade panel | Reload window after `task generate-tools`. |
112
+ | Global workflows missing | Check `~/.codeium/windsurf/global_workflows/` exists. |
113
+ | Frontmatter parse error | Re-run `python3 scripts/compress.py --generate-tools`. |
114
+
115
+ ## Cross-references
116
+
117
+ - [`docs/installation.md`](../../installation.md) — install matrix index.
118
+ - [`templates/windsurf-rule.md.j2`](../../../templates/windsurf-rule.md.j2)
119
+ — template used by the projection generator.
120
+ - [`AGENTS.md`](../../../AGENTS.md) — package self-orientation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.39.0",
3
+ "version": "1.41.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,
@@ -43,20 +43,24 @@ def _read_settings_level(settings_path: Path) -> str | None:
43
43
  """Read verbosity.script_output from .agent-settings.yml.
44
44
 
45
45
  Returns None when the file is missing, PyYAML is unavailable, or
46
- the key is absent. Errors fall through to the default level.
46
+ the key is absent. Errors fall through to the default level. Goes
47
+ through the centralized loader (road-to-portable-dev-preferences P3)
48
+ so the tolerance contract — missing file, malformed YAML, no PyYAML
49
+ — degrades uniformly across scripts. ``verbosity.script_output`` is
50
+ not on the user-global whitelist, so a value there is silently
51
+ ignored; the project file is the only source for this knob.
47
52
  """
48
- if not settings_path.is_file():
49
- return None
53
+ # Lazy import — supports both `python3 scripts/foo.py` (sys.path
54
+ # contains scripts/) and `pytest` (scripts._lib is a proper package).
50
55
  try:
51
- import yaml # type: ignore[import-untyped]
56
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found] # noqa: PLC0415
52
57
  except ImportError:
53
- return None
54
- try:
55
- with settings_path.open(encoding="utf-8") as fh:
56
- data = yaml.safe_load(fh) or {}
57
- except (OSError, yaml.YAMLError):
58
- return None
59
- section = data.get("verbosity") if isinstance(data, dict) else None
58
+ from scripts._lib.agent_settings import ( # noqa: PLC0415
59
+ load_agent_settings,
60
+ )
61
+
62
+ data = load_agent_settings(project_path=settings_path)
63
+ section = data.get("verbosity")
60
64
  if not isinstance(section, dict):
61
65
  return None
62
66
  value = section.get("script_output")
@@ -88,15 +88,21 @@ def _load_retention_days(settings_path: Path | None = None) -> int:
88
88
  file, invalid YAML, missing key, non-int value). Pruning never
89
89
  blocks the council on a settings error.
90
90
  """
91
- path = settings_path or SETTINGS_FILE
92
- if not path.exists():
93
- return DEFAULT_RETENTION_DAYS
91
+ # Centralized loader (road-to-portable-dev-preferences P3): tolerance
92
+ # contract handles missing file / malformed YAML / no PyYAML uniformly.
93
+ # ``ai_council.session_retention_days`` is not whitelisted, so the
94
+ # user-global file cannot override the project value.
94
95
  try:
95
- import yaml # type: ignore[import-not-found]
96
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
97
- except Exception: # noqa: BLE001 - never block on settings parse
98
- return DEFAULT_RETENTION_DAYS
99
- ai = data.get("ai_council") if isinstance(data, dict) else None
96
+ from scripts._lib.agent_settings import load_agent_settings
97
+ except ImportError: # pragma: no cover — script-style invocation
98
+ import sys as _sys
99
+ from pathlib import Path as _Path
100
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
101
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
102
+
103
+ path = settings_path or SETTINGS_FILE
104
+ data = load_agent_settings(project_path=path)
105
+ ai = data.get("ai_council")
100
106
  if not isinstance(ai, dict):
101
107
  return DEFAULT_RETENTION_DAYS
102
108
  raw = ai.get("session_retention_days", DEFAULT_RETENTION_DAYS)
@@ -609,27 +609,36 @@ def status(*, path: Path | None = None) -> dict[str, Any]:
609
609
  }
610
610
 
611
611
 
612
+ def _load_chat_history_section(settings_path: Path) -> dict | None:
613
+ """Return the ``chat_history`` mapping from .agent-settings.yml or None.
614
+
615
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
616
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
617
+ No ``chat_history.*`` keys are whitelisted, so user-global cannot
618
+ leak into this section — the project file remains authoritative.
619
+ """
620
+ try:
621
+ from scripts._lib.agent_settings import load_agent_settings
622
+ except ImportError: # pragma: no cover — script-style invocation
623
+ import sys as _sys
624
+ from pathlib import Path as _Path
625
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
626
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
627
+
628
+ data = load_agent_settings(project_path=settings_path)
629
+ section = data.get("chat_history")
630
+ return section if isinstance(section, dict) else None
631
+
632
+
612
633
  def _read_chat_history_enabled(settings_path: Path) -> bool:
613
634
  """Read chat_history.enabled from .agent-settings.yml.
614
635
 
615
636
  Returns False when the file is missing, malformed, lacks the
616
637
  `chat_history` section, or sets enabled to false. Default-deny so
617
638
  `turn-check` is safe to run from projects that have not opted in.
618
- PyYAML is imported lazily — the rest of this module works without it.
619
639
  """
620
- if not settings_path.is_file():
621
- return False
622
- try:
623
- import yaml # type: ignore[import-untyped]
624
- except ImportError:
625
- return True # fail open: settings file present but no parser
626
- try:
627
- with settings_path.open(encoding="utf-8") as fh:
628
- data = yaml.safe_load(fh) or {}
629
- except (OSError, yaml.YAMLError):
630
- return False
631
- section = data.get("chat_history") if isinstance(data, dict) else None
632
- if not isinstance(section, dict):
640
+ section = _load_chat_history_section(settings_path)
641
+ if section is None:
633
642
  return False
634
643
  return bool(section.get("enabled", False))
635
644
 
@@ -739,19 +748,8 @@ VALID_PLATFORMS = tuple(PLATFORM_EVENT_MAP.keys())
739
748
 
740
749
  def _read_chat_history_frequency(settings_path: Path) -> str:
741
750
  """Read chat_history.frequency from .agent-settings.yml. Default per_phase."""
742
- if not settings_path.is_file():
743
- return "per_phase"
744
- try:
745
- import yaml # type: ignore[import-untyped]
746
- except ImportError:
747
- return "per_phase"
748
- try:
749
- with settings_path.open(encoding="utf-8") as fh:
750
- data = yaml.safe_load(fh) or {}
751
- except (OSError, yaml.YAMLError):
752
- return "per_phase"
753
- section = data.get("chat_history") if isinstance(data, dict) else None
754
- if not isinstance(section, dict):
751
+ section = _load_chat_history_section(settings_path)
752
+ if section is None:
755
753
  return "per_phase"
756
754
  val = str(section.get("frequency", "per_phase")).lower()
757
755
  return val if val in VALID_FREQS else "per_phase"
@@ -764,19 +762,8 @@ def _read_chat_history_max_sessions(settings_path: Path) -> int:
764
762
  Used by ``prune_sessions`` to decide how many distinct ``s`` tags
765
763
  survive in the body.
766
764
  """
767
- if not settings_path.is_file():
768
- return DEFAULT_MAX_SESSIONS
769
- try:
770
- import yaml # type: ignore[import-untyped]
771
- except ImportError:
772
- return DEFAULT_MAX_SESSIONS
773
- try:
774
- with settings_path.open(encoding="utf-8") as fh:
775
- data = yaml.safe_load(fh) or {}
776
- except (OSError, yaml.YAMLError):
777
- return DEFAULT_MAX_SESSIONS
778
- section = data.get("chat_history") if isinstance(data, dict) else None
779
- if not isinstance(section, dict):
765
+ section = _load_chat_history_section(settings_path)
766
+ if section is None:
780
767
  return DEFAULT_MAX_SESSIONS
781
768
  try:
782
769
  n = int(section.get("max_sessions", DEFAULT_MAX_SESSIONS))
@@ -794,19 +781,8 @@ def _read_text_limits(settings_path: Path) -> dict[str, int]:
794
781
  values are clamped to 0. Non-int values are silently dropped.
795
782
  """
796
783
  out = dict(DEFAULT_TEXT_LIMITS)
797
- if not settings_path.is_file():
798
- return out
799
- try:
800
- import yaml # type: ignore[import-untyped]
801
- except ImportError:
802
- return out
803
- try:
804
- with settings_path.open(encoding="utf-8") as fh:
805
- data = yaml.safe_load(fh) or {}
806
- except (OSError, yaml.YAMLError):
807
- return out
808
- section = data.get("chat_history") if isinstance(data, dict) else None
809
- if not isinstance(section, dict):
784
+ section = _load_chat_history_section(settings_path)
785
+ if section is None:
810
786
  return out
811
787
  overrides = section.get("text_limits")
812
788
  if not isinstance(overrides, dict):
@@ -42,20 +42,22 @@ def load_settings(settings_path: Path | str | None = None) -> Settings:
42
42
 
43
43
 
44
44
  def _read_section(path: Path) -> dict[str, Any] | None:
45
- """Return the ``commands.suggestion`` mapping or ``None`` on any miss."""
46
- if not path.is_file():
47
- return None
48
- try:
49
- import yaml # type: ignore[import-untyped]
50
- except ImportError:
51
- return None
45
+ """Return the ``commands.suggestion`` mapping or ``None`` on any miss.
46
+
47
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
48
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
49
+ No ``commands.*`` keys are whitelisted, so user-global cannot cascade
50
+ into this section.
51
+ """
52
52
  try:
53
- with path.open(encoding="utf-8") as fh:
54
- data = yaml.safe_load(fh) or {}
55
- except (OSError, yaml.YAMLError):
56
- return None
57
- if not isinstance(data, dict):
58
- return None
53
+ from scripts._lib.agent_settings import load_agent_settings
54
+ except ImportError: # pragma: no cover — script-style invocation
55
+ import sys as _sys
56
+ from pathlib import Path as _Path
57
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
58
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
59
+
60
+ data = load_agent_settings(project_path=path)
59
61
  commands = data.get("commands")
60
62
  if not isinstance(commands, dict):
61
63
  return None
@@ -100,16 +100,20 @@ def _normalize_trigger(item) -> dict | None:
100
100
 
101
101
 
102
102
  def _load_settings() -> dict:
103
- """Read .agent-settings.yml for compile-time toggles. Stdlib-only fallback."""
104
- if not SETTINGS_PATH.exists():
105
- return {}
106
- text = SETTINGS_PATH.read_text(encoding="utf-8")
103
+ """Read .agent-settings.yml for compile-time toggles.
104
+
105
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
106
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
107
+ """
107
108
  try:
108
- import yaml # type: ignore
109
- data = yaml.safe_load(text) or {}
110
- return data if isinstance(data, dict) else {}
111
- except ImportError:
112
- return {}
109
+ from scripts._lib.agent_settings import load_agent_settings
110
+ except ImportError: # pragma: no cover — script-style invocation
111
+ import sys as _sys
112
+ from pathlib import Path as _Path
113
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
114
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
115
+
116
+ return load_agent_settings(project_path=SETTINGS_PATH)
113
117
 
114
118
 
115
119
  def _collect(rules_dir: Path) -> dict: