@event4u/agent-config 2.2.2 → 2.4.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/onboard.md +14 -9
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/skills/ai-council/SKILL.md +5 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
- package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +79 -0
- package/README.md +1 -8
- package/config/agent-settings.template.yml +5 -3
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -3
- package/docs/contracts/installed-tools-lockfile.md +142 -0
- package/docs/customization.md +23 -17
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
- package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +6 -4
- package/docs/installation.md +17 -2
- package/docs/migration/v1-to-v2.md +45 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/claude-desktop.md +107 -65
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +30 -4
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/agent_settings.py +29 -7
- package/scripts/_lib/agents_overlay.py +15 -4
- package/scripts/_lib/claude_desktop_bundler.py +150 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_lock.py +37 -4
- package/scripts/_lib/installed_tools.py +189 -45
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/_lib/update_check.py +29 -5
- package/scripts/_lib/user_global_paths.py +249 -0
- package/scripts/agent-config +69 -0
- package/scripts/ai_council/__init__.py +4 -3
- package/scripts/ai_council/budget_guard.py +34 -4
- package/scripts/ai_council/bundler.py +2 -0
- package/scripts/ai_council/clients.py +28 -7
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1149 -53
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- package/scripts/skill_trigger_eval.py +13 -2
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Vendor-namespaced user-global path resolution.
|
|
2
|
+
|
|
3
|
+
Phase 1 of road-to-event4u-namespace-and-claude-desktop.md. Single source
|
|
4
|
+
of truth for "where does this package keep user-global state on disk?".
|
|
5
|
+
Replaces hard-coded ``~/.config/agent-config/`` literals scattered across
|
|
6
|
+
``scripts/_lib`` and ``scripts/ai_council``.
|
|
7
|
+
|
|
8
|
+
Resolution order:
|
|
9
|
+
|
|
10
|
+
1. ``$EVENT4U_CONFIG_HOME`` — full path override (testing + power users).
|
|
11
|
+
2. ``~/.event4u/agent-config/`` — vendor-namespaced source-of-truth.
|
|
12
|
+
|
|
13
|
+
For backward compatibility during the transition, ``legacy_xdg_root()``
|
|
14
|
+
exposes the old ``~/.config/agent-config/`` path so loaders can read
|
|
15
|
+
state written by pre-2.4 installs. Writers should never target the
|
|
16
|
+
legacy path; the auto-migration shim (Phase 3) copies state once into
|
|
17
|
+
the new namespace.
|
|
18
|
+
|
|
19
|
+
Contract — pure, read-only, never auto-creates directories.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
#: Marker suffix for in-progress entry copies during migration. A copy
|
|
29
|
+
#: that crashes mid-flight leaves ``<name><suffix><pid>`` behind so the
|
|
30
|
+
#: next run can clean it up before retrying — instead of treating a
|
|
31
|
+
#: partial subdir as a completed copy.
|
|
32
|
+
_PARTIAL_SUFFIX = ".event4u-partial-"
|
|
33
|
+
|
|
34
|
+
#: Environment variable that overrides ``event4u_root()`` outright.
|
|
35
|
+
#: Accepts a full path (``~`` expanded). Primarily used by tests; power
|
|
36
|
+
#: users may also point this at a custom location.
|
|
37
|
+
EVENT4U_HOME_ENV = "EVENT4U_CONFIG_HOME"
|
|
38
|
+
|
|
39
|
+
#: Vendor-namespaced default. Relative to the user's home directory.
|
|
40
|
+
DEFAULT_EVENT4U_ROOT_RELATIVE = Path(".event4u") / "agent-config"
|
|
41
|
+
|
|
42
|
+
#: Legacy XDG-shaped default written by pre-2.4 installs. Read-only
|
|
43
|
+
#: fallback during the transition; never the target of a write.
|
|
44
|
+
LEGACY_XDG_ROOT_RELATIVE = Path(".config") / "agent-config"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def event4u_root(env: Optional[dict] = None) -> Path:
|
|
48
|
+
"""Return the active user-global root directory.
|
|
49
|
+
|
|
50
|
+
Honours ``EVENT4U_CONFIG_HOME`` first, falls back to
|
|
51
|
+
``~/.event4u/agent-config/``. Never creates the directory.
|
|
52
|
+
"""
|
|
53
|
+
env_map = env if env is not None else os.environ
|
|
54
|
+
override = env_map.get(EVENT4U_HOME_ENV)
|
|
55
|
+
if override:
|
|
56
|
+
return Path(override).expanduser()
|
|
57
|
+
return Path.home() / DEFAULT_EVENT4U_ROOT_RELATIVE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def legacy_xdg_root() -> Path:
|
|
61
|
+
"""Return the pre-2.4 user-global root at ``~/.config/agent-config/``.
|
|
62
|
+
|
|
63
|
+
Used by loaders during the transition to read settings, lockfiles,
|
|
64
|
+
and keys written before the namespace migration ran. Writers MUST
|
|
65
|
+
NOT target this path — only ``event4u_root()`` is a valid write
|
|
66
|
+
target. Never creates the directory.
|
|
67
|
+
"""
|
|
68
|
+
return Path.home() / LEGACY_XDG_ROOT_RELATIVE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_with_fallback(
|
|
72
|
+
relative_name: str,
|
|
73
|
+
*,
|
|
74
|
+
env: Optional[dict] = None,
|
|
75
|
+
) -> Optional[Path]:
|
|
76
|
+
"""Resolve a named file/dir under the user-global root, with legacy fallback.
|
|
77
|
+
|
|
78
|
+
Returns the new-namespace path if it exists on disk, otherwise the
|
|
79
|
+
legacy XDG path if it exists, otherwise ``None``. Callers that need
|
|
80
|
+
the *write target* (regardless of existence) should use
|
|
81
|
+
``event4u_root() / relative_name`` directly.
|
|
82
|
+
|
|
83
|
+
``relative_name`` is a forward-slash separated string (e.g.
|
|
84
|
+
``"installed.lock"`` or ``"agents/global"``). It is treated as a
|
|
85
|
+
path fragment relative to the chosen root; absolute paths are
|
|
86
|
+
rejected with ``ValueError``.
|
|
87
|
+
"""
|
|
88
|
+
fragment = Path(relative_name)
|
|
89
|
+
if fragment.is_absolute():
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"resolve_with_fallback expects a relative path, got {relative_name!r}"
|
|
92
|
+
)
|
|
93
|
+
new_path = event4u_root(env=env) / fragment
|
|
94
|
+
if new_path.exists():
|
|
95
|
+
return new_path
|
|
96
|
+
legacy_path = legacy_xdg_root() / fragment
|
|
97
|
+
if legacy_path.exists():
|
|
98
|
+
return legacy_path
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_target(relative_name: str, *, env: Optional[dict] = None) -> Path:
|
|
103
|
+
"""Return the canonical write target for a named user-global file/dir.
|
|
104
|
+
|
|
105
|
+
Always rooted at ``event4u_root()`` — writers never target the
|
|
106
|
+
legacy XDG path. Caller is responsible for ``mkdir(parents=True)``
|
|
107
|
+
on the parent before writing. Never creates the directory itself.
|
|
108
|
+
"""
|
|
109
|
+
fragment = Path(relative_name)
|
|
110
|
+
if fragment.is_absolute():
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"write_target expects a relative path, got {relative_name!r}"
|
|
113
|
+
)
|
|
114
|
+
return event4u_root(env=env) / fragment
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
#: Breadcrumb dropped into the legacy root after a successful migration.
|
|
118
|
+
#: Tells the user where their state now lives and how to clean up. The
|
|
119
|
+
#: legacy tree itself is never auto-deleted — only the user does that.
|
|
120
|
+
MIGRATION_BREADCRUMB_NAME = "MIGRATED.md"
|
|
121
|
+
|
|
122
|
+
_BREADCRUMB_TEMPLATE = """# Migrated to `~/.event4u/agent-config/`
|
|
123
|
+
|
|
124
|
+
This directory (`~/.config/agent-config/`) is the **legacy** location
|
|
125
|
+
for `event4u/agent-config` user-global state. As of v2.4 the canonical
|
|
126
|
+
location is `~/.event4u/agent-config/`.
|
|
127
|
+
|
|
128
|
+
The migration shim has already copied your settings, keys, lockfiles,
|
|
129
|
+
and overrides into the new namespace. File modes (0600 on keys) were
|
|
130
|
+
preserved. Loaders prefer the new path but still read from this tree
|
|
131
|
+
as a fallback, so removing it is safe **once you've confirmed** the
|
|
132
|
+
new location is working.
|
|
133
|
+
|
|
134
|
+
## To clean up
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
rm -rf ~/.config/agent-config
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Why the move
|
|
141
|
+
|
|
142
|
+
`~/.config/` is a generic XDG-shaped directory shared by many tools.
|
|
143
|
+
`~/.event4u/agent-config/` is vendor-namespaced and avoids collisions
|
|
144
|
+
with unrelated CLIs. See
|
|
145
|
+
`agents/roadmaps/road-to-event4u-namespace-and-claude-desktop.md` for
|
|
146
|
+
the full rationale.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def migrate_legacy_namespace(
|
|
151
|
+
*,
|
|
152
|
+
env: Optional[dict] = None,
|
|
153
|
+
legacy_root_override: Optional[Path] = None,
|
|
154
|
+
) -> bool:
|
|
155
|
+
"""Copy pre-2.4 user-global state from legacy XDG root into the new namespace.
|
|
156
|
+
|
|
157
|
+
Idempotent and safe to call on every install / init. Returns ``True``
|
|
158
|
+
if a copy ran during this invocation, ``False`` when the migration
|
|
159
|
+
was already complete or there was nothing to migrate.
|
|
160
|
+
|
|
161
|
+
Contract:
|
|
162
|
+
|
|
163
|
+
- Never auto-deletes the legacy tree — that's the user's call (the
|
|
164
|
+
breadcrumb at ``~/.config/agent-config/MIGRATED.md`` documents it).
|
|
165
|
+
- Preserves file modes via ``shutil.copytree(..., copy_function=copy2)``
|
|
166
|
+
so 0600 key files stay 0600 after the copy.
|
|
167
|
+
- If the new root already exists with any content, the migration
|
|
168
|
+
treats it as already-done and only writes the breadcrumb (if
|
|
169
|
+
missing) — never overwrites new-namespace state.
|
|
170
|
+
- If the legacy root is missing or empty, the function is a no-op.
|
|
171
|
+
- Per-entry atomic write: each entry is copied to a sibling
|
|
172
|
+
``<name>.event4u-partial-<pid>`` and then ``os.replace``'d into
|
|
173
|
+
the final name. If a previous run crashed mid-``copytree``, the
|
|
174
|
+
leftover ``*.event4u-partial-*`` siblings are cleaned up at the
|
|
175
|
+
top of the next run before retrying — a partial directory is
|
|
176
|
+
never mistaken for a completed copy.
|
|
177
|
+
|
|
178
|
+
``legacy_root_override`` is for tests; production callers leave it ``None``.
|
|
179
|
+
"""
|
|
180
|
+
legacy_root = (
|
|
181
|
+
legacy_root_override if legacy_root_override is not None else legacy_xdg_root()
|
|
182
|
+
)
|
|
183
|
+
new_root = event4u_root(env=env)
|
|
184
|
+
|
|
185
|
+
if not legacy_root.exists() or not legacy_root.is_dir():
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
# Skip the migrated-breadcrumb itself when checking for content so a
|
|
189
|
+
# second invocation does not loop on its own marker.
|
|
190
|
+
legacy_entries = [
|
|
191
|
+
p for p in legacy_root.iterdir() if p.name != MIGRATION_BREADCRUMB_NAME
|
|
192
|
+
]
|
|
193
|
+
if not legacy_entries:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
# Real content check ignores partial-copy debris from a prior
|
|
197
|
+
# interrupted run; otherwise the breadcrumb would be written for
|
|
198
|
+
# a half-finished migration and retry would never run.
|
|
199
|
+
new_has_content = new_root.exists() and any(
|
|
200
|
+
not _is_partial_entry(p) for p in new_root.iterdir()
|
|
201
|
+
)
|
|
202
|
+
if new_has_content:
|
|
203
|
+
_ensure_breadcrumb(legacy_root)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
new_root.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
_purge_partial_entries(new_root)
|
|
208
|
+
|
|
209
|
+
for entry in legacy_entries:
|
|
210
|
+
target = new_root / entry.name
|
|
211
|
+
if target.exists():
|
|
212
|
+
continue
|
|
213
|
+
staging = new_root / f"{entry.name}{_PARTIAL_SUFFIX}{os.getpid()}"
|
|
214
|
+
if staging.exists():
|
|
215
|
+
_remove_path(staging)
|
|
216
|
+
if entry.is_dir():
|
|
217
|
+
shutil.copytree(entry, staging, copy_function=shutil.copy2)
|
|
218
|
+
else:
|
|
219
|
+
shutil.copy2(entry, staging)
|
|
220
|
+
os.replace(staging, target)
|
|
221
|
+
|
|
222
|
+
_ensure_breadcrumb(legacy_root)
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_partial_entry(path: Path) -> bool:
|
|
227
|
+
return _PARTIAL_SUFFIX in path.name
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _purge_partial_entries(new_root: Path) -> None:
|
|
231
|
+
"""Remove ``*.event4u-partial-*`` leftovers from a previous interrupted run."""
|
|
232
|
+
for entry in new_root.iterdir():
|
|
233
|
+
if _is_partial_entry(entry):
|
|
234
|
+
_remove_path(entry)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _remove_path(path: Path) -> None:
|
|
238
|
+
if path.is_dir() and not path.is_symlink():
|
|
239
|
+
shutil.rmtree(path)
|
|
240
|
+
else:
|
|
241
|
+
path.unlink(missing_ok=True)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ensure_breadcrumb(legacy_root: Path) -> None:
|
|
245
|
+
"""Write the ``MIGRATED.md`` breadcrumb into ``legacy_root`` if absent."""
|
|
246
|
+
breadcrumb = legacy_root / MIGRATION_BREADCRUMB_NAME
|
|
247
|
+
if breadcrumb.exists():
|
|
248
|
+
return
|
|
249
|
+
breadcrumb.write_text(_BREADCRUMB_TEMPLATE, encoding="utf-8")
|
package/scripts/agent-config
CHANGED
|
@@ -114,6 +114,24 @@ Commands:
|
|
|
114
114
|
validate Read-only drift detection on the manifest
|
|
115
115
|
(marker missing, scope divergence, version drift).
|
|
116
116
|
Exits 1 on drift. Flags: --quiet | --skip-version-check
|
|
117
|
+
uninstall Remove bridge markers (project) or lockfile
|
|
118
|
+
entries (global). Idempotent. User-deployed
|
|
119
|
+
content under ~/.<tool>/ is preserved unless
|
|
120
|
+
--purge is passed (destructive).
|
|
121
|
+
Flags: --global | --tools=<list> | --dry-run
|
|
122
|
+
| --purge | --force | --project=<path>
|
|
123
|
+
prune Remove project bridge markers not declared in
|
|
124
|
+
agents/installed-tools.lock (npm-prune style).
|
|
125
|
+
Hard-floors when lockfile is absent.
|
|
126
|
+
Flags: --dry-run | --json | --project=<path>
|
|
127
|
+
| --all-missing-lock
|
|
128
|
+
doctor Read-only drift report: manifest ↔ filesystem.
|
|
129
|
+
Lists missing, modified, and foreign files.
|
|
130
|
+
Exits 1 on drift, 2 on missing lockfile.
|
|
131
|
+
Flags: --json | --project=<path>
|
|
132
|
+
versions List available @event4u/agent-config versions
|
|
133
|
+
on npm. Marks the current pin and latest.
|
|
134
|
+
Flags: --offline | --limit=N | --json
|
|
117
135
|
help Show this help
|
|
118
136
|
--version, -V Print package version
|
|
119
137
|
|
|
@@ -151,6 +169,17 @@ Examples:
|
|
|
151
169
|
./agent-config sync --dry-run
|
|
152
170
|
./agent-config sync
|
|
153
171
|
./agent-config validate
|
|
172
|
+
./agent-config uninstall --tools=cursor --dry-run
|
|
173
|
+
./agent-config uninstall --global --tools=windsurf --purge
|
|
174
|
+
./agent-config prune --dry-run
|
|
175
|
+
./agent-config prune --json
|
|
176
|
+
./agent-config doctor
|
|
177
|
+
./agent-config doctor --json
|
|
178
|
+
./agent-config versions
|
|
179
|
+
./agent-config versions --limit=10
|
|
180
|
+
./agent-config versions --json
|
|
181
|
+
./agent-config init --offline --tools=claude-code,cursor --yes
|
|
182
|
+
./agent-config update --offline --to=2.2.0
|
|
154
183
|
|
|
155
184
|
All commands operate on the CURRENT DIRECTORY (your project root).
|
|
156
185
|
The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
|
|
@@ -596,6 +625,42 @@ cmd_validate() {
|
|
|
596
625
|
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
|
|
597
626
|
}
|
|
598
627
|
|
|
628
|
+
# `agent-config uninstall` — remove bridge markers (project) or lockfile
|
|
629
|
+
# entries (global). Idempotent. Pass `--purge` to also delete deployed
|
|
630
|
+
# content directories under user-scope anchors (destructive). See
|
|
631
|
+
# scripts/_cli/cmd_uninstall.py.
|
|
632
|
+
cmd_uninstall() {
|
|
633
|
+
require_python3
|
|
634
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_uninstall "$@"
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# `agent-config prune` — remove orphaned project bridge markers.
|
|
638
|
+
# Drift-cleanup sibling to `uninstall`: compares on-disk markers
|
|
639
|
+
# against agents/installed-tools.lock and unlinks anything not
|
|
640
|
+
# declared. Hard-floors when lockfile is absent. See
|
|
641
|
+
# scripts/_cli/cmd_prune.py.
|
|
642
|
+
cmd_prune() {
|
|
643
|
+
require_python3
|
|
644
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_prune "$@"
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
# `agent-config doctor` — read-only drift report against the manifest.
|
|
648
|
+
# Surfaces missing / modified / foreign files. Exit 0 clean, 1 drift,
|
|
649
|
+
# 2 manifest-absent. See scripts/_cli/cmd_doctor.py.
|
|
650
|
+
cmd_doctor() {
|
|
651
|
+
require_python3
|
|
652
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_doctor "$@"
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# `agent-config versions` — list available @event4u/agent-config versions
|
|
656
|
+
# on the npm registry. Marks the current pin (from .agent-settings.yml)
|
|
657
|
+
# and the latest published version. Offline-tolerant. See
|
|
658
|
+
# scripts/_cli/cmd_versions.py.
|
|
659
|
+
cmd_versions() {
|
|
660
|
+
require_python3
|
|
661
|
+
exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_versions "$@"
|
|
662
|
+
}
|
|
663
|
+
|
|
599
664
|
main() {
|
|
600
665
|
local cmd="${1-}"
|
|
601
666
|
[[ $# -gt 0 ]] && shift || true
|
|
@@ -641,6 +706,10 @@ main() {
|
|
|
641
706
|
export) cmd_export "$@" ;;
|
|
642
707
|
sync) cmd_sync "$@" ;;
|
|
643
708
|
validate) cmd_validate "$@" ;;
|
|
709
|
+
uninstall) cmd_uninstall "$@" ;;
|
|
710
|
+
prune) cmd_prune "$@" ;;
|
|
711
|
+
doctor) cmd_doctor "$@" ;;
|
|
712
|
+
versions) cmd_versions "$@" ;;
|
|
644
713
|
help|--help|-h|"") usage ;;
|
|
645
714
|
--version|-V) print_version ;;
|
|
646
715
|
*)
|
|
@@ -13,9 +13,10 @@ Architecture:
|
|
|
13
13
|
prompts.py — Neutrality system-prompt templates per input mode.
|
|
14
14
|
|
|
15
15
|
Trust boundary: this module makes networked, paid calls. Tokens come
|
|
16
|
-
exclusively from
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
exclusively from ``~/.event4u/agent-config/<provider>.key`` (mode 0600;
|
|
17
|
+
legacy ``~/.config/agent-config/<provider>.key`` is read as a fallback
|
|
18
|
+
for pre-2.4 installs). The module never edits files, never opens PRs,
|
|
19
|
+
never merges — output is text only, advisory.
|
|
19
20
|
"""
|
|
20
21
|
|
|
21
22
|
from scripts.ai_council.clients import (
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Adds a 24h-rolling-window USD limit on top of the per-session caps in
|
|
4
4
|
`orchestrator.CostBudget`. Persists a small JSONL ledger in
|
|
5
|
-
``~/.
|
|
6
|
-
permission discipline as the API keys).
|
|
5
|
+
``~/.event4u/agent-config/council-spend.jsonl`` (mode 0600, same
|
|
6
|
+
permission discipline as the API keys). The legacy
|
|
7
|
+
``~/.config/agent-config/council-spend.jsonl`` is read as a fallback so
|
|
8
|
+
spend history accumulated under a pre-2.4 install is preserved across
|
|
9
|
+
the namespace migration; new entries are always appended to the new
|
|
10
|
+
path.
|
|
7
11
|
|
|
8
12
|
Contract
|
|
9
13
|
- The ledger is **append-only**. Each line is ``{"ts": ISO-8601 UTC,
|
|
@@ -31,10 +35,34 @@ import sys
|
|
|
31
35
|
from dataclasses import dataclass
|
|
32
36
|
from pathlib import Path
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
from scripts._lib import user_global_paths
|
|
39
|
+
|
|
40
|
+
LEDGER_FILENAME = "council-spend.jsonl"
|
|
41
|
+
|
|
42
|
+
#: Canonical write target under the new namespace. Reads route via
|
|
43
|
+
#: :func:`_resolve_ledger_path` so a ledger still sitting in the legacy
|
|
44
|
+
#: ``~/.config/agent-config/`` tree keeps contributing to the rolling
|
|
45
|
+
#: window until the user migrates.
|
|
46
|
+
LEDGER_PATH = user_global_paths.write_target(LEDGER_FILENAME)
|
|
35
47
|
ROLLING_WINDOW_HOURS = 24
|
|
36
48
|
|
|
37
49
|
|
|
50
|
+
def _resolve_ledger_path(path: Path | None) -> Path:
|
|
51
|
+
"""Return the active ledger path, preferring the new namespace.
|
|
52
|
+
|
|
53
|
+
A caller-supplied ``path`` always wins (tests pin a tmp file). When
|
|
54
|
+
no override is given we prefer the new namespace, then fall back to
|
|
55
|
+
the legacy location if a ledger file already exists there. New
|
|
56
|
+
writes always target the new namespace via :data:`LEDGER_PATH`.
|
|
57
|
+
"""
|
|
58
|
+
if path is not None:
|
|
59
|
+
return path
|
|
60
|
+
found = user_global_paths.resolve_with_fallback(LEDGER_FILENAME)
|
|
61
|
+
if found is not None:
|
|
62
|
+
return found
|
|
63
|
+
return LEDGER_PATH
|
|
64
|
+
|
|
65
|
+
|
|
38
66
|
@dataclass
|
|
39
67
|
class SpendEntry:
|
|
40
68
|
ts: _dt.datetime # UTC, tz-aware
|
|
@@ -87,8 +115,10 @@ def read_entries(path: Path | None = None) -> list[SpendEntry]:
|
|
|
87
115
|
"""Read every well-formed entry from the ledger.
|
|
88
116
|
|
|
89
117
|
Malformed lines are skipped silently. Empty/missing ledger → [].
|
|
118
|
+
Reads route through :func:`_resolve_ledger_path` so a legacy ledger
|
|
119
|
+
keeps contributing to the rolling window until the user migrates.
|
|
90
120
|
"""
|
|
91
|
-
p = path
|
|
121
|
+
p = _resolve_ledger_path(path)
|
|
92
122
|
if not p.exists():
|
|
93
123
|
return []
|
|
94
124
|
out: list[SpendEntry] = []
|
|
@@ -38,6 +38,8 @@ class CouncilContext:
|
|
|
38
38
|
# placeholder. Order matters — the most specific pattern goes first.
|
|
39
39
|
|
|
40
40
|
_REDACTION_LINE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
41
|
+
(re.compile(r"~?/?\.event4u/agent-config/[^/\s]+\.key"),
|
|
42
|
+
"[redacted: agent-config key path]"),
|
|
41
43
|
(re.compile(r"~?/?\.config/agent-config/[^/\s]+\.key"),
|
|
42
44
|
"[redacted: agent-config key path]"),
|
|
43
45
|
(re.compile(r"^\s*Authorization:\s", re.IGNORECASE),
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""External-AI clients for the council.
|
|
2
2
|
|
|
3
3
|
Mirrors the contract from `scripts/skill_trigger_eval.py`:
|
|
4
|
-
- Tokens come exclusively from
|
|
4
|
+
- Tokens come exclusively from ``~/.event4u/agent-config/<provider>.key``
|
|
5
|
+
(legacy ``~/.config/agent-config/<provider>.key`` is read as a
|
|
6
|
+
fallback so pre-2.4 installs keep working until the user moves the
|
|
7
|
+
files into the new namespace).
|
|
5
8
|
- File mode must be exactly 0o600. Drift is a hard abort.
|
|
6
9
|
- No environment-variable fallback. No keychain fallback.
|
|
7
10
|
- Real SDKs (`anthropic`, `openai`) are *soft* dependencies — the
|
|
@@ -28,8 +31,24 @@ from dataclasses import dataclass, field
|
|
|
28
31
|
from pathlib import Path
|
|
29
32
|
from typing import TextIO
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
from scripts._lib import user_global_paths
|
|
35
|
+
|
|
36
|
+
ANTHROPIC_KEY_FILENAME = "anthropic.key"
|
|
37
|
+
OPENAI_KEY_FILENAME = "openai.key"
|
|
38
|
+
|
|
39
|
+
#: Canonical write target under the new namespace. Reads route via
|
|
40
|
+
#: :func:`_resolve_key_path` so a key still sitting in the legacy
|
|
41
|
+
#: ``~/.config/agent-config/`` tree keeps working.
|
|
42
|
+
ANTHROPIC_KEY_PATH = user_global_paths.write_target(ANTHROPIC_KEY_FILENAME)
|
|
43
|
+
OPENAI_KEY_PATH = user_global_paths.write_target(OPENAI_KEY_FILENAME)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_key_path(filename: str) -> Path:
|
|
47
|
+
"""Return the active key path, preferring the new namespace."""
|
|
48
|
+
found = user_global_paths.resolve_with_fallback(filename)
|
|
49
|
+
if found is not None:
|
|
50
|
+
return found
|
|
51
|
+
return user_global_paths.write_target(filename)
|
|
33
52
|
|
|
34
53
|
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5"
|
|
35
54
|
DEFAULT_OPENAI_MODEL = "gpt-4o"
|
|
@@ -87,12 +106,14 @@ def _load_key(path: Path, prefix: str, install_script: str) -> str:
|
|
|
87
106
|
return key
|
|
88
107
|
|
|
89
108
|
|
|
90
|
-
def load_anthropic_key(path: Path =
|
|
91
|
-
|
|
109
|
+
def load_anthropic_key(path: Path | None = None) -> str:
|
|
110
|
+
resolved = path if path is not None else _resolve_key_path(ANTHROPIC_KEY_FILENAME)
|
|
111
|
+
return _load_key(resolved, "sk-ant-", "scripts/install_anthropic_key.sh")
|
|
92
112
|
|
|
93
113
|
|
|
94
|
-
def load_openai_key(path: Path =
|
|
95
|
-
|
|
114
|
+
def load_openai_key(path: Path | None = None) -> str:
|
|
115
|
+
resolved = path if path is not None else _resolve_key_path(OPENAI_KEY_FILENAME)
|
|
116
|
+
return _load_key(resolved, "sk-", "scripts/install_openai_key.sh")
|
|
96
117
|
|
|
97
118
|
|
|
98
119
|
class ExternalAIClient(ABC):
|
package/scripts/compress.py
CHANGED
|
@@ -19,6 +19,8 @@ Usage:
|
|
|
19
19
|
python scripts/compress.py --project-augment # rebuild .augment/ projection
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
22
24
|
import hashlib
|
|
23
25
|
import json
|
|
24
26
|
import re
|
|
@@ -38,6 +40,40 @@ AUGMENT_DIR = PROJECT_ROOT / ".augment"
|
|
|
38
40
|
HASH_FILE = PROJECT_ROOT / ".compression-hashes.json"
|
|
39
41
|
SETTINGS_FILE = PROJECT_ROOT / ".agent-settings.yml"
|
|
40
42
|
|
|
43
|
+
# Self-projection tool toggle — see .agent-tools.yml. When the file is
|
|
44
|
+
# absent (e.g. tests run in tmp dirs, consumer projects), `_active_tools`
|
|
45
|
+
# returns ``None`` which is treated as "emit every tool".
|
|
46
|
+
_ALL_TOOLS = frozenset({
|
|
47
|
+
"claude-code", "claude-desktop", "augment", "copilot",
|
|
48
|
+
"cursor", "windsurf", "cline", "gemini",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _active_tools() -> frozenset[str] | None:
|
|
53
|
+
"""Return the set of active self-projection tools, or None for "all".
|
|
54
|
+
|
|
55
|
+
Reads `.agent-tools.yml` relative to the current `PROJECT_ROOT` so
|
|
56
|
+
test fixtures that monkey-patch `compress.PROJECT_ROOT` see their own
|
|
57
|
+
(empty) project root and get the default "all tools" behaviour.
|
|
58
|
+
"""
|
|
59
|
+
tools_file = PROJECT_ROOT / ".agent-tools.yml"
|
|
60
|
+
if not tools_file.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
data = yaml.safe_load(tools_file.read_text()) or {}
|
|
64
|
+
except yaml.YAMLError:
|
|
65
|
+
return None
|
|
66
|
+
tools = data.get("tools") if isinstance(data, dict) else None
|
|
67
|
+
if not isinstance(tools, list):
|
|
68
|
+
return None
|
|
69
|
+
return frozenset(str(t) for t in tools if isinstance(t, str))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tool_active(tool_id: str) -> bool:
|
|
73
|
+
"""True when ``tool_id`` should be emitted by self-projection."""
|
|
74
|
+
active = _active_tools()
|
|
75
|
+
return True if active is None else tool_id in active
|
|
76
|
+
|
|
41
77
|
# Files to copy as-is even if .md (not compressed by agent)
|
|
42
78
|
COPY_AS_IS = {"README.md"}
|
|
43
79
|
|
|
@@ -306,6 +342,24 @@ PERSONA_TOOL_DIRS = {
|
|
|
306
342
|
".cursor/personas": "../../.agent-src/personas",
|
|
307
343
|
}
|
|
308
344
|
|
|
345
|
+
# Map tool-projection directories to the canonical tool ID used by
|
|
346
|
+
# `.agent-tools.yml`. Directories not in this map are always emitted.
|
|
347
|
+
_DIR_TOOL_ID = {
|
|
348
|
+
".claude/rules": "claude-code",
|
|
349
|
+
".cursor/rules": "cursor",
|
|
350
|
+
".clinerules": "cline",
|
|
351
|
+
".claude/personas": "claude-code",
|
|
352
|
+
".cursor/personas": "cursor",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _filter_tool_dirs(mapping: dict[str, str]) -> dict[str, str]:
|
|
357
|
+
"""Drop entries whose tool ID is not active in `.agent-tools.yml`."""
|
|
358
|
+
return {
|
|
359
|
+
d: p for d, p in mapping.items()
|
|
360
|
+
if _tool_active(_DIR_TOOL_ID.get(d, "claude-code"))
|
|
361
|
+
}
|
|
362
|
+
|
|
309
363
|
|
|
310
364
|
def strip_frontmatter(content: str) -> str:
|
|
311
365
|
"""Remove YAML frontmatter (between --- markers) from content."""
|
|
@@ -461,8 +515,9 @@ def generate_rule_symlinks() -> int:
|
|
|
461
515
|
"""
|
|
462
516
|
# All .md files in .agent-src/rules/ — not just universal ones
|
|
463
517
|
rules = sorted([f.name for f in RULES_SOURCE.glob("*.md")])
|
|
518
|
+
tool_dirs = _filter_tool_dirs(TOOL_DIRS)
|
|
464
519
|
total = 0
|
|
465
|
-
for tool_dir, rel_prefix in
|
|
520
|
+
for tool_dir, rel_prefix in tool_dirs.items():
|
|
466
521
|
target_dir = PROJECT_ROOT / tool_dir
|
|
467
522
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
468
523
|
|
|
@@ -481,13 +536,13 @@ def generate_rule_symlinks() -> int:
|
|
|
481
536
|
|
|
482
537
|
# Verify counts match across all tool directories
|
|
483
538
|
source_count = len(rules)
|
|
484
|
-
for tool_dir in
|
|
539
|
+
for tool_dir in tool_dirs:
|
|
485
540
|
target_dir = PROJECT_ROOT / tool_dir
|
|
486
541
|
tool_count = len([f for f in target_dir.iterdir() if f.is_symlink() and f.suffix == ".md"])
|
|
487
542
|
if tool_count != source_count:
|
|
488
543
|
print(f" ⚠️ {tool_dir}: {tool_count} rules (expected {source_count})")
|
|
489
544
|
|
|
490
|
-
info(f" ✅ Created {total} rule symlinks across {len(
|
|
545
|
+
info(f" ✅ Created {total} rule symlinks across {len(tool_dirs)} tool directories ({source_count} rules each)")
|
|
491
546
|
return total
|
|
492
547
|
|
|
493
548
|
|
|
@@ -812,8 +867,9 @@ def generate_persona_symlinks() -> int:
|
|
|
812
867
|
personas = sorted([
|
|
813
868
|
f.name for f in PERSONAS_SOURCE.glob("*.md") if f.stem != "README"
|
|
814
869
|
])
|
|
870
|
+
tool_dirs = _filter_tool_dirs(PERSONA_TOOL_DIRS)
|
|
815
871
|
total = 0
|
|
816
|
-
for tool_dir, rel_prefix in
|
|
872
|
+
for tool_dir, rel_prefix in tool_dirs.items():
|
|
817
873
|
target_dir = PROJECT_ROOT / tool_dir
|
|
818
874
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
819
875
|
|
|
@@ -830,28 +886,35 @@ def generate_persona_symlinks() -> int:
|
|
|
830
886
|
link.symlink_to(target)
|
|
831
887
|
total += 1
|
|
832
888
|
|
|
833
|
-
info(f" ✅ Created {total} persona symlinks across {len(
|
|
889
|
+
info(f" ✅ Created {total} persona symlinks across {len(tool_dirs)} tool directories ({len(personas)} personas each)")
|
|
834
890
|
return total
|
|
835
891
|
|
|
836
892
|
|
|
837
893
|
def generate_tools() -> None:
|
|
838
|
-
"""Generate all tool-specific directories and files.
|
|
894
|
+
"""Generate all tool-specific directories and files.
|
|
895
|
+
|
|
896
|
+
`.agent-tools.yml` (top-level) gates per-tool emission. When the file
|
|
897
|
+
is missing, every tool is emitted (preserves test fixtures and
|
|
898
|
+
pre-gating behaviour). See `_active_tools()` and `_tool_active()`.
|
|
899
|
+
"""
|
|
839
900
|
info("🔧 Generating multi-agent tool directories...\n")
|
|
840
901
|
rules = generate_rule_symlinks()
|
|
841
|
-
generate_windsurfrules()
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
902
|
+
windsurfrules = generate_windsurfrules() if _tool_active("windsurf") else 0
|
|
903
|
+
if _tool_active("gemini"):
|
|
904
|
+
generate_gemini_md()
|
|
905
|
+
skills = generate_claude_skills() if _tool_active("claude-code") else 0
|
|
906
|
+
commands = generate_claude_commands() if _tool_active("claude-code") else 0
|
|
845
907
|
personas = generate_persona_symlinks()
|
|
846
|
-
cursor_mdc = generate_cursor_mdc_rules()
|
|
847
|
-
windsurf_modern = generate_windsurf_modern_rules()
|
|
848
|
-
cursor_cmds = generate_cursor_commands()
|
|
849
|
-
windsurf_wf = generate_windsurf_workflows()
|
|
908
|
+
cursor_mdc = generate_cursor_mdc_rules() if _tool_active("cursor") else 0
|
|
909
|
+
windsurf_modern = generate_windsurf_modern_rules() if _tool_active("windsurf") else 0
|
|
910
|
+
cursor_cmds = generate_cursor_commands() if _tool_active("cursor") else 0
|
|
911
|
+
windsurf_wf = generate_windsurf_workflows() if _tool_active("windsurf") else 0
|
|
850
912
|
summary = (
|
|
851
913
|
f"✅ generate-tools — rules={rules} skills={skills} "
|
|
852
914
|
f"commands={commands} personas={personas} "
|
|
853
915
|
f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
|
|
854
|
-
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf}"
|
|
916
|
+
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf} "
|
|
917
|
+
f"windsurfrules={windsurfrules}"
|
|
855
918
|
)
|
|
856
919
|
if resolve_level() == "verbose":
|
|
857
920
|
print(f"\n{summary}")
|