@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
@@ -29,8 +29,10 @@ from typing import Any
29
29
  _SCRIPTS = Path(__file__).resolve().parent
30
30
  sys.path.insert(0, str(_SCRIPTS))
31
31
 
32
+ from mcp_server.catalog import load_catalog # noqa: E402
32
33
  from mcp_server.prompts import load_all_prompts, to_mcp_prompt_meta # noqa: E402
33
34
  from mcp_server.resources import load_all_resources, to_mcp_resource_meta # noqa: E402
35
+ from mcp_server.tools import ALLOWLIST # noqa: E402
34
36
 
35
37
  PAGE_SIZE = 50
36
38
 
@@ -96,6 +98,21 @@ def _normalize_resources(payload: dict[str, Any]) -> list[dict[str, Any]]:
96
98
  return sorted(out, key=lambda x: x["uri"])
97
99
 
98
100
 
101
+ def _local_tools_list() -> dict[str, Any]:
102
+ """Tools catalog + allowlist as the stdio server publishes them."""
103
+ catalog_names = [c.name for c in load_catalog()]
104
+ allowlist_names = list(ALLOWLIST.keys())
105
+ return {"tools": [{"name": n} for n in sorted(set(catalog_names + allowlist_names))]}
106
+
107
+
108
+ def _normalize_tools(payload: dict[str, Any]) -> list[dict[str, Any]]:
109
+ """Compare on `name` only — descriptions / schemas drift acceptably."""
110
+ return sorted(
111
+ [{"name": t["name"]} for t in payload.get("tools", [])],
112
+ key=lambda x: x["name"],
113
+ )
114
+
115
+
99
116
  def _diff(label: str, local: list[Any], remote: list[Any]) -> int:
100
117
  if local == remote:
101
118
  print(f"✅ {label}: {len(local)} entries match")
@@ -129,8 +146,9 @@ def main() -> int:
129
146
  failed += _diff("resources/list", local_r, remote_r)
130
147
 
131
148
  try:
132
- _ = _rpc(args.target, "tools/list")
133
- print("tools/list: round-trips (stub list — content not parity-checked)")
149
+ local_t = _normalize_tools(_local_tools_list())
150
+ remote_t = _normalize_tools(_rpc(args.target, "tools/list"))
151
+ failed += _diff("tools/list", local_t, remote_t)
134
152
  except Exception as e:
135
153
  print(f"❌ tools/list: {e}")
136
154
  failed += 1
@@ -0,0 +1,125 @@
1
+ """Consumer tool catalog — source of truth for Phase 1 discovery stubs.
2
+
3
+ Loaded once at module import from ``consumer_tool_catalog.json``. Both
4
+ the stdio server (``tools.py``) and the cloud pack
5
+ (``scripts/pack_mcp_content.py``) read from this file so the manifest
6
+ returned by ``tools/list`` on either transport is byte-identical apart
7
+ from per-tool ``implemented_on`` metadata.
8
+
9
+ Side-effect classification (``ro`` / ``fs-write`` / ``shell``) and the
10
+ ``not_implemented`` envelope contract live in
11
+ ``docs/contracts/mcp-tool-stub-envelope.md``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ _CATALOG_FILE = Path(__file__).resolve().parent / "consumer_tool_catalog.json"
21
+
22
+ # Stable error code surfaced in the ``not_implemented`` envelope. The
23
+ # Worker mirrors this string verbatim — keep them in sync via the
24
+ # envelope contract.
25
+ NOT_IMPLEMENTED_CODE = "not_implemented"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CatalogEntry:
30
+ """One row in ``consumer_tool_catalog.json``.
31
+
32
+ ``implemented_on`` lists transports where a real handler is wired
33
+ (``stdio`` / ``worker``); missing transports return the
34
+ ``not_implemented`` envelope.
35
+ """
36
+
37
+ name: str
38
+ description: str
39
+ side_effect: str
40
+ implemented_on: tuple[str, ...]
41
+ input_schema: dict[str, Any]
42
+
43
+
44
+ def _validate(raw: dict[str, Any]) -> None:
45
+ """Refuse to boot on a malformed catalog. Boot-time errors only."""
46
+ if raw.get("schema_version") != 1:
47
+ raise ValueError(
48
+ f"catalog: unsupported schema_version="
49
+ f"{raw.get('schema_version')!r}; expected 1"
50
+ )
51
+ tools = raw.get("tools")
52
+ if not isinstance(tools, list) or not tools:
53
+ raise ValueError("catalog: 'tools' must be a non-empty list")
54
+ seen: set[str] = set()
55
+ for entry in tools:
56
+ if not isinstance(entry, dict):
57
+ raise ValueError("catalog: every tool entry must be an object")
58
+ for field in ("name", "description", "side_effect", "input_schema"):
59
+ if field not in entry:
60
+ raise ValueError(f"catalog: tool missing '{field}'")
61
+ if entry["side_effect"] not in ("ro", "fs-write", "shell"):
62
+ raise ValueError(
63
+ f"catalog: tool {entry['name']!r} has invalid side_effect "
64
+ f"{entry['side_effect']!r} (expected ro / fs-write / shell)"
65
+ )
66
+ name = entry["name"]
67
+ if name in seen:
68
+ raise ValueError(f"catalog: duplicate tool name {name!r}")
69
+ seen.add(name)
70
+
71
+
72
+ def load_catalog(path: Path | None = None) -> list[CatalogEntry]:
73
+ """Parse and validate the catalog. Returns entries in file order."""
74
+ target = path or _CATALOG_FILE
75
+ raw = json.loads(target.read_text(encoding="utf-8"))
76
+ _validate(raw)
77
+ return [
78
+ CatalogEntry(
79
+ name=t["name"],
80
+ description=t["description"],
81
+ side_effect=t["side_effect"],
82
+ implemented_on=tuple(t.get("implemented_on") or ()),
83
+ input_schema=t["input_schema"],
84
+ )
85
+ for t in raw["tools"]
86
+ ]
87
+
88
+
89
+ def load_raw(path: Path | None = None) -> dict[str, Any]:
90
+ """Return the raw parsed JSON. Used by the cloud packer."""
91
+ target = path or _CATALOG_FILE
92
+ raw = json.loads(target.read_text(encoding="utf-8"))
93
+ _validate(raw)
94
+ return raw
95
+
96
+
97
+ def install_hint(raw: dict[str, Any] | None = None) -> str:
98
+ """Stable install-hint surfaced in the envelope."""
99
+ data = raw if raw is not None else load_raw()
100
+ return str(data.get("install_hint_stdio") or "")
101
+
102
+
103
+ def not_implemented_envelope(
104
+ tool_name: str,
105
+ *,
106
+ transport: str,
107
+ install_hint_value: str,
108
+ ) -> dict[str, Any]:
109
+ """Wire-shape error envelope used when a stub is invoked.
110
+
111
+ Mirrored verbatim by the Cloud Worker (`workers/mcp/src/stubs.ts`).
112
+ """
113
+ return {
114
+ "code": NOT_IMPLEMENTED_CODE,
115
+ "tool": tool_name,
116
+ "transport": transport,
117
+ "install_hint": install_hint_value,
118
+ "alternative": "stdio",
119
+ "message": (
120
+ f"Tool '{tool_name}' is in the discovery catalog but not "
121
+ f"implemented on the {transport} transport. See the install "
122
+ "hint to wire it up locally, or check "
123
+ "docs/contracts/mcp-tool-stub-envelope.md."
124
+ ),
125
+ }
@@ -0,0 +1,275 @@
1
+ {
2
+ "schema_version": 1,
3
+ "description": "Source-of-truth catalog of consumer-relevant MCP tools. Read by the stdio server (scripts/mcp_server/) and packed into the Cloud Worker bundle (workers/mcp/). Phase 1 of road-to-mcp-full-coverage: tools without 'implemented' transports return the 'not_implemented' envelope defined in docs/contracts/mcp-tool-stub-envelope.md. The 'implemented_on' field lists transports where the real handler is wired; everything else is a discovery stub. See agents/roadmaps/road-to-mcp-full-coverage.md.",
4
+ "install_hint_stdio": "pip install agent-config[mcp] && ./agent-config mcp:run",
5
+ "tools": [
6
+ {
7
+ "name": "lint_skills",
8
+ "description": "Lint skill / rule / command / guideline / persona markdown files. Returns the same JSON payload as `scripts/skill_linter.py --format json`. Read-only — never writes or spawns git. Pass `paths` to lint a subset, omit for a full tree scan.",
9
+ "side_effect": "ro",
10
+ "implemented_on": ["stdio"],
11
+ "input_schema": {
12
+ "type": "object",
13
+ "properties": {
14
+ "paths": {
15
+ "type": "array",
16
+ "items": {"type": "string"},
17
+ "description": "Repo-relative paths to lint. Empty / missing → full tree scan."
18
+ }
19
+ },
20
+ "additionalProperties": false
21
+ }
22
+ },
23
+ {
24
+ "name": "chat_history_append",
25
+ "description": "Append one entry to the consumer's chat-history JSONL (`agents/.agent-chat-history`). Path-scoped — writes outside the allowlist raise ValueError. Use `dry_run` to preview the payload without touching the filesystem.",
26
+ "side_effect": "fs-write",
27
+ "implemented_on": ["stdio"],
28
+ "input_schema": {
29
+ "type": "object",
30
+ "properties": {
31
+ "text": {"type": "string"},
32
+ "entry_type": {"type": "string", "description": "Short `t` tag (e.g. note, decision). Defaults to `note`."},
33
+ "path": {"type": "string", "description": "Optional path override. Must resolve to `agents/.agent-chat-history` or `.agent-chat-history` under consumer_root."},
34
+ "session": {"type": "string"},
35
+ "dry_run": {"type": "boolean", "default": false},
36
+ "min_schema_version": {"type": "integer"}
37
+ },
38
+ "required": ["text"],
39
+ "additionalProperties": false
40
+ }
41
+ },
42
+ {
43
+ "name": "chat_history_read",
44
+ "description": "Read recent chat-history entries from `agents/.agent-chat-history`. Filter by session, limit, or entry-type. Read-only.",
45
+ "side_effect": "ro",
46
+ "implemented_on": ["stdio"],
47
+ "input_schema": {
48
+ "type": "object",
49
+ "properties": {
50
+ "last": {"type": "integer", "description": "Return only the last N entries.", "minimum": 1},
51
+ "session": {"type": "string", "description": "Filter by 16-char session id."},
52
+ "entry_type": {"type": "string", "description": "Filter by `t` field."},
53
+ "path": {"type": "string"}
54
+ },
55
+ "additionalProperties": false
56
+ }
57
+ },
58
+ {
59
+ "name": "memory_lookup",
60
+ "description": "Hybrid memory retrieval over `agents/memory/<type>/*.yml` and `agents/memory/intake/*.jsonl`. Read-only.",
61
+ "side_effect": "ro",
62
+ "implemented_on": ["stdio"],
63
+ "input_schema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "types": {"type": "array", "items": {"type": "string"}, "description": "Memory types to scan (e.g. ownership, historical-patterns)."},
67
+ "keys": {"type": "array", "items": {"type": "string"}, "description": "Path / glob keys to match."},
68
+ "limit": {"type": "integer", "minimum": 1, "default": 5}
69
+ },
70
+ "required": ["types"],
71
+ "additionalProperties": false
72
+ }
73
+ },
74
+ {
75
+ "name": "memory_signal",
76
+ "description": "Append an engineering-memory signal to `agents/memory/intake/signals-YYYY-MM.jsonl`. Rate-limited per `(type, path)` within a rolling window.",
77
+ "side_effect": "fs-write",
78
+ "implemented_on": [],
79
+ "input_schema": {
80
+ "type": "object",
81
+ "properties": {
82
+ "type": {"type": "string", "description": "Memory type (historical-patterns, incident-learnings, ownership, ...)."},
83
+ "path": {"type": "string", "description": "Anchor path the signal is about."},
84
+ "body": {"type": "string", "description": "Free-form signal body."}
85
+ },
86
+ "required": ["type", "path", "body"],
87
+ "additionalProperties": false
88
+ }
89
+ },
90
+ {
91
+ "name": "memory_status",
92
+ "description": "Report whether the optional `@event4u/agent-memory` package is reachable, and surface its routing metadata. Read-only.",
93
+ "side_effect": "ro",
94
+ "implemented_on": ["stdio"],
95
+ "input_schema": {"type": "object", "properties": {}, "additionalProperties": false}
96
+ },
97
+ {
98
+ "name": "skill_trigger_eval",
99
+ "description": "Score a user message against the compiled router and return the matching skills with their trigger reasons. Read-only.",
100
+ "side_effect": "ro",
101
+ "implemented_on": [],
102
+ "input_schema": {
103
+ "type": "object",
104
+ "properties": {
105
+ "message": {"type": "string"},
106
+ "context": {"type": "string", "description": "Optional recent-turn context."},
107
+ "limit": {"type": "integer", "minimum": 1, "default": 5}
108
+ },
109
+ "required": ["message"],
110
+ "additionalProperties": false
111
+ }
112
+ },
113
+ {
114
+ "name": "suggest_command",
115
+ "description": "Run the command-suggestion engine: score commands against a user message + context and return the numbered options block. Read-only, deterministic.",
116
+ "side_effect": "ro",
117
+ "implemented_on": [],
118
+ "input_schema": {
119
+ "type": "object",
120
+ "properties": {
121
+ "message": {"type": "string"},
122
+ "context": {"type": "string"}
123
+ },
124
+ "required": ["message"],
125
+ "additionalProperties": false
126
+ }
127
+ },
128
+ {
129
+ "name": "suggest_skill_for_task",
130
+ "description": "Match a free-form task description to the most relevant skills with frontmatter triggers. Read-only.",
131
+ "side_effect": "ro",
132
+ "implemented_on": [],
133
+ "input_schema": {
134
+ "type": "object",
135
+ "properties": {
136
+ "task": {"type": "string"},
137
+ "limit": {"type": "integer", "minimum": 1, "default": 5}
138
+ },
139
+ "required": ["task"],
140
+ "additionalProperties": false
141
+ }
142
+ },
143
+ {
144
+ "name": "mine_session",
145
+ "description": "Extract patterns, decisions, and learnings from a chat-history session JSONL. Read-only.",
146
+ "side_effect": "ro",
147
+ "implemented_on": [],
148
+ "input_schema": {
149
+ "type": "object",
150
+ "properties": {
151
+ "session": {"type": "string", "description": "Session id to mine."},
152
+ "path": {"type": "string", "description": "Optional path to a chat-history JSONL."}
153
+ },
154
+ "additionalProperties": false
155
+ }
156
+ },
157
+ {
158
+ "name": "update_form_request_messages",
159
+ "description": "Sync the `messages()` method of a Laravel FormRequest class — add missing entries, link to language keys, drop stale ones.",
160
+ "side_effect": "fs-write",
161
+ "implemented_on": [],
162
+ "input_schema": {
163
+ "type": "object",
164
+ "properties": {
165
+ "request_class": {"type": "string", "description": "Fully-qualified FormRequest class FQN or repo-relative file path."},
166
+ "dry_run": {"type": "boolean", "default": false}
167
+ },
168
+ "required": ["request_class"],
169
+ "additionalProperties": false
170
+ }
171
+ },
172
+ {
173
+ "name": "sync_gitignore",
174
+ "description": "Synchronise the `event4u/agent-config` block in the consumer project's `.gitignore` — adds missing entries, preserves user-added lines.",
175
+ "side_effect": "fs-write",
176
+ "implemented_on": [],
177
+ "input_schema": {
178
+ "type": "object",
179
+ "properties": {
180
+ "dry_run": {"type": "boolean", "default": false}
181
+ },
182
+ "additionalProperties": false
183
+ }
184
+ },
185
+ {
186
+ "name": "sync_agent_settings",
187
+ "description": "Synchronise `.agent-settings.yml` against the current template + profile — adds new sections/keys, preserves user values.",
188
+ "side_effect": "fs-write",
189
+ "implemented_on": [],
190
+ "input_schema": {
191
+ "type": "object",
192
+ "properties": {
193
+ "profile": {"type": "string", "description": "Override the profile pinned in the settings file."},
194
+ "dry_run": {"type": "boolean", "default": false}
195
+ },
196
+ "additionalProperties": false
197
+ }
198
+ },
199
+ {
200
+ "name": "run_tests",
201
+ "description": "Execute the consumer project's test suite via the project's standard test runner. Returns the exit code, runner name, and a structured tail of the output.",
202
+ "side_effect": "shell",
203
+ "implemented_on": [],
204
+ "input_schema": {
205
+ "type": "object",
206
+ "properties": {
207
+ "filter": {"type": "string", "description": "Restrict to tests matching this pattern."},
208
+ "path": {"type": "string", "description": "Restrict to tests under this directory."}
209
+ },
210
+ "additionalProperties": false
211
+ }
212
+ },
213
+ {
214
+ "name": "run_quality_checks",
215
+ "description": "Run the configured quality gate (PHPStan / Rector / ECS / equivalent) and return a structured per-tool result.",
216
+ "side_effect": "shell",
217
+ "implemented_on": [],
218
+ "input_schema": {
219
+ "type": "object",
220
+ "properties": {
221
+ "tool": {"type": "string", "description": "Restrict to a single tool name."}
222
+ },
223
+ "additionalProperties": false
224
+ }
225
+ },
226
+ {
227
+ "name": "list_skills",
228
+ "description": "Enumerate every skill currently exposed as a prompt, with name + description + source. Read-only manifest view.",
229
+ "side_effect": "ro",
230
+ "implemented_on": ["stdio"],
231
+ "input_schema": {"type": "object", "properties": {}, "additionalProperties": false}
232
+ },
233
+ {
234
+ "name": "list_commands",
235
+ "description": "Enumerate every slash command currently exposed as a prompt. Read-only manifest view.",
236
+ "side_effect": "ro",
237
+ "implemented_on": ["stdio"],
238
+ "input_schema": {"type": "object", "properties": {}, "additionalProperties": false}
239
+ },
240
+ {
241
+ "name": "list_rules",
242
+ "description": "Enumerate every rule currently exposed as a resource. Read-only manifest view.",
243
+ "side_effect": "ro",
244
+ "implemented_on": ["stdio"],
245
+ "input_schema": {"type": "object", "properties": {}, "additionalProperties": false}
246
+ },
247
+ {
248
+ "name": "compile_router",
249
+ "description": "Regenerate the compiled router (`router.compiled.json`) from the current rule / skill / command frontmatter. Shell-bound — wraps `scripts/compile_router.py`.",
250
+ "side_effect": "shell",
251
+ "implemented_on": [],
252
+ "input_schema": {
253
+ "type": "object",
254
+ "properties": {
255
+ "dry_run": {"type": "boolean", "default": false}
256
+ },
257
+ "additionalProperties": false
258
+ }
259
+ },
260
+ {
261
+ "name": "read_resource_body",
262
+ "description": "Fetch the rendered body of any resource URI (rule, guideline, context) without going through `resources/read`. Convenience for clients that want to inline content into a tool call result.",
263
+ "side_effect": "ro",
264
+ "implemented_on": ["stdio"],
265
+ "input_schema": {
266
+ "type": "object",
267
+ "properties": {
268
+ "uri": {"type": "string", "description": "Resource URI like `rule://commit-policy`."}
269
+ },
270
+ "required": ["uri"],
271
+ "additionalProperties": false
272
+ }
273
+ }
274
+ ]
275
+ }
@@ -0,0 +1,128 @@
1
+ """MCP telemetry sink — Phase 1 J4 instrumentation.
2
+
3
+ Per ``agents/roadmaps/road-to-mcp-full-coverage.md`` §Phase 1 J4 +
4
+ ``docs/contracts/mcp-tool-stub-envelope.md``, both transports log every
5
+ ``tools/call`` with ``{tool_name, client_id_hash, ts, transport,
6
+ outcome}``. Payload bodies are never logged; the client identifier is
7
+ hashed at the server boundary so the queryable store never sees raw
8
+ identity.
9
+
10
+ Outcomes:
11
+
12
+ - ``implemented`` — real handler ran (no envelope returned).
13
+ - ``stub`` — catalog entry missing this transport; ``not_implemented``
14
+ envelope returned.
15
+ - ``latent_demand`` — caller asked for a tool not in the catalog.
16
+
17
+ The sink writes JSONL to ``agents/.mcp-telemetry/calls.jsonl`` under the
18
+ consumer root. Failure to write must not break the wire surface: the
19
+ ``record_call`` helper swallows OSError + ValueError and emits a single
20
+ warning to stderr.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ Outcome = Literal["implemented", "stub", "latent_demand"]
33
+
34
+ # Stable file location relative to consumer_root. Phase 2 K1 routes
35
+ # this into a queryable store; Phase 1 only needs the file to exist.
36
+ TELEMETRY_REL_DIR = "agents/.mcp-telemetry"
37
+ TELEMETRY_FILENAME = "calls.jsonl"
38
+
39
+ # Truncation length for the client_id hash. 12 hex chars = 48 bits of
40
+ # entropy — enough to distinguish hundreds of consumers without
41
+ # becoming a re-identification vector.
42
+ _HASH_LEN = 12
43
+
44
+
45
+ def _client_id_seed() -> str:
46
+ """Identity components that together pin a consumer install.
47
+
48
+ USER + machine hostname + repo path is a stable triple that survives
49
+ sessions without leaking PII into the log. The hash never reverses.
50
+ """
51
+ user = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
52
+ host = os.environ.get("HOSTNAME")
53
+ if not host and hasattr(os, "uname"):
54
+ host = os.uname().nodename
55
+ host = host or "unknown"
56
+ cwd = str(Path.cwd().resolve())
57
+ return f"{user}|{host}|{cwd}"
58
+
59
+
60
+ def hash_client_id(seed: str | None = None) -> str:
61
+ """SHA-256(seed) truncated to 12 hex chars. Boundary-only call."""
62
+ raw = seed if seed is not None else _client_id_seed()
63
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
64
+ return digest[:_HASH_LEN]
65
+
66
+
67
+ def _resolve_log_path(consumer_root: Path | None = None) -> Path:
68
+ """Pick the JSONL location. Defaults to CWD when no override given."""
69
+ root = (consumer_root or Path.cwd()).resolve()
70
+ return root / TELEMETRY_REL_DIR / TELEMETRY_FILENAME
71
+
72
+
73
+ def _now_iso() -> str:
74
+ """ISO-8601 UTC timestamp, seconds precision."""
75
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
76
+
77
+
78
+ def build_record(
79
+ *,
80
+ tool_name: str,
81
+ outcome: Outcome,
82
+ transport: str,
83
+ client_id_hash_value: str | None = None,
84
+ ts: str | None = None,
85
+ ) -> dict[str, object]:
86
+ """Pure helper — assemble the record without touching the filesystem."""
87
+ return {
88
+ "tool_name": tool_name,
89
+ "client_id_hash": client_id_hash_value or hash_client_id(),
90
+ "ts": ts or _now_iso(),
91
+ "transport": transport,
92
+ "outcome": outcome,
93
+ }
94
+
95
+
96
+ def record_call(
97
+ *,
98
+ tool_name: str,
99
+ outcome: Outcome,
100
+ transport: str,
101
+ consumer_root: Path | None = None,
102
+ client_id_hash_value: str | None = None,
103
+ ) -> dict[str, object] | None:
104
+ """Append one JSONL record. Returns the record or None on failure.
105
+
106
+ Failures are swallowed: telemetry must never break the wire surface.
107
+ A single ``mcp-server: warn: telemetry`` line is emitted to stderr
108
+ so silent-failure windows show up in the boot log and the J6
109
+ healthcheck can detect them.
110
+ """
111
+ record = build_record(
112
+ tool_name=tool_name,
113
+ outcome=outcome,
114
+ transport=transport,
115
+ client_id_hash_value=client_id_hash_value,
116
+ )
117
+ target = _resolve_log_path(consumer_root)
118
+ try:
119
+ target.parent.mkdir(parents=True, exist_ok=True)
120
+ with target.open("a", encoding="utf-8") as fh:
121
+ fh.write(json.dumps(record, separators=(",", ":")) + "\n")
122
+ except (OSError, ValueError) as exc:
123
+ print(
124
+ f"mcp-server: warn: telemetry write failed: {exc}",
125
+ file=sys.stderr,
126
+ )
127
+ return None
128
+ return record