@event4u/agent-config 2.1.0 → 2.2.1

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 (38) hide show
  1. package/.agent-src/rules/no-cheap-questions.md +11 -2
  2. package/.agent-src/skills/readme-writing-package/SKILL.md +24 -0
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +69 -0
  5. package/README.md +71 -6
  6. package/docs/DISTRIBUTION_CHECKLIST.md +7 -6
  7. package/docs/architecture.md +1 -1
  8. package/docs/contracts/tier-3-contrib-plugin.md +129 -0
  9. package/docs/decisions/ADR-007-agent-discovery-scopes.md +286 -0
  10. package/docs/decisions/ADR-008-installed-tools-manifest.md +160 -0
  11. package/docs/decisions/INDEX.md +2 -0
  12. package/docs/getting-started.md +1 -1
  13. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +32 -0
  14. package/docs/guidelines/agent-infra/installed-tools-manifest.md +135 -0
  15. package/docs/installation.md +83 -27
  16. package/docs/setup/per-ide/aider.md +1 -1
  17. package/docs/setup/per-ide/claude-code.md +1 -1
  18. package/docs/setup/per-ide/claude-desktop.md +8 -4
  19. package/docs/setup/per-ide/cline.md +2 -2
  20. package/docs/setup/per-ide/codex.md +1 -1
  21. package/docs/setup/per-ide/copilot.md +2 -2
  22. package/docs/setup/per-ide/cursor.md +2 -2
  23. package/docs/setup/per-ide/gemini-cli.md +1 -1
  24. package/docs/setup/per-ide/windsurf.md +2 -2
  25. package/docs/troubleshooting.md +4 -4
  26. package/package.json +1 -1
  27. package/scripts/_cli/cmd_export.py +157 -0
  28. package/scripts/_cli/cmd_sync.py +162 -0
  29. package/scripts/_cli/cmd_update.py +23 -1
  30. package/scripts/_cli/cmd_validate.py +164 -0
  31. package/scripts/_lib/installed_lock.py +160 -0
  32. package/scripts/_lib/installed_tools.py +237 -0
  33. package/scripts/agent-config +78 -1
  34. package/scripts/install +43 -10
  35. package/scripts/install.py +975 -14
  36. package/templates/agent-config-wrapper.sh +1 -1
  37. package/templates/consumer-settings/README.md +1 -1
  38. package/templates/marketing-copy.yml +6 -5
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
36
36
  from pathlib import Path
37
37
  from typing import Optional
38
38
 
39
- from scripts._lib import update_check
39
+ from scripts._lib import installed_lock, update_check
40
40
  from scripts._lib.agent_settings import (
41
41
  DEFAULT_PROJECT_FILE,
42
42
  _resolve_cascade_paths,
@@ -205,9 +205,31 @@ def main(
205
205
 
206
206
  cache_warmer(latest)
207
207
  _refresh_state(latest, latest, state_path)
208
+ _refresh_global_lockfile(latest, out=out)
208
209
  return 0
209
210
 
210
211
 
212
+ def _refresh_global_lockfile(version: str, *, out=sys.stdout) -> None:
213
+ """Update ``~/.config/agent-config/installed.lock`` if it exists.
214
+
215
+ Phase 1.6 — the lockfile is only present when the user has run a
216
+ global install; we never create one here, but we keep it in lockstep
217
+ when ``update`` flips the pin. Atomic write goes through
218
+ ``installed_lock.write_lockfile``.
219
+ """
220
+ lock_path = installed_lock.lockfile_path()
221
+ existing = installed_lock.read_lockfile(path=lock_path)
222
+ if existing is None:
223
+ return
224
+ recorded = existing.get("agent_config_version")
225
+ tools = list(existing.get("tools", []))
226
+ if recorded == version:
227
+ print(f"ℹ️ {lock_path} already records {version}.", file=out)
228
+ return
229
+ installed_lock.write_lockfile(version, tools, path=lock_path)
230
+ print(f"✅ Refreshed global lockfile at {lock_path}.", file=out)
231
+
232
+
211
233
  def _detect_installed_version() -> str:
212
234
  """Read ``version`` from the package's own ``package.json``."""
213
235
  pkg_json = Path(__file__).resolve().parents[2] / "package.json"
@@ -0,0 +1,164 @@
1
+ """``agent-config validate`` — drift detection for the installed-tools manifest.
2
+
3
+ Phase 3.4 of road-to-global-first-install.md (ADR-008). Read-only check —
4
+ never edits the manifest, never re-runs the installer. Exits non-zero if any
5
+ drift is found so CI can gate on it. Surfaces three drift kinds documented in
6
+ ADR-008 §Lifecycle:
7
+
8
+ 1. **marker_missing** — recorded ``bridge_marker`` path does not exist.
9
+ 2. **scope_divergence** — recorded scope is ``project`` but the marker only
10
+ exists at the user-scope anchor (or vice versa); the manifest is lying
11
+ about where the tool actually lives.
12
+ 3. **version_drift** — manifest's ``agent_config_version`` no longer
13
+ matches the package's currently-installed version (single repo-level
14
+ check, surfaced once not per-tool).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Iterable
23
+
24
+ from scripts._lib import installed_lock, installed_tools
25
+ from scripts.install import PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS
26
+
27
+
28
+ def _resolve_marker(project_root: Path, bridge_marker: str, scope: str) -> Path:
29
+ if scope == "global":
30
+ return Path(bridge_marker).expanduser()
31
+ candidate = Path(bridge_marker)
32
+ return candidate if candidate.is_absolute() else (project_root / candidate)
33
+
34
+
35
+ def _counterpart_path(project_root: Path, tool_id: str, scope: str) -> Path | None:
36
+ """Return the *other* scope's canonical marker path, or None if unknown."""
37
+ if scope == "project":
38
+ anchor = USER_SCOPE_PATHS.get(tool_id)
39
+ return Path(anchor).expanduser() if anchor else None
40
+ rel = PROJECT_BRIDGE_MARKERS.get(tool_id)
41
+ return (project_root / rel) if rel else None
42
+
43
+
44
+ def _check_entry(project_root: Path, entry: dict) -> list[dict]:
45
+ name = str(entry.get("name", "")).strip()
46
+ scope = str(entry.get("scope", "")).strip()
47
+ bridge_marker = str(entry.get("bridge_marker", "")).strip()
48
+ issues: list[dict] = []
49
+ if not name or scope not in ("project", "global") or not bridge_marker:
50
+ issues.append({
51
+ "kind": "manifest_corrupt",
52
+ "name": name or "<unknown>",
53
+ "detail": f"entry missing required fields (scope={scope!r}, marker={bridge_marker!r})",
54
+ })
55
+ return issues
56
+ target = _resolve_marker(project_root, bridge_marker, scope)
57
+ if not target.exists():
58
+ counterpart = _counterpart_path(project_root, name, scope)
59
+ if counterpart is not None and counterpart.exists():
60
+ other_scope = "global" if scope == "project" else "project"
61
+ issues.append({
62
+ "kind": "scope_divergence",
63
+ "name": name,
64
+ "detail": (
65
+ f"recorded scope={scope} ({target}) is missing, but "
66
+ f"counterpart at scope={other_scope} ({counterpart}) exists"
67
+ ),
68
+ })
69
+ else:
70
+ issues.append({
71
+ "kind": "marker_missing",
72
+ "name": name,
73
+ "detail": f"bridge_marker not found: {target}",
74
+ })
75
+ return issues
76
+
77
+
78
+ def _version_drift(manifest_version: str, current_version: str) -> dict | None:
79
+ if not manifest_version or not current_version:
80
+ return None
81
+ if manifest_version != current_version:
82
+ return {
83
+ "kind": "version_drift",
84
+ "name": "<manifest>",
85
+ "detail": (
86
+ f"manifest recorded agent_config_version={manifest_version}; "
87
+ f"currently installed package is {current_version}"
88
+ ),
89
+ }
90
+ return None
91
+
92
+
93
+ def _parse(argv: list[str]) -> argparse.Namespace:
94
+ parser = argparse.ArgumentParser(
95
+ prog="agent-config validate",
96
+ description=(
97
+ "Read-only drift detection for agents/installed-tools.lock. "
98
+ "Exits 1 if any drift is found."
99
+ ),
100
+ )
101
+ parser.add_argument("--project", default=None, help="Override project root.")
102
+ parser.add_argument(
103
+ "--quiet", action="store_true", help="Suppress non-essential output.",
104
+ )
105
+ parser.add_argument(
106
+ "--skip-version-check",
107
+ action="store_true",
108
+ help="Skip the manifest-vs-package version drift check.",
109
+ )
110
+ return parser.parse_args(argv)
111
+
112
+
113
+ def _emit(quiet: bool, msg: str) -> None:
114
+ if not quiet:
115
+ print(msg)
116
+
117
+
118
+ def _format(issue: dict) -> str:
119
+ return f" ❌ [{issue['kind']}] {issue['name']}: {issue['detail']}"
120
+
121
+
122
+ def main(argv: list[str]) -> int:
123
+ opts = _parse(argv)
124
+ project_root = Path(
125
+ opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
126
+ ).resolve()
127
+ manifest = installed_tools.manifest_path(project_root)
128
+ data = installed_tools.read_manifest(manifest)
129
+
130
+ if data is None:
131
+ _emit(opts.quiet, f"❌ No manifest found at {manifest}")
132
+ _emit(opts.quiet, " Run `./agent-config init --tools=<id>` to create one.")
133
+ return 1
134
+
135
+ entries = list(data.get("tools") or [])
136
+ issues: list[dict] = []
137
+ for entry in entries:
138
+ issues.extend(_check_entry(project_root, entry))
139
+
140
+ if not opts.skip_version_check:
141
+ manifest_version = str(data.get("agent_config_version", "")).strip()
142
+ current_version = installed_lock.current_package_version()
143
+ drift = _version_drift(manifest_version, current_version)
144
+ if drift is not None:
145
+ issues.append(drift)
146
+
147
+ _emit(opts.quiet, f"Manifest: {manifest}")
148
+ _emit(opts.quiet, f"Tools: {len(entries)} entries")
149
+
150
+ if not issues:
151
+ _emit(opts.quiet, "✅ No drift detected.")
152
+ return 0
153
+
154
+ _emit(opts.quiet, f"Drift: {len(issues)} issue(s)")
155
+ for issue in issues:
156
+ _emit(opts.quiet, _format(issue))
157
+ _emit(opts.quiet, "")
158
+ _emit(opts.quiet, "Run `./agent-config sync` to replay missing bridges, or")
159
+ _emit(opts.quiet, "`./agent-config init --tools=<id> --force` to refresh the manifest.")
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,160 @@
1
+ """Global-install lockfile at ``~/.config/agent-config/installed.lock``.
2
+
3
+ Phase 1.6 of road-to-global-first-install (ADR-007 D5). Records the
4
+ package version that performed the most recent user-scope install plus
5
+ the tools that were scaffolded. ``init --global`` reads this file: on
6
+ version mismatch the install refuses unless ``--force`` is passed; the
7
+ ``update`` subcommand refreshes the entry in lockstep with the pin
8
+ flip in ``.agent-settings.yml``.
9
+
10
+ The schema is intentionally minimal YAML so the module can read and
11
+ write without depending on ``pyyaml``. Atomic writes go through
12
+ ``tempfile + os.replace`` per ADR-007 risk-mitigation row.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ LOCKFILE_ENV = "AGENT_CONFIG_INSTALLED_LOCK"
25
+ DEFAULT_LOCKFILE = Path.home() / ".config" / "agent-config" / "installed.lock"
26
+ SCHEMA_VERSION = 1
27
+
28
+ _VERSION_RE = re.compile(r'^\s*agent_config_version\s*:\s*"?([^"\s]+)"?\s*$')
29
+ _SCHEMA_RE = re.compile(r"^\s*schema_version\s*:\s*(\d+)\s*$")
30
+ _INSTALLED_AT_RE = re.compile(r'^\s*installed_at\s*:\s*"?([^"\s]+)"?\s*$')
31
+ _TOOL_RE = re.compile(r"^\s*-\s*([A-Za-z0-9_\-.]+)\s*$")
32
+
33
+
34
+ def lockfile_path(env: Optional[dict] = None) -> Path:
35
+ """Return the active lockfile path, honoring the env override."""
36
+ env = env if env is not None else os.environ
37
+ override = env.get(LOCKFILE_ENV)
38
+ if override:
39
+ return Path(override).expanduser()
40
+ return DEFAULT_LOCKFILE
41
+
42
+
43
+ def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
44
+ """Parse ``path`` (or the default) into a dict; return ``None`` if absent.
45
+
46
+ Tolerates partial / malformed files: missing keys yield missing dict
47
+ entries rather than raising, so a hand-edited corrupt file does not
48
+ brick ``init``.
49
+ """
50
+ target = path or lockfile_path()
51
+ try:
52
+ text = target.read_text(encoding="utf-8")
53
+ except FileNotFoundError:
54
+ return None
55
+ except OSError:
56
+ return None
57
+
58
+ data: dict = {"tools": []}
59
+ in_tools = False
60
+ for raw_line in text.splitlines():
61
+ if _SCHEMA_RE.match(raw_line):
62
+ data["schema_version"] = int(_SCHEMA_RE.match(raw_line).group(1))
63
+ in_tools = False
64
+ continue
65
+ if _VERSION_RE.match(raw_line):
66
+ data["agent_config_version"] = _VERSION_RE.match(raw_line).group(1)
67
+ in_tools = False
68
+ continue
69
+ if _INSTALLED_AT_RE.match(raw_line):
70
+ data["installed_at"] = _INSTALLED_AT_RE.match(raw_line).group(1)
71
+ in_tools = False
72
+ continue
73
+ if raw_line.strip().startswith("tools:"):
74
+ in_tools = True
75
+ continue
76
+ if in_tools:
77
+ m = _TOOL_RE.match(raw_line)
78
+ if m:
79
+ data["tools"].append(m.group(1))
80
+ elif raw_line.strip() and not raw_line.startswith((" ", "\t", "-")):
81
+ in_tools = False
82
+ return data
83
+
84
+
85
+ def _render(version: str, tools: list[str], installed_at: str) -> str:
86
+ lines = [
87
+ f"schema_version: {SCHEMA_VERSION}",
88
+ f'agent_config_version: "{version}"',
89
+ f'installed_at: "{installed_at}"',
90
+ "tools:",
91
+ ]
92
+ for tool in tools:
93
+ lines.append(f" - {tool}")
94
+ return "\n".join(lines) + "\n"
95
+
96
+
97
+ def write_lockfile(
98
+ version: str,
99
+ tools: list[str],
100
+ *,
101
+ path: Optional[Path] = None,
102
+ now: Optional[datetime] = None,
103
+ ) -> Path:
104
+ """Atomically write the lockfile; return the path written."""
105
+ target = path or lockfile_path()
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ stamp = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
108
+ rendered = _render(version, sorted(set(tools)), stamp)
109
+ # Atomic write: tempfile in the same dir + os.replace. The same-dir
110
+ # constraint keeps the rename atomic across all POSIX filesystems
111
+ # and Windows when the file already exists.
112
+ fd, tmp_name = tempfile.mkstemp(
113
+ prefix=".installed.lock.", dir=str(target.parent), text=False
114
+ )
115
+ try:
116
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
117
+ fh.write(rendered)
118
+ os.replace(tmp_name, target)
119
+ except Exception:
120
+ try:
121
+ os.unlink(tmp_name)
122
+ except OSError:
123
+ pass
124
+ raise
125
+ return target
126
+
127
+
128
+ def check_version(
129
+ installed_version: str,
130
+ *,
131
+ path: Optional[Path] = None,
132
+ ) -> tuple[bool, Optional[str]]:
133
+ """Compare ``installed_version`` against the lockfile's recorded version.
134
+
135
+ Returns ``(ok, recorded_version_or_none)``:
136
+ * ``(True, None)`` — no lockfile yet; ``init`` may proceed.
137
+ * ``(True, vX)`` — matches; ``init`` may proceed.
138
+ * ``(False, vY)`` — mismatch; caller must refuse without ``--force``.
139
+ """
140
+ existing = read_lockfile(path=path)
141
+ if existing is None:
142
+ return True, None
143
+ recorded = existing.get("agent_config_version")
144
+ if not recorded:
145
+ return True, None
146
+ return (recorded == installed_version, recorded)
147
+
148
+
149
+ def current_package_version(repo_root: Optional[Path] = None) -> str:
150
+ """Read ``version`` from the package's own ``package.json``."""
151
+ if repo_root is None:
152
+ repo_root = Path(__file__).resolve().parents[2]
153
+ try:
154
+ data = json.loads((repo_root / "package.json").read_text(encoding="utf-8"))
155
+ version = data.get("version")
156
+ if isinstance(version, str) and version.strip():
157
+ return version.strip()
158
+ except (OSError, ValueError, json.JSONDecodeError):
159
+ pass
160
+ return "0.0.0"
@@ -0,0 +1,237 @@
1
+ """Project-scope installed-tools manifest at ``agents/installed-tools.lock``.
2
+
3
+ Phase 3 of road-to-global-first-install (ADR-008). Committed
4
+ bill-of-materials for AI tooling a project depends on. Sibling to the
5
+ global lockfile (``installed_lock.py``) but architecturally distinct:
6
+
7
+ - ``installed_lock.py`` lives in ``~/.config/agent-config/`` and tracks
8
+ the user-scope environment (a single ``agent_config_version`` and a
9
+ flat ``tools[]`` list).
10
+ - ``installed_tools.py`` lives in ``agents/`` and tracks **per-project**
11
+ tooling with richer per-entry metadata (``scope``, ``bridge_marker``,
12
+ ``installed_at``).
13
+
14
+ The file is machine-managed: ``init`` appends / merges; ``sync`` replays;
15
+ ``validate`` drift-checks. Schema is YAML; ``pyyaml`` is used when
16
+ available, otherwise a constrained manual parser handles the documented
17
+ schema (no anchors, no flow style, single-level nesting under
18
+ ``tools``).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import re
24
+ import tempfile
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ MANIFEST_ENV = "AGENT_CONFIG_INSTALLED_TOOLS"
30
+ DEFAULT_MANIFEST_RELATIVE = Path("agents") / "installed-tools.lock"
31
+ SCHEMA_VERSION = 1
32
+
33
+ _VALID_SCOPES = ("global", "project")
34
+
35
+
36
+ def manifest_path(project_root: Path, env: Optional[dict] = None) -> Path:
37
+ """Return the active manifest path, honoring the env override."""
38
+ env = env if env is not None else os.environ
39
+ override = env.get(MANIFEST_ENV)
40
+ if override:
41
+ return Path(override).expanduser()
42
+ return project_root / DEFAULT_MANIFEST_RELATIVE
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Read
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _TOP_KEY_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"?([^"\n]*?)"?\s*$')
50
+ _LIST_DASH_RE = re.compile(r"^\s*-\s*(.+?)\s*$")
51
+ _INDENT_KEY_RE = re.compile(r'^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"?([^"\n]*?)"?\s*$')
52
+
53
+
54
+ def read_manifest(path: Path) -> Optional[dict[str, Any]]:
55
+ """Parse the manifest into a dict; return ``None`` if absent.
56
+
57
+ Tolerates partial / malformed files: missing keys yield missing dict
58
+ entries rather than raising, so a corrupted file does not brick
59
+ ``init``.
60
+ """
61
+ try:
62
+ text = path.read_text(encoding="utf-8")
63
+ except (FileNotFoundError, OSError):
64
+ return None
65
+ try:
66
+ import yaml # type: ignore[import-untyped]
67
+ data = yaml.safe_load(text) or {}
68
+ if isinstance(data, dict):
69
+ data.setdefault("tools", [])
70
+ return data
71
+ except ImportError:
72
+ pass
73
+ except Exception:
74
+ # Fall through to the manual parser; corrupt YAML is recoverable
75
+ # from our strict schema as long as the top-level shape holds.
76
+ pass
77
+ return _parse_manual(text)
78
+
79
+
80
+ def _parse_manual(text: str) -> dict[str, Any]:
81
+ data: dict[str, Any] = {"tools": []}
82
+ in_tools = False
83
+ current: Optional[dict[str, Any]] = None
84
+ for raw in text.splitlines():
85
+ stripped = raw.strip()
86
+ if not stripped or stripped.startswith("#"):
87
+ continue
88
+ if stripped == "tools:":
89
+ in_tools = True
90
+ current = None
91
+ continue
92
+ if in_tools:
93
+ m = _LIST_DASH_RE.match(raw)
94
+ if m:
95
+ first = m.group(1)
96
+ current = {}
97
+ data["tools"].append(current)
98
+ # Could be inline like `- name: foo` — handle that.
99
+ inline = _TOP_KEY_RE.match(first)
100
+ if inline:
101
+ current[inline.group(1)] = inline.group(2)
102
+ continue
103
+ mk = _INDENT_KEY_RE.match(raw)
104
+ if mk and current is not None:
105
+ current[mk.group(1)] = mk.group(2)
106
+ continue
107
+ m_top = _TOP_KEY_RE.match(raw)
108
+ if m_top:
109
+ key, value = m_top.group(1), m_top.group(2)
110
+ if key == "schema_version":
111
+ try:
112
+ data[key] = int(value)
113
+ except ValueError:
114
+ data[key] = value
115
+ else:
116
+ data[key] = value
117
+ in_tools = False
118
+ current = None
119
+ return data
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Write
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _render(
128
+ version: str,
129
+ tools: list[dict[str, Any]],
130
+ ) -> str:
131
+ lines = [
132
+ f"schema_version: {SCHEMA_VERSION}",
133
+ f'agent_config_version: "{version}"',
134
+ "tools:",
135
+ ]
136
+ for tool in tools:
137
+ lines.append(f" - name: {tool['name']}")
138
+ lines.append(f" scope: {tool['scope']}")
139
+ lines.append(f" bridge_marker: {tool['bridge_marker']}")
140
+ lines.append(f' installed_at: "{tool["installed_at"]}"')
141
+ return "\n".join(lines) + "\n"
142
+
143
+
144
+ def write_manifest(
145
+ path: Path,
146
+ version: str,
147
+ tools: list[dict[str, Any]],
148
+ ) -> Path:
149
+ """Atomically write the manifest; return the path written."""
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ rendered = _render(version, tools)
152
+ fd, tmp_name = tempfile.mkstemp(
153
+ prefix=".installed-tools.lock.", dir=str(path.parent), text=False
154
+ )
155
+ try:
156
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
157
+ fh.write(rendered)
158
+ os.replace(tmp_name, path)
159
+ except Exception:
160
+ try:
161
+ os.unlink(tmp_name)
162
+ except OSError:
163
+ pass
164
+ raise
165
+ return path
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Mutation helpers
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ class ScopeMismatchError(RuntimeError):
174
+ """Raised when an existing manifest entry conflicts with the new scope."""
175
+
176
+ def __init__(self, name: str, recorded_scope: str, new_scope: str):
177
+ super().__init__(
178
+ f"tool {name!r} is committed as scope={recorded_scope}; "
179
+ f"refusing to change it to scope={new_scope} without --force"
180
+ )
181
+ self.name = name
182
+ self.recorded_scope = recorded_scope
183
+ self.new_scope = new_scope
184
+
185
+
186
+ def upsert_tool(
187
+ existing: list[dict[str, Any]],
188
+ *,
189
+ name: str,
190
+ scope: str,
191
+ bridge_marker: str,
192
+ installed_at: Optional[str] = None,
193
+ force: bool = False,
194
+ ) -> list[dict[str, Any]]:
195
+ """Return a new tools list with ``name`` added or refreshed.
196
+
197
+ Idempotency rules from ADR-008 §Lifecycle:
198
+ * Same name, same scope → no-op (timestamp preserved).
199
+ * Same name, different scope → raise ``ScopeMismatchError`` unless
200
+ ``force=True``, in which case the entry is rewritten.
201
+ * New name → appended in install order (not alphabetised).
202
+ """
203
+ if scope not in _VALID_SCOPES:
204
+ raise ValueError(f"scope must be one of {_VALID_SCOPES}: {scope!r}")
205
+ stamp = installed_at or _today()
206
+ result: list[dict[str, Any]] = []
207
+ found = False
208
+ for entry in existing:
209
+ if entry.get("name") == name:
210
+ found = True
211
+ recorded = str(entry.get("scope", ""))
212
+ if recorded == scope:
213
+ # Idempotent no-op — preserve original installed_at.
214
+ result.append(entry)
215
+ continue
216
+ if not force:
217
+ raise ScopeMismatchError(name, recorded, scope)
218
+ result.append({
219
+ "name": name,
220
+ "scope": scope,
221
+ "bridge_marker": bridge_marker,
222
+ "installed_at": stamp,
223
+ })
224
+ continue
225
+ result.append(entry)
226
+ if not found:
227
+ result.append({
228
+ "name": name,
229
+ "scope": scope,
230
+ "bridge_marker": bridge_marker,
231
+ "installed_at": stamp,
232
+ })
233
+ return result
234
+
235
+
236
+ def _today() -> str:
237
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")