diff --git a/CHANGELOG.md b/CHANGELOG.md index 6076c82d..ffe3e2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ not per individual commit. ## [Unreleased] +### Added + +- Claude Code plugin telemetry. A plugin hook sends `agent_invocation` and + `subagent_invocation` events for Konductor's own agents, in the CLI's format, with + `claude-plugin` as the harness. It is on by default and shares the machine ID in + `~/.konductor/telemetry.json` with the CLI. Turn it off with the plugin's + `telemetry_enabled` option or any existing opt-out. + ### Fixed - The Claude Code plugin release candidate could include untracked build output (`cli/`, diff --git a/Makefile b/Makefile index af1a2776..3281c3dc 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,8 @@ # target's own script first and renders plugin.json/ # .mcp.json at the flat tree's root afterward -- there # is no separate repo-root plugin layout. -# claude-plugin-check PR-time smoke test: assembles the flat branch tree +# claude-plugin-check PR-time smoke test: runs the plugin telemetry hook's +# node tests, assembles the flat branch tree # (scripts/assemble-claude-plugin-branch.sh, which # calls claude-plugin's own generation first) into a # scratch directory, then validates it (--strict) when @@ -189,6 +190,8 @@ claude-plugin: # (see assemble-claude-plugin-branch.sh and claude-plugin's own comment # above). claude-plugin-check: + @echo "=== [claude-plugin-check] Running the plugin telemetry hook tests ===" + node --test tests/scripts/claude-plugin-telemetry-hook.test.js $(MAKE) -C cli build TARGET=$(TARGET) @test -x "$(KONDUCTOR_BIN)" || { echo "error: $(KONDUCTOR_BIN) not found; run 'make -C cli build' first" >&2; exit 1; } @validated=1; \ diff --git a/README.md b/README.md index fc537fe2..ab287d07 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,11 @@ that stays the same across all projects on your machine. UUID and stores it in `$HOME/.konductor/telemetry.json`. Every project on that machine sends the same UUID, so events from different projects can be linked to one machine, though not to which projects. +**Claude Code plugin.** The marketplace plugin sends `agent_invocation` and +`subagent_invocation` the same way, through a plugin hook, with `claude-plugin` as the runtime. It +shares the machine identifier in `$HOME/.konductor/telemetry.json` with the CLI. See +[Install via the Claude Code plugin marketplace](docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry). + **Opting out** | To stop reporting for | Do this | @@ -448,6 +453,7 @@ UUID, so events from different projects can be linked to one machine, though not | One project | Run `konductor install` or `konductor update` with `--no-telemetry`, or set `telemetry.enabled: false` in `.konductor/config.yml` | | Any process with the variable set (a shell, a CI job, a fleet) | Set `KONDUCTOR_TELEMETRY=off` | | The whole machine | Set `"telemetry_consent": false` in `$HOME/.konductor/telemetry.json` | +| The Claude Code plugin | Turn off the plugin's `telemetry_enabled` option. The variable, `config.yml` and machine settings above also apply; `--no-telemetry` doesn't | - `--no-telemetry` also removes an opt-in record left by an earlier install, and stops a global install's hooks from reporting in that project. diff --git a/docs/telemetry-schema.json b/docs/telemetry-schema.json index 090a91b1..3d4ddb9d 100644 --- a/docs/telemetry-schema.json +++ b/docs/telemetry-schema.json @@ -65,14 +65,14 @@ "string", "null" ], - "description": "Populated only on cli_error events, copied verbatim from the identity record. Null on every other eventType -- package_uninstalled/package_installed/package_version_updated carry their harness value through targetName instead, not through this field." + "description": "Populated on cli_error events, copied verbatim from the identity record. agent_invocation/subagent_invocation from the Claude Code marketplace plugin carry \"claude-plugin\"; the CLI sends null on those. Null on every other eventType -- package_uninstalled/package_installed/package_version_updated carry their harness value through targetName instead, not through this field." }, "agentVersion": { "type": [ "string", "null" ], - "description": "The installed CONTENT's own version (distinct from the outer envelope's Version, which is always the CLI binary's own env!(\"CARGO_PKG_VERSION\")) -- populated only on package_installed and package_version_updated, sourced from that install's /.konductor/install-info.json record. Null on every other eventType (agent_invocation, subagent_invocation, mcp_tool_call, cli_error, package_uninstalled), and also null on package_installed/package_version_updated when the install source predates the VERSION-in-dist/ convention or that file could not be read -- never a fabricated version string." + "description": "The installed CONTENT's own version (distinct from the outer envelope's Version, which is the CLI binary's own env!(\"CARGO_PKG_VERSION\") on CLI events and the plugin manifest version on Claude Code plugin events) -- populated only on package_installed and package_version_updated, sourced from that install's /.konductor/install-info.json record. Null on every other eventType (agent_invocation, subagent_invocation, mcp_tool_call, cli_error, package_uninstalled), and also null on package_installed/package_version_updated when the install source predates the VERSION-in-dist/ convention or that file could not be read -- never a fabricated version string." }, "TimeStamp": { "type": "string", diff --git a/docs/user-guide/tasks/install-claude-plugin-marketplace.md b/docs/user-guide/tasks/install-claude-plugin-marketplace.md index 557ccdff..801004bd 100644 --- a/docs/user-guide/tasks/install-claude-plugin-marketplace.md +++ b/docs/user-guide/tasks/install-claude-plugin-marketplace.md @@ -92,6 +92,35 @@ npx playwright install chromium Add `--scope user` to make that server available across projects. +## Telemetry + +The plugin sends usage telemetry to AWS by default. A hook runs when a +session starts with a Konductor agent and when a Konductor agent is +delegated to. Each event carries the agent name, the delegating Konductor +agent's name when Claude Code exposes it, the plugin version, a hashed +session ID, and `claude-plugin` as the harness so plugin events can be told +apart from CLI ones. Your own agents and Claude Code's built-in agents are never +reported. Prompts, file paths, and tool inputs are never sent. + +Events also carry the machine ID stored in `~/.konductor/telemetry.json`. +The plugin creates that file if it is missing. The Konductor CLI uses the +same file, so plugin and CLI events from one machine share one ID. + +To opt out, do any one of these: + +- Turn off the plugin's `telemetry_enabled` option in `/config`. +- Set `KONDUCTOR_TELEMETRY=off` in your environment. +- Set `telemetry.enabled: false` in `.konductor/config.yml` in the project, + any directory above it, or `$HOME`. +- Set `"telemetry_consent": false` in `~/.konductor/telemetry.json`. This + also stops the CLI from reporting on this machine. + +The plugin always sends to the default endpoint. It ignores +`KONDUCTOR_METRICS_ENDPOINT` and `telemetry.endpoint`. + +See [Data Collection](../../../README.md#data-collection) for the full +details and the event schema. + ## Related - [Install for Claude Code](install-claude-code.md) diff --git a/generated/claude-plugin/README.md b/generated/claude-plugin/README.md index 5b9d194b..3efbaabc 100644 --- a/generated/claude-plugin/README.md +++ b/generated/claude-plugin/README.md @@ -68,6 +68,9 @@ The published branch contains: .mcp.json agents/ skills/ +hooks/hooks.json +hooks/konductor-telemetry.js +docs/telemetry-schema.json README.md LICENSE.txt ``` @@ -111,6 +114,32 @@ mcp__plugin_konductor_aws-mcp__* Bring-your-own servers, including `playwright-mcp`, keep their bare grants and must be configured by the user. +## Telemetry + +The plugin ships a telemetry hook. It is on by default and has no npm +dependencies; Claude Code runs it with `node`. + +- `scripts/claude-plugin-telemetry-hook.js` is copied to + `hooks/konductor-telemetry.js`. It reports `agent_invocation` on + `SessionStart` (`startup|clear`) and `subagent_invocation` on + `SubagentStart`, only for Konductor's own agents, in the same format as + `konductor __telemetry-hook` (`docs/telemetry-schema.json`, also copied + into the tree). It prints nothing and always exits 0. +- `scripts/render-claude-plugin-hooks-json.py` writes `hooks/hooks.json`. + The `SubagentStart` matcher lists every file in the assembled `agents/`, + as `konductor:` only. Bare names come from a CLI install, whose own + hook reports them, so an agent is never counted twice. +- The machine ID is shared with the CLI through `~/.konductor/telemetry.json`. + +Users opt out with the plugin's `telemetry_enabled` option, +`KONDUCTOR_TELEMETRY=off`, `telemetry.enabled: false` in +`.konductor/config.yml`, or `"telemetry_consent": false` in +`~/.konductor/telemetry.json`. See the +[install guide](../../docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry) +and the root README's [Data Collection](../../README.md#data-collection) +section. Tests: `node --test tests/scripts/claude-plugin-telemetry-hook.test.js`, +also run by `make claude-plugin-check`. + ## Kiro CLI Kiro CLI renders `clientConfig.kiroCli.mcpServers`, not an agent's diff --git a/scripts/assemble-claude-plugin-branch.sh b/scripts/assemble-claude-plugin-branch.sh index 3f37237e..9a94ef2d 100755 --- a/scripts/assemble-claude-plugin-branch.sh +++ b/scripts/assemble-claude-plugin-branch.sh @@ -82,4 +82,14 @@ python3 "$REPO_ROOT/scripts/rewrite-claude-plugin-agent-grants.py" \ --agents-dir "$OUT/agents" \ --plugin-json "$OUT/.claude-plugin/plugin.json" +# Telemetry hook. hooks.json is generated from the assembled agents/ so the +# SubagentStart matcher covers every agent shipped. +mkdir -p "$OUT/hooks" "$OUT/docs" +cp "$REPO_ROOT/scripts/claude-plugin-telemetry-hook.js" "$OUT/hooks/konductor-telemetry.js" +cp "$REPO_ROOT/docs/telemetry-schema.json" "$OUT/docs/telemetry-schema.json" +python3 "$REPO_ROOT/scripts/render-claude-plugin-hooks-json.py" \ + --agents-dir "$OUT/agents" \ + --plugin-json "$OUT/.claude-plugin/plugin.json" \ + --output "$OUT/hooks/hooks.json" + echo "=== [assemble-claude-plugin] Done: flat plugin tree at $OUT ===" diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js new file mode 100644 index 00000000..674aecfc --- /dev/null +++ b/scripts/claude-plugin-telemetry-hook.js @@ -0,0 +1,726 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// +// Claude Code plugin telemetry hook, copied to hooks/konductor-telemetry.js +// in the published plugin tree. The plugin ships no konductor binary, so +// this is a dependency-free port of `konductor __telemetry-hook` for the two +// Claude Code events it handles: +// +// node konductor-telemetry.js agent-invocation (SessionStart) +// node konductor-telemetry.js subagent-invocation (SubagentStart) +// +// It reads the hook payload from stdin, prints nothing, and always exits 0. +// SessionStart stdout becomes model context, so any output would be a bug. +// +// The wire format matches cli/konductor-rs/src/cli/telemetry/envelope.rs, so +// plugin and CLI events line up. Both use the machine identity in +// ~/.konductor/telemetry.json. + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const https = require('node:https'); +const os = require('node:os'); +const path = require('node:path'); + +const SOLUTION_ID = 'SO0370'; +const DEFAULT_TELEMETRY_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic'; +const NIL_UUID_SENTINEL = '0'.repeat(64); +// Tells plugin events apart from the CLI's, which send null on agent events. +const HARNESS = 'claude-plugin'; +const INSTANCE_SCHEMA_VERSION = 1; +const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 64 * 1024; +const SEND_TIMEOUT_MS = 3000; +const AGENT_INVOCATION = 'agent-invocation'; +const SUBAGENT_INVOCATION = 'subagent-invocation'; +const DEFAULT_PLUGIN_NAME = 'konductor'; + +const PLUGIN_ROOT = path.resolve(__dirname, '..'); + +function sha256Hex(text) { + return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); +} + +function isValidUuidShape(value) { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +} + +function nonEmptyString(value) { + return typeof value === 'string' && value !== '' ? value : null; +} + +// Returns the text of a regular file of at most MAX_FILE_BYTES, else null. +// O_NONBLOCK stops a FIFO from blocking the open, which would also stall the +// backstop timer. +function readSmallFile(file) { + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); + } catch { + return null; + } + try { + if (!fs.fstatSync(fd).isFile()) { + return null; + } + const buffer = Buffer.alloc(MAX_FILE_BYTES + 1); + let size = 0; + while (size < buffer.length) { + const read = fs.readSync(fd, buffer, size, buffer.length - size, null); + if (read === 0) { + break; + } + size += read; + } + return size > MAX_FILE_BYTES ? null : buffer.toString('utf8', 0, size); + } catch { + return null; + } finally { + fs.closeSync(fd); + } +} + +function isoMillis(date) { + return date.toISOString(); +} + +// The outer envelope's "YYYY-MM-DD HH:MM:SS.f" shape (tenths of a second). +function wireTimestamp(date) { + const iso = date.toISOString(); + return `${iso.slice(0, 10)} ${iso.slice(11, 19)}.${iso.slice(20, 21)}`; +} + +function epochNanos() { + return BigInt(Date.now()) * 1000000n + (process.hrtime.bigint() % 1000000n); +} + +function hashPresentSessionId(uuid, rawSessionId) { + const raw = nonEmptyString(rawSessionId); + return raw === null ? null : sha256Hex(`${uuid}${raw}`); +} + +function buildEventBody({ + eventType, + targetName, + uuid, + version, + sessionId = null, + parentSessionId = null, + parentAgentName = null, + now = new Date(), +}) { + return { + Solution: SOLUTION_ID, + Version: version, + UUID: uuid, + TimeStamp: wireTimestamp(now), + Data: { + // Rust passes the nil sentinel as eventId entropy for agent events. + eventId: sha256Hex(`${NIL_UUID_SENTINEL}-${epochNanos()}-${process.pid}`), + eventType, + targetName, + sessionId: hashPresentSessionId(uuid, sessionId), + parentSessionId: hashPresentSessionId(uuid, parentSessionId), + parentAgentName: nonEmptyString(parentAgentName), + errorCode: null, + harness: HARNESS, + agentVersion: null, + TimeStamp: isoMillis(now), + }, + }; +} + +// ── Consent ────────────────────────────────────────────────────────────── + +function telemetryOffByEnv(env) { + return env.KONDUCTOR_TELEMETRY === 'off'; +} + +const TRUE_SCALARS = ['true', 'True', 'TRUE']; +const FALSE_SCALARS = ['false', 'False', 'FALSE']; + +function stripComment(line) { + let quote = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote) { + if (c === quote) { + quote = null; + } + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) { + return line.slice(0, i); + } + } + return line; +} + +// Turns a YAML flow mapping (or JSON) into JSON text. Plain scalars become +// strings except booleans, null and numbers. A `:` only splits a key from its +// value when followed by a space or after a quoted key, so URLs stay whole. +function flowToJson(text) { + const out = []; + let i = 0; + let afterQuoted = false; + while (i < text.length) { + const c = text[i]; + if (/\s/.test(c)) { + i++; + } else if ('{}[],'.includes(c)) { + out.push(c); + afterQuoted = false; + i++; + } else if (c === ':' && (afterQuoted || i + 1 === text.length || /[\s,}\]]/.test(text[i + 1]))) { + out.push(':'); + afterQuoted = false; + i++; + } else if (c === '"') { + const end = /^"(?:[^"\\]|\\.)*"/.exec(text.slice(i)); + if (!end) { + return null; + } + out.push(end[0]); + afterQuoted = true; + i += end[0].length; + } else if (c === "'") { + const end = /^'(?:[^']|'')*'/.exec(text.slice(i)); + if (!end) { + return null; + } + out.push(JSON.stringify(end[0].slice(1, -1).replace(/''/g, "'"))); + afterQuoted = true; + i += end[0].length; + } else { + let j = i; + while (j < text.length && !',{}[]'.includes(text[j]) && !(text[j] === ':' && (j + 1 === text.length || /[\s,}\]]/.test(text[j + 1])))) { + j++; + } + const plain = text.slice(i, j).trim(); + if (TRUE_SCALARS.includes(plain)) { + out.push('true'); + } else if (FALSE_SCALARS.includes(plain)) { + out.push('false'); + } else if (plain === 'null' || plain === '~') { + out.push('null'); + } else if (/^-?\d+(\.\d+)?$/.test(plain)) { + out.push(plain); + } else { + out.push(JSON.stringify(plain)); + } + afterQuoted = false; + i = j; + } + } + return out.join(''); +} + +function parseFlow(text) { + const json = flowToJson(text); + if (json === null) { + return undefined; + } + try { + return JSON.parse(json); + } catch { + return undefined; + } +} + +const KEY_LINE = /^(\s*)(?:"([^"]*)"|'([^']*)'|([^\s'"{[#][^:]*?))\s*:(?:\s+(.*))?$/; + +// `telemetry.enabled` from the top level of a block mapping: true, false, +// null when the section has no `enabled`, or undefined when it can't be +// read with confidence. +function blockEnabled(lines) { + let section = null; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const match = KEY_LINE.exec(line); + if (match === null) { + if (line.trimStart().startsWith('-') && section !== null) { + return undefined; + } + continue; + } + const [, indent, dq, sq, plain, rest = ''] = match; + const key = dq ?? sq ?? plain; + if (indent === '') { + if (key !== 'telemetry') { + if (section !== null) { + section.done = true; + } + continue; + } + if (section !== null) { + return undefined; + } + section = { values: [], childIndent: null, done: false }; + if (rest.startsWith('{')) { + let flow = rest; + while (!balanced(flow) && index + 1 < lines.length) { + flow += `\n${lines[++index]}`; + } + const value = parseFlow(flow); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + if (Object.hasOwn(value, 'enabled')) { + section.values.push(value.enabled); + } + section.done = true; + } else if (rest !== '') { + return undefined; + } + continue; + } + if (section === null || section.done) { + continue; + } + if (section.childIndent === null) { + section.childIndent = indent.length; + } + if (indent.length === section.childIndent && key === 'enabled') { + section.values.push(TRUE_SCALARS.includes(rest) ? true : FALSE_SCALARS.includes(rest) ? false : rest); + } + } + if (section === null) { + return undefined; + } + if (section.values.length === 0) { + return null; + } + return section.values.length === 1 && typeof section.values[0] === 'boolean' ? section.values[0] : undefined; +} + +function balanced(text) { + let depth = 0; + for (const c of text) { + depth += '{['.includes(c) ? 1 : '}]'.includes(c) ? -1 : 0; + } + return depth <= 0; +} + +// Reads `telemetry.enabled` from .konductor/config.yml. A missing setting +// leaves telemetry on, as in the CLI. Fails closed: if `enabled` is set but +// not plainly to true, or the file mentions both words but has no plain +// top-level `telemetry` key, telemetry is treated as off. YAML tags, +// anchors, aliases and merge keys are never interpreted. +function configDisablesTelemetry(rawText) { + const lines = rawText + .replace(/^\uFEFF/, '') + .replace(/^---[ \t]+(?=\S)/, '') + .split(/\r?\n/) + .map((line) => stripComment(line).replace(/\s+$/, '')) + .filter((line) => line.trim() !== '' && !/^(---|\.\.\.)$/.test(line)); + const body = lines.join('\n'); + if (!/telemetry/i.test(body) || !/enabled/i.test(body)) { + return false; + } + if (/(^|[\s:,[{-])[&*!]|< path.join(dir, '.konductor', 'config.yml')); +} + +// Nearest first, as the CLI walks install roots, then $HOME. An unreadable +// file is skipped, as the CLI's read_telemetry_config does. A non-regular or +// oversized file counts as unreadable. +function telemetryOffByConfig(cwd, home) { + return configPaths(cwd, home).some((file) => { + const text = readSmallFile(file); + return text !== null && configDisablesTelemetry(text); + }); +} + +// Plugin userConfig `telemetry_enabled`, exported by Claude Code to hooks. +function telemetryOffByPluginOption(env) { + const value = env.CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED; + if (typeof value !== 'string') { + return false; + } + return ['false', '0', 'off', 'no'].includes(value.trim().toLowerCase()); +} + +// ── Machine identity (~/.konductor/telemetry.json) ─────────────────────── + +function homeDir(env) { + const home = nonEmptyString(env.HOME); + if (home !== null) { + return home; + } + // The CLI anchors on $HOME only; Windows rarely sets it. + return process.platform === 'win32' ? nonEmptyString(os.homedir()) : null; +} + +function instancePath(home) { + return path.join(home, '.konductor', 'telemetry.json'); +} + +// Same outcomes as identity.rs `read_instance_raw`: ok, newer, or absent. +function readInstanceRaw(home) { + let value; + try { + value = JSON.parse(readSmallFile(instancePath(home))); + } catch { + return { kind: 'absent' }; + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { kind: 'absent' }; + } + const schemaVersion = value.schema_version; + if (!Number.isInteger(schemaVersion) || schemaVersion < 0) { + return { kind: 'absent' }; + } + if (schemaVersion > INSTANCE_SCHEMA_VERSION) { + return { kind: 'newer', uuid: isValidUuidShape(value.UUID) ? value.UUID : null }; + } + if ( + schemaVersion !== INSTANCE_SCHEMA_VERSION || + !isValidUuidShape(value.UUID) || + typeof value.created_at !== 'string' || + typeof value.telemetry_consent !== 'boolean' + ) { + return { kind: 'absent' }; + } + return { + kind: 'ok', + record: { + schema_version: schemaVersion, + UUID: value.UUID, + created_at: value.created_at, + telemetry_consent: value.telemetry_consent, + }, + }; +} + +function failClosed(uuid = NIL_UUID_SENTINEL) { + return { UUID: uuid, telemetry_consent: false }; +} + +function fromReadOutcome(outcome) { + if (outcome.kind === 'ok') { + return outcome.record; + } + if (outcome.kind === 'newer') { + return failClosed(outcome.uuid || NIL_UUID_SENTINEL); + } + return null; +} + +function createPrivateDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === 'win32') { + return; + } + const stat = fs.lstatSync(dir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error('not a real directory'); + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error('directory owned by another user'); + } + fs.chmodSync(dir, 0o700); +} + +function writeAll(fd, bytes) { + let offset = 0; + while (offset < bytes.length) { + const written = fs.writeSync(fd, bytes, offset, bytes.length - offset); + if (!(written > 0)) { + throw new Error('short write'); + } + offset += written; + } +} + +let tempCounter = 0; + +// Mirrors identity.rs `ensure_instance`: publish with link(2) so a racing +// writer can't be overwritten, and never repair or replace an existing file. +// An unreadable record fails closed (nil UUID, no consent) for this call. +function ensureInstance(home, now = new Date()) { + const existing = fromReadOutcome(readInstanceRaw(home)); + if (existing !== null) { + return existing; + } + const dir = path.join(home, '.konductor'); + try { + createPrivateDir(dir); + } catch { + return failClosed(); + } + const record = { + schema_version: INSTANCE_SCHEMA_VERSION, + UUID: crypto.randomBytes(32).toString('hex'), + created_at: isoMillis(now), + telemetry_consent: true, + }; + const tmp = path.join(dir, `telemetry.json.tmp-${process.pid}-${tempCounter++}`); + try { + try { + fs.unlinkSync(tmp); + } catch { + // No leftover temp file. + } + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0); + const fd = fs.openSync(tmp, flags, 0o600); + try { + if (process.platform !== 'win32') { + fs.fchmodSync(fd, 0o600); + } + writeAll(fd, Buffer.from(`${JSON.stringify(record, null, 2)}\n`, 'utf8')); + } finally { + fs.closeSync(fd); + } + try { + fs.linkSync(tmp, instancePath(home)); + return record; + } catch (err) { + if (err && err.code === 'EEXIST') { + return fromReadOutcome(readInstanceRaw(home)) || failClosed(); + } + return failClosed(); + } + } catch { + return failClosed(); + } finally { + try { + fs.unlinkSync(tmp); + } catch { + // Already gone. + } + } +} + +// ── Agent names ────────────────────────────────────────────────────────── + +function readPluginManifest(pluginRoot) { + try { + const manifest = JSON.parse(readSmallFile(path.join(pluginRoot, '.claude-plugin', 'plugin.json'))); + const version = nonEmptyString(manifest.version); + if (version === null) { + return null; + } + return { name: nonEmptyString(manifest.name) || DEFAULT_PLUGIN_NAME, version }; + } catch { + return null; + } +} + +function konductorAgentNames(pluginRoot) { + try { + return new Set( + fs + .readdirSync(path.join(pluginRoot, 'agents')) + .filter((file) => file.endsWith('.md') && !file.startsWith('_')) + .map((file) => file.slice(0, -'.md'.length)), + ); + } catch { + return new Set(); + } +} + +// Claude Code reports plugin agents as `:`. A bare name +// belongs to a CLI install, whose own hook reports it. Returns the bare +// name, or null when the agent isn't one of this plugin's. +function konductorAgentName(raw, pluginName, agents) { + const value = nonEmptyString(raw); + const prefix = `${pluginName}:`; + if (value === null || !value.startsWith(prefix)) { + return null; + } + const bare = value.slice(prefix.length); + return agents.has(bare) ? bare : null; +} + +// Mirrors the Claude rows of telemetry_hook.rs `resolve_invocations`. +function resolveInvocation(eventType, payload, env, pluginName, agents) { + const sessionId = nonEmptyString(payload.session_id) || nonEmptyString(payload.sessionId); + const agentType = nonEmptyString(payload.agent_type) || nonEmptyString(payload.agent_name); + const agent = konductorAgentName(agentType, pluginName, agents); + if (agent === null) { + return null; + } + if (eventType === AGENT_INVOCATION) { + return { eventType: 'agent_invocation', targetName: agent, sessionId, parentSessionId: null, parentAgentName: null }; + } + if (eventType === SUBAGENT_INVOCATION) { + return { + eventType: 'subagent_invocation', + targetName: agent, + sessionId, + parentSessionId: sessionId, + parentAgentName: konductorAgentName(env.CLAUDE_CODE_AGENT, pluginName, agents), + }; + } + return null; +} + +// ── Transport ──────────────────────────────────────────────────────────── + +function send(url, body) { + return new Promise((resolve) => { + let request; + const done = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + if (request) { + request.destroy(); + } + done(); + }, SEND_TIMEOUT_MS); + try { + const payload = Buffer.from(JSON.stringify(body), 'utf8'); + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length }, + timeout: SEND_TIMEOUT_MS, + }; + request = https.request(url, options); + request.on('response', (response) => { + response.resume(); + response.on('end', done); + response.on('error', done); + }); + request.on('timeout', () => request.destroy()); + request.on('error', done); + request.on('close', done); + request.end(payload); + } catch { + done(); + } + }); +} + +function readStdinCapped() { + return new Promise((resolve) => { + const chunks = []; + let size = 0; + process.stdin.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_HOOK_PAYLOAD_BYTES) { + process.stdin.destroy(); + resolve(null); + return; + } + chunks.push(chunk); + }); + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + process.stdin.on('error', () => resolve(null)); + }); +} + +async function run(argv, env) { + const eventType = argv[0]; + if (eventType !== AGENT_INVOCATION && eventType !== SUBAGENT_INVOCATION) { + return; + } + const text = await readStdinCapped(); + if (text === null) { + return; + } + let payload; + try { + payload = JSON.parse(text); + } catch { + return; + } + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return; + } + + if (telemetryOffByEnv(env) || telemetryOffByPluginOption(env)) { + return; + } + const manifest = readPluginManifest(PLUGIN_ROOT); + if (manifest === null) { + return; + } + const invocation = resolveInvocation( + eventType, + payload, + env, + manifest.name, + konductorAgentNames(PLUGIN_ROOT), + ); + if (invocation === null) { + return; + } + const home = homeDir(env); + if (home === null) { + return; + } + const cwd = nonEmptyString(payload.cwd) || process.cwd(); + if (telemetryOffByConfig(cwd, home)) { + return; + } + + const record = ensureInstance(home); + if (!record.telemetry_consent) { + return; + } + + await send(DEFAULT_TELEMETRY_ENDPOINT, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version })); +} + +function main() { + const quietExit = () => process.exit(0); + process.on('uncaughtException', quietExit); + process.on('unhandledRejection', quietExit); + // Backstop in case stdin never closes. + setTimeout(quietExit, SEND_TIMEOUT_MS + 2000).unref(); + run(process.argv.slice(2), process.env).then(quietExit, quietExit); +} + +if (require.main === module) { + main(); +} + +module.exports = { + DEFAULT_TELEMETRY_ENDPOINT, + INSTANCE_SCHEMA_VERSION, + MAX_HOOK_PAYLOAD_BYTES, + NIL_UUID_SENTINEL, + SEND_TIMEOUT_MS, + SOLUTION_ID, + buildEventBody, + configDisablesTelemetry, + ensureInstance, + konductorAgentName, + readInstanceRaw, + resolveInvocation, +}; diff --git a/scripts/claude-plugin.template.json b/scripts/claude-plugin.template.json index 1aaab28d..1e56beb1 100644 --- a/scripts/claude-plugin.template.json +++ b/scripts/claude-plugin.template.json @@ -28,6 +28,14 @@ "skills": [ "./generated/claude-plugin/skills/" ], + "userConfig": { + "telemetry_enabled": { + "type": "boolean", + "title": "Send usage telemetry to AWS", + "description": "Report which Konductor agents run. Each event carries the agent name, the delegating Konductor agent's name on a delegation, the plugin version, a hashed session ID, the claude-plugin harness tag, and the machine ID in ~/.konductor/telemetry.json, which the Konductor CLI also uses. See Data Collection in the README.", + "default": true + } + }, "metadata": { "sourceRepo": "https://github.com/aws-solutions/konductor", "generatedFrom": "agents/*.agent-spec.json, skills/*/SKILL.md, agent-sops/*.sop.md", diff --git a/scripts/render-claude-plugin-hooks-json.py b/scripts/render-claude-plugin-hooks-json.py new file mode 100755 index 00000000..aa77c3f1 --- /dev/null +++ b/scripts/render-claude-plugin-hooks-json.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Render the plugin's hooks/hooks.json for the telemetry hook. + +SessionStart (startup|clear) reports agent_invocation and SubagentStart +reports subagent_invocation, the same events the CLI wires into Claude +Code settings. The SubagentStart matcher is built from the assembled +agents/*.md, so a new agent can't be left out. It matches only the +`:` names Claude Code gives plugin agents; a bare name +belongs to a CLI install, whose own hook reports it. + +Called by scripts/assemble-claude-plugin-branch.sh. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +HOOK_SCRIPT = "hooks/konductor-telemetry.js" + + +def frontmatter_name(agent_file: Path): + lines = agent_file.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return None + for line in lines[1:]: + if line.strip() == "---": + return None + match = re.match(r"^name:\s*['\"]?([^'\"\s]+)['\"]?\s*$", line) + if match: + return match.group(1) + return None + + +def agent_names(agents_dir: Path): + names = [] + for agent_file in sorted(agents_dir.glob("*.md")): + if agent_file.name.startswith("_"): + continue + name = frontmatter_name(agent_file) + # The hook filters on file names, so they must match what Claude + # Code reports as agent_type. + if name != agent_file.stem: + raise ValueError( + f"{agent_file.name}: frontmatter name {name!r} does not match the file name" + ) + names.append(name) + return names + + +def command(event_type: str) -> dict: + return { + "type": "command", + "command": f'node "${{CLAUDE_PLUGIN_ROOT}}/{HOOK_SCRIPT}" {event_type}', + "async": True, + } + + +def regex_escape(text: str) -> str: + # Same character set as the CLI's installed_agents_matcher, so a + # JavaScript regex compiled with or without the `u` flag accepts it. + return "".join("\\" + c if c in "\\^$.|?*+()[]{}/" else c for c in text) + + +def render(plugin_name: str, names) -> dict: + alternatives = "|".join(regex_escape(name) for name in sorted(set(names))) + return { + "hooks": { + "SessionStart": [ + {"matcher": "startup|clear", "hooks": [command("agent-invocation")]} + ], + "SubagentStart": [ + { + "matcher": f"^{regex_escape(plugin_name)}:({alternatives})$", + "hooks": [command("subagent-invocation")], + } + ], + } + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--agents-dir", required=True, type=Path) + parser.add_argument("--plugin-json", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + plugin_name = json.loads(args.plugin_json.read_text(encoding="utf-8")).get("name") + if not plugin_name: + print(f"error: no plugin name in {args.plugin_json}", file=sys.stderr) + return 1 + try: + names = agent_names(args.agents_dir) + except ValueError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + if not names: + print(f"error: no agent .md files found under {args.agents_dir}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(render(plugin_name, names), indent=2) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js new file mode 100644 index 00000000..2298ecd7 --- /dev/null +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -0,0 +1,745 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Tests for scripts/claude-plugin-telemetry-hook.js, the Claude Code plugin's +// telemetry hook. Each end-to-end case runs the hook from a scratch plugin +// tree with a scratch HOME, and preloads claude-plugin-telemetry-https-stub.js +// so nothing reaches the real endpoint. +// +// Run: node --test tests/scripts/claude-plugin-telemetry-hook.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, spawnSync } = require('node:child_process'); +const { once } = require('node:events'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOK_SOURCE = path.join(REPO_ROOT, 'scripts', 'claude-plugin-telemetry-hook.js'); +const HOOKS_RENDERER = path.join(REPO_ROOT, 'scripts', 'render-claude-plugin-hooks-json.py'); +const SCHEMA_PATH = path.join(REPO_ROOT, 'docs', 'telemetry-schema.json'); +const REPORT_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry', 'report.rs'); +const TELEMETRY_HOOK_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry_hook.rs'); +const IDENTITY_RS = path.join(REPO_ROOT, 'shared', 'konductor-telemetry', 'src', 'identity.rs'); +const STUB = path.join(__dirname, 'claude-plugin-telemetry-https-stub.js'); + +const hook = require(HOOK_SOURCE); + +const PLUGIN_VERSION = '9.8.7'; +const AGENTS = ['konductor', 'k-architect', 'k-developer']; +const RAW_SESSION_ID = 'bd3b7f9b-1111-2222-3333-444455556666'; +const HEX64 = /^[a-f0-9]{64}$/; + +function scratchDir(t, label) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `konductor-plugin-telemetry-${label}-`)); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function makePluginTree(t) { + const root = scratchDir(t, 'plugin'); + fs.mkdirSync(path.join(root, '.claude-plugin')); + fs.mkdirSync(path.join(root, 'agents')); + fs.mkdirSync(path.join(root, 'hooks')); + fs.writeFileSync( + path.join(root, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'konductor', version: PLUGIN_VERSION }), + ); + for (const agent of AGENTS) { + fs.writeFileSync(path.join(root, 'agents', `${agent}.md`), `---\nname: ${agent}\n---\nbody\n`); + } + fs.copyFileSync(HOOK_SOURCE, path.join(root, 'hooks', 'konductor-telemetry.js')); + return root; +} + +// Runs the hook once. `env` is the whole environment apart from PATH, HOME +// and the stub's output file, so the developer's own settings never leak in. +function runHook(t, { eventType, payload, env = {}, home, stdin, preload = [], setupPlugin, timeout = 15000 }) { + const plugin = makePluginTree(t); + if (setupPlugin) { + setupPlugin(plugin); + } + const homeDir = home || scratchDir(t, 'home'); + const project = scratchDir(t, 'project'); + const stubOut = path.join(scratchDir(t, 'stub'), 'requests.jsonl'); + const input = + stdin !== undefined ? stdin : JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, ...payload }); + const requires = [STUB, ...preload].flatMap((file) => ['--require', file]); + const started = Date.now(); + const result = spawnSync( + process.execPath, + [...requires, path.join(plugin, 'hooks', 'konductor-telemetry.js'), ...(eventType ? [eventType] : [])], + { + input, + cwd: project, + env: { PATH: process.env.PATH, HOME: homeDir, TELEMETRY_STUB_OUT: stubOut, ...env }, + encoding: 'utf8', + timeout, + }, + ); + const elapsed = Date.now() - started; + const requests = fs.existsSync(stubOut) + ? fs + .readFileSync(stubOut, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + : []; + return { result, requests, home: homeDir, project, elapsed }; +} + +function recordPath(home) { + return path.join(home, '.konductor', 'telemetry.json'); +} + +function writeRecord(home, record) { + fs.mkdirSync(path.join(home, '.konductor'), { recursive: true }); + const text = typeof record === 'string' ? record : `${JSON.stringify(record, null, 2)}\n`; + fs.writeFileSync(recordPath(home), text); + return text; +} + +const VALID_UUID = 'ab'.repeat(32); + +function assertSilent(result) { + assert.equal(result.status, 0); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); +} + +// ── Contract with the schema and the CLI ───────────────────────────────── + +test('body from the exported builder has every schema-required field', () => { + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); + const sessionPattern = new RegExp(schema.properties.sessionId.pattern); + for (const eventType of ['agent_invocation', 'subagent_invocation']) { + const body = hook.buildEventBody({ + eventType, + targetName: 'k-architect', + uuid: VALID_UUID, + version: PLUGIN_VERSION, + sessionId: RAW_SESSION_ID, + parentSessionId: eventType === 'subagent_invocation' ? RAW_SESSION_ID : null, + parentAgentName: eventType === 'subagent_invocation' ? 'konductor' : null, + }); + assert.deepEqual(Object.keys(body), ['Solution', 'Version', 'UUID', 'TimeStamp', 'Data']); + for (const key of schema.required) { + assert.ok(Object.hasOwn(body.Data, key), `${eventType} is missing ${key}`); + } + assert.match(body.Data.sessionId, sessionPattern); + assert.match(body.Data.eventId, new RegExp(schema.properties.eventId.pattern)); + assert.equal(body.Data.harness, 'claude-plugin'); + assert.equal(body.Data.errorCode, null); + assert.equal(body.Data.agentVersion, null); + assert.match(body.TimeStamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d$/); + assert.match(body.Data.TimeStamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + } +}); + +test('session ids are hashed as sha256(UUID + raw id), and absent ids stay null', () => { + const crypto = require('node:crypto'); + const body = hook.buildEventBody({ + eventType: 'agent_invocation', + targetName: 'konductor', + uuid: VALID_UUID, + version: PLUGIN_VERSION, + sessionId: RAW_SESSION_ID, + }); + const expected = crypto.createHash('sha256').update(`${VALID_UUID}${RAW_SESSION_ID}`).digest('hex'); + assert.equal(body.Data.sessionId, expected); + assert.equal(body.Data.parentSessionId, null); + assert.equal(body.Data.parentAgentName, null); + const empty = hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'x', uuid: VALID_UUID, version: '1', sessionId: '' }); + assert.equal(empty.Data.sessionId, null); +}); + +test('endpoint and solution id match the CLI source', () => { + const source = fs.readFileSync(REPORT_RS, 'utf8'); + const endpoint = /const DEFAULT_TELEMETRY_ENDPOINT: &str = "([^"]+)";/.exec(source); + const solution = /const SOLUTION_ID: &str = "([^"]+)";/.exec(source); + assert.ok(endpoint && solution, 'constants not found in report.rs'); + assert.equal(hook.DEFAULT_TELEMETRY_ENDPOINT, endpoint[1]); + assert.equal(hook.SOLUTION_ID, solution[1]); +}); + +function rustConst(file, name) { + const match = new RegExp(`const ${name}: [^=]+=\\s*([^;]+);`).exec(fs.readFileSync(file, 'utf8')); + assert.ok(match, `${name} not found in ${path.relative(REPO_ROOT, file)}`); + return match[1].trim(); +} + +test('identity and payload constants match the Rust sources', () => { + assert.equal(hook.NIL_UUID_SENTINEL, JSON.parse(rustConst(IDENTITY_RS, 'NIL_UUID_SENTINEL'))); + assert.equal(hook.INSTANCE_SCHEMA_VERSION, Number(rustConst(IDENTITY_RS, 'INSTANCE_SCHEMA_VERSION'))); + const maxPayload = rustConst(TELEMETRY_HOOK_RS, 'MAX_HOOK_PAYLOAD_BYTES'); + assert.match(maxPayload, /^[\d\s*]+$/); + assert.equal(hook.MAX_HOOK_PAYLOAD_BYTES, maxPayload.split('*').reduce((n, f) => n * Number(f), 1)); +}); + +function jsonType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (Number.isInteger(value)) return 'integer'; + return typeof value; +} + +test('every event field matches its schema type and pattern', () => { + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); + const bodies = [ + hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'konductor', uuid: VALID_UUID, version: '1', sessionId: RAW_SESSION_ID }), + hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'konductor', uuid: VALID_UUID, version: '1' }), + hook.buildEventBody({ + eventType: 'subagent_invocation', + targetName: 'k-developer', + uuid: VALID_UUID, + version: '1', + sessionId: RAW_SESSION_ID, + parentSessionId: RAW_SESSION_ID, + parentAgentName: 'konductor', + }), + ]; + for (const { Data } of bodies) { + for (const [key, value] of Object.entries(Data)) { + const property = schema.properties[key]; + assert.ok(property, `${key} is not in the schema`); + const types = [].concat(property.type); + const actual = jsonType(value); + assert.ok(types.includes(actual) || (actual === 'integer' && types.includes('number')), `${key}: ${actual} not in ${types}`); + if (typeof value === 'string' && property.pattern) { + assert.match(value, new RegExp(property.pattern), key); + } + if (typeof value === 'string' && property.format === 'date-time') { + assert.ok(!Number.isNaN(Date.parse(value)), key); + } + } + } +}); + +// ── End to end ─────────────────────────────────────────────────────────── + +test('SessionStart for a plugin agent posts one agent_invocation', (t) => { + const { result, requests, home } = runHook(t, { + eventType: 'agent-invocation', + payload: { hook_event_name: 'SessionStart', source: 'startup', agent_type: 'konductor:konductor' }, + }); + assertSilent(result); + assert.equal(requests.length, 1); + const [request] = requests; + assert.equal(request.url, hook.DEFAULT_TELEMETRY_ENDPOINT); + assert.equal(request.method, 'POST'); + assert.equal(request.headers['Content-Type'], 'application/json'); + const body = JSON.parse(request.body); + const record = JSON.parse(fs.readFileSync(recordPath(home), 'utf8')); + assert.equal(body.Solution, 'SO0370'); + assert.equal(body.Version, PLUGIN_VERSION); + assert.equal(body.UUID, record.UUID); + assert.equal(body.Data.eventType, 'agent_invocation'); + assert.equal(body.Data.targetName, 'konductor'); + assert.match(body.Data.sessionId, HEX64); + assert.equal(body.Data.parentSessionId, null); +}); + +test('SubagentStart reports the target and a Konductor parent from CLAUDE_CODE_AGENT', (t) => { + const { requests } = runHook(t, { + eventType: 'subagent-invocation', + payload: { hook_event_name: 'SubagentStart', agent_id: 'a36b', agent_type: 'konductor:k-developer' }, + env: { CLAUDE_CODE_AGENT: 'konductor:konductor' }, + }); + assert.equal(requests.length, 1); + const data = JSON.parse(requests[0].body).Data; + assert.equal(data.eventType, 'subagent_invocation'); + assert.equal(data.targetName, 'k-developer'); + assert.equal(data.parentAgentName, 'konductor'); + assert.equal(data.parentSessionId, data.sessionId); +}); + +test('a non-Konductor parent agent is never named', (t) => { + for (const parent of ['my-own-agent', 'other-plugin:konductor', 'konductor', undefined]) { + const env = parent === undefined ? {} : { CLAUDE_CODE_AGENT: parent }; + const { requests } = runHook(t, { + eventType: 'subagent-invocation', + payload: { agent_type: 'konductor:k-architect' }, + env, + }); + assert.equal(requests.length, 1, String(parent)); + assert.equal(JSON.parse(requests[0].body).Data.parentAgentName, null, String(parent)); + } +}); + +test('non-Konductor agents send nothing, including other namespaces', (t) => { + for (const agentType of ['Explore', 'general-purpose', 'k-architect', 'konductor', 'other-plugin:k-architect', 'konductor:not-ours', 'konductor:', '']) { + for (const eventType of ['agent-invocation', 'subagent-invocation']) { + const { result, requests, home } = runHook(t, { eventType, payload: { agent_type: agentType } }); + assertSilent(result); + assert.equal(requests.length, 0, `${eventType} ${agentType}`); + assert.equal(fs.existsSync(recordPath(home)), false, 'no record minted for a dropped event'); + } + } +}); + +test('a SessionStart with no agent_type sends nothing', (t) => { + const { result, requests } = runHook(t, { + eventType: 'agent-invocation', + payload: { hook_event_name: 'SessionStart', source: 'startup' }, + }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +// ── Opt-outs ───────────────────────────────────────────────────────────── + +const AGENT_EVENT = { eventType: 'agent-invocation', payload: { agent_type: 'konductor:konductor' } }; + +test('KONDUCTOR_TELEMETRY=off suppresses sending and minting', (t) => { + const { result, requests, home } = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_TELEMETRY: 'off' } }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.existsSync(recordPath(home)), false); +}); + +test('telemetry.enabled: false in the project config suppresses sending', (t) => { + const project = scratchDir(t, 'config-project'); + fs.mkdirSync(path.join(project, '.konductor')); + fs.writeFileSync(path.join(project, '.konductor', 'config.yml'), 'telemetry:\n enabled: false\n'); + const { result, requests } = runHook(t, { + eventType: 'agent-invocation', + stdin: JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, agent_type: 'konductor:konductor' }), + }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +test('telemetry_consent: false in the machine record suppresses sending', (t) => { + const home = scratchDir(t, 'declined-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: false, + }); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('the plugin telemetry_enabled option set to false suppresses sending', (t) => { + const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED: 'false' } }); + assertSilent(result); + assert.equal(requests.length, 0); + const enabled = runHook(t, { ...AGENT_EVENT, env: { CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED: 'true' } }); + assert.equal(enabled.requests.length, 1); +}); + +function writeConfig(dir, text) { + fs.mkdirSync(path.join(dir, '.konductor'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.konductor', 'config.yml'), text); +} + +function agentEventIn(cwd) { + return { + eventType: 'agent-invocation', + stdin: JSON.stringify({ cwd, session_id: RAW_SESSION_ID, agent_type: 'konductor:konductor' }), + }; +} + +test('an opt-out in a parent directory of cwd suppresses sending', (t) => { + const project = scratchDir(t, 'parent-config'); + writeConfig(project, 'telemetry:\n enabled: false\n'); + const subdir = path.join(project, 'packages', 'app'); + fs.mkdirSync(subdir, { recursive: true }); + const { result, requests } = runHook(t, agentEventIn(subdir)); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +test('an opt-out in $HOME/.konductor/config.yml suppresses sending', (t) => { + const home = scratchDir(t, 'home-config'); + writeConfig(home, 'telemetry:\n enabled: false\n'); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.existsSync(recordPath(home)), false); +}); + +test('a nearer config with telemetry on does not override an opt-out further up', (t) => { + const home = scratchDir(t, 'home-config-off'); + writeConfig(home, 'telemetry:\n enabled: false\n'); + const project = scratchDir(t, 'project-config-on'); + writeConfig(project, 'telemetry:\n enabled: true\n'); + const { requests } = runHook(t, { ...agentEventIn(project), home }); + assert.equal(requests.length, 0); +}); + +test('an unreadable config fails open, as the CLI does', (t) => { + const asDirectory = scratchDir(t, 'config-dir'); + fs.mkdirSync(path.join(asDirectory, '.konductor', 'config.yml'), { recursive: true }); + assert.equal(runHook(t, agentEventIn(asDirectory)).requests.length, 1); + if (process.platform !== 'win32' && process.getuid() !== 0) { + const noRead = scratchDir(t, 'config-noread'); + writeConfig(noRead, 'telemetry:\n enabled: false\n'); + fs.chmodSync(path.join(noRead, '.konductor', 'config.yml'), 0o000); + assert.equal(runHook(t, agentEventIn(noRead)).requests.length, 1); + } +}); + +test('config parser treats telemetry.enabled: false as off in every YAML form', () => { + const off = { + block: 'telemetry:\n enabled: false\n', + 'block with comment': 'telemetry:\n endpoint: https://x\n enabled: false # off\n', + 'flow value': 'telemetry: {enabled: false}\n', + 'capitalized, deeper indent': 'other: 1\ntelemetry:\n enabled: False\n', + 'quoted key': '"telemetry":\n "enabled": false\n', + 'single-quoted key': "'telemetry':\n 'enabled': false\n", + BOM: '\uFEFFtelemetry:\n enabled: false\n', + 'flow top': '{telemetry: {enabled: false}}\n', + JSON: '{"telemetry": {"enabled": false, "endpoint": "https://x/y"}}\n', + 'flow quoted': "telemetry: {'enabled': false}\n", + 'flow top quoted': "{'telemetry': {\"enabled\": false}}\n", + 'multi-line flow': 'telemetry: {\n endpoint: https://x/y,\n enabled: false\n}\n', + '!!bool tag': 'telemetry:\n enabled: !!bool false\n', + 'anchor and merge': 'defaults: &d\n enabled: false\ntelemetry:\n <<: *d\n', + 'quoted scalar': 'telemetry:\n enabled: "false"\n', + 'duplicate key': 'telemetry:\n enabled: true\n enabled: false\n', + 'unclosed flow': 'telemetry: {enabled: true\n', + 'flow after ---': '--- {telemetry: {enabled: false}}\n', + 'uniform root indent': ' telemetry:\n enabled: false\n', + 'indented after ---': '--- \n telemetry:\n enabled: false\n', + 'indented JSON': ' {\n "telemetry": {\n "enabled": false\n }\n}\n', + 'complex key': '? telemetry\n: enabled: false\n', + }; + const on = { + block: 'telemetry:\n enabled: true\n', + 'block with endpoint': 'telemetry:\n endpoint: https://x/y # here\n enabled: TRUE\n', + JSON: '{"telemetry": {"enabled": true}}', + 'flow top': '{telemetry: {enabled: true, endpoint: https://x/y}}\n', + BOM: '\uFEFFtelemetry:\n enabled: true\n', + 'no enabled key': 'telemetry:\n endpoint: https://x\n', + 'no telemetry key': 'enabled: false\n', + 'other section': 'other:\n enabled: false\n', + 'nested enabled': 'telemetry:\n nested:\n enabled: false\n', + 'commented out': '# telemetry:\n# enabled: false\n', + 'after ---': '--- \ntelemetry:\n enabled: true\n', + 'flow after ---': '--- {telemetry: {enabled: true}}\n', + empty: '', + }; + for (const [name, text] of Object.entries(off)) { + assert.equal(hook.configDisablesTelemetry(text), true, name); + } + for (const [name, text] of Object.entries(on)) { + assert.equal(hook.configDisablesTelemetry(text), false, name); + } +}); + +// ── File reads ─────────────────────────────────────────────────────────── + +const POSIX_ONLY = { skip: process.platform === 'win32' }; + +function mkfifo(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const made = spawnSync('mkfifo', [file]); + assert.equal(made.status, 0, 'mkfifo failed'); +} + +test('a FIFO config.yml is skipped without blocking the hook', POSIX_ONLY, (t) => { + const project = scratchDir(t, 'fifo-config'); + mkfifo(path.join(project, '.konductor', 'config.yml')); + const { result, requests, elapsed } = runHook(t, { ...agentEventIn(project), timeout: 8000 }); + assertSilent(result); + assert.ok(elapsed < 3000, `took ${elapsed} ms`); + assert.equal(requests.length, 1); +}); + +test('a FIFO telemetry.json or plugin.json exits promptly and sends nothing', POSIX_ONLY, (t) => { + const fifoHome = scratchDir(t, 'fifo-home'); + mkfifo(recordPath(fifoHome)); + const fifoManifest = (plugin) => { + const manifest = path.join(plugin, '.claude-plugin', 'plugin.json'); + fs.rmSync(manifest); + mkfifo(manifest); + }; + for (const options of [{ home: fifoHome }, { setupPlugin: fifoManifest }]) { + const { result, requests, elapsed } = runHook(t, { ...AGENT_EVENT, ...options, timeout: 8000 }); + assertSilent(result); + assert.ok(elapsed < 3000, `took ${elapsed} ms`); + assert.equal(requests.length, 0); + } +}); + +test('a config.yml over 64 KiB is treated as unreadable', (t) => { + const optOut = 'telemetry:\n enabled: false\n'; + const atCap = scratchDir(t, 'config-at-cap'); + writeConfig(atCap, optOut + '#'.repeat(64 * 1024 - optOut.length - 1) + '\n'); + assert.equal(runHook(t, agentEventIn(atCap)).requests.length, 0); + const overCap = scratchDir(t, 'config-over-cap'); + writeConfig(overCap, optOut + '#'.repeat(64 * 1024 - optOut.length) + '\n'); + assert.equal(runHook(t, agentEventIn(overCap)).requests.length, 1); +}); + +test('an oversized telemetry.json fails closed and is left alone', (t) => { + const home = scratchDir(t, 'big-record-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: true, + pad: 'x'.repeat(64 * 1024), + }); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('a non-Konductor session opens no config files', (t) => { + const traceDir = scratchDir(t, 'trace'); + const traceOut = path.join(traceDir, 'opened.txt'); + const tracer = path.join(traceDir, 'trace-config-reads.js'); + fs.writeFileSync( + tracer, + `'use strict'; +const fs = require('node:fs'); +for (const name of ['openSync', 'readFileSync', 'statSync', 'existsSync']) { + const original = fs[name]; + fs[name] = function (file, ...rest) { + if (String(file).endsWith('config.yml')) { + fs.appendFileSync(${JSON.stringify(traceOut)}, String(file) + '\\n'); + } + return original.call(this, file, ...rest); + }; +} +`, + ); + const project = scratchDir(t, 'traced-project'); + writeConfig(project, 'telemetry:\n enabled: true\n'); + const opened = () => (fs.existsSync(traceOut) ? fs.readFileSync(traceOut, 'utf8') : ''); + const stdin = (agentType) => JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, agent_type: agentType }); + + const other = runHook(t, { eventType: 'agent-invocation', stdin: stdin('Explore'), preload: [tracer] }); + assertSilent(other.result); + assert.equal(opened(), ''); + + const ours = runHook(t, { eventType: 'agent-invocation', stdin: stdin('konductor:konductor'), preload: [tracer] }); + assert.equal(ours.requests.length, 1); + assert.notEqual(opened(), '', 'the tracer saw no reads at all'); +}); + +// ── Identity ───────────────────────────────────────────────────────────── + +test('creates the machine record with mode 0600 when missing', (t) => { + const { requests, home } = runHook(t, AGENT_EVENT); + const record = JSON.parse(fs.readFileSync(recordPath(home), 'utf8')); + assert.deepEqual(Object.keys(record), ['schema_version', 'UUID', 'created_at', 'telemetry_consent']); + assert.equal(record.schema_version, 1); + assert.match(record.UUID, HEX64); + assert.equal(record.telemetry_consent, true); + assert.equal(JSON.parse(requests[0].body).UUID, record.UUID); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(recordPath(home)).mode & 0o777, 0o600); + } + const leftovers = fs.readdirSync(path.join(home, '.konductor')).filter((f) => f.includes('.tmp-')); + assert.deepEqual(leftovers, []); +}); + +test('never overwrites an existing record and sends its UUID', (t) => { + const home = scratchDir(t, 'existing-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: true, + }); + const { requests } = runHook(t, { ...AGENT_EVENT, home }); + assert.equal(requests.length, 1); + assert.equal(JSON.parse(requests[0].body).UUID, VALID_UUID); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('an invalid record resolves to the nil sentinel, is left alone, and sends nothing', (t) => { + const invalid = [ + '{not json', + JSON.stringify({ schema_version: 1, UUID: 'AB'.repeat(32), created_at: 'x', telemetry_consent: true }), + JSON.stringify({ schema_version: 1, UUID: VALID_UUID, telemetry_consent: true }), + JSON.stringify({ schema_version: 2, telemetry_consent: true }), + ]; + for (const text of invalid) { + const home = scratchDir(t, 'invalid-home'); + writeRecord(home, text); + const record = hook.ensureInstance(home); + assert.equal(record.UUID, hook.NIL_UUID_SENTINEL, text); + assert.equal(record.telemetry_consent, false, text); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0, text); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); + } +}); + +test('a newer-schema record keeps its UUID but fails closed on consent', (t) => { + const home = scratchDir(t, 'newer-home'); + writeRecord(home, { schema_version: 2, UUID: VALID_UUID, telemetry_consent: true }); + assert.deepEqual(hook.ensureInstance(home), { UUID: VALID_UUID, telemetry_consent: false }); +}); + +test('creates ~/.konductor with mode 0700', { skip: process.platform === 'win32' }, (t) => { + const { requests, home } = runHook(t, AGENT_EVENT); + assert.equal(requests.length, 1); + assert.equal(fs.lstatSync(path.join(home, '.konductor')).mode & 0o777, 0o700); +}); + +test('a symlinked ~/.konductor sends nothing and writes nothing into the target', { skip: process.platform === 'win32' }, (t) => { + const home = scratchDir(t, 'symlink-home'); + const target = scratchDir(t, 'symlink-target'); + fs.symlinkSync(target, path.join(home, '.konductor')); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.deepEqual(fs.readdirSync(target), []); + assert.equal(fs.lstatSync(path.join(home, '.konductor')).isSymbolicLink(), true); +}); + +function withWriteSync(t, replacement) { + const original = fs.writeSync; + fs.writeSync = (fd, data, ...rest) => replacement(original, fd, data, ...rest); + t.after(() => { + fs.writeSync = original; + }); +} + +test('a short write still publishes the whole record', (t) => { + const home = scratchDir(t, 'short-write-home'); + withWriteSync(t, (original, fd, data, offset = 0, length) => { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data)); + return original(fd, bytes, offset, Math.min(7, length ?? bytes.length - offset)); + }); + const record = hook.ensureInstance(home); + assert.equal(record.telemetry_consent, true); + assert.deepEqual(JSON.parse(fs.readFileSync(recordPath(home), 'utf8')), record); +}); + +test('a write that makes no progress fails closed and publishes nothing', (t) => { + const home = scratchDir(t, 'stalled-write-home'); + withWriteSync(t, () => 0); + assert.deepEqual(hook.ensureInstance(home), { UUID: hook.NIL_UUID_SENTINEL, telemetry_consent: false }); + assert.equal(fs.existsSync(recordPath(home)), false); + assert.deepEqual(fs.readdirSync(path.join(home, '.konductor')), []); +}); + +// ── Failure handling ───────────────────────────────────────────────────── + +test('bad input prints nothing and exits 0', (t) => { + const cases = [ + { eventType: 'agent-invocation', stdin: 'not json at all' }, + { eventType: 'agent-invocation', stdin: '' }, + { eventType: 'agent-invocation', stdin: '[1,2,3]' }, + { eventType: 'agent-invocation', stdin: 'null' }, + { eventType: 'subagent-invocation', stdin: '{"agent_type": 42, "session_id": {}}' }, + { eventType: 'agent-invocation', stdin: JSON.stringify({ agent_type: 'konductor', pad: 'x'.repeat(70 * 1024) }) }, + { eventType: 'bogus-event', stdin: JSON.stringify({ agent_type: 'konductor' }) }, + { eventType: undefined, stdin: JSON.stringify({ agent_type: 'konductor' }) }, + ]; + for (const testCase of cases) { + const { result, requests } = runHook(t, testCase); + assertSilent(result); + assert.equal(requests.length, 0, JSON.stringify(testCase.eventType)); + } +}); + +test('an unwritable HOME prints nothing and exits 0', (t) => { + const home = path.join(scratchDir(t, 'file-home'), 'not-a-dir'); + fs.writeFileSync(home, 'x'); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +test('a request that never responds is dropped after SEND_TIMEOUT_MS', (t) => { + const { result, requests, elapsed } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_MODE: 'hang' } }); + assertSilent(result); + assert.equal(requests.length, 1); + assert.ok(elapsed >= hook.SEND_TIMEOUT_MS - 200 && elapsed < hook.SEND_TIMEOUT_MS + 1500, `took ${elapsed} ms`); +}); + +test('stdin that never closes still exits 0 through the backstop', async (t) => { + const plugin = makePluginTree(t); + const stubOut = path.join(scratchDir(t, 'stub'), 'requests.jsonl'); + const started = Date.now(); + const child = spawn(process.execPath, ['--require', STUB, path.join(plugin, 'hooks', 'konductor-telemetry.js'), 'agent-invocation'], { + cwd: scratchDir(t, 'project'), + env: { PATH: process.env.PATH, HOME: scratchDir(t, 'home'), TELEMETRY_STUB_OUT: stubOut }, + stdio: ['pipe', 'ignore', 'ignore'], + }); + const killer = setTimeout(() => child.kill('SIGKILL'), 10000); + const [code] = await once(child, 'exit'); + clearTimeout(killer); + child.stdin.destroy(); + const elapsed = Date.now() - started; + assert.equal(code, 0); + assert.ok(elapsed < hook.SEND_TIMEOUT_MS + 3500, `took ${elapsed} ms`); + assert.equal(fs.existsSync(stubOut), false); +}); + +test('the endpoint is fixed: KONDUCTOR_METRICS_ENDPOINT and telemetry.endpoint are ignored', (t) => { + const project = scratchDir(t, 'endpoint-config'); + writeConfig(project, 'telemetry:\n enabled: true\n endpoint: https://config.example/generic\n'); + const { requests } = runHook(t, { ...agentEventIn(project), env: { KONDUCTOR_METRICS_ENDPOINT: 'https://x.example/' } }); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, hook.DEFAULT_TELEMETRY_ENDPOINT); +}); + +// ── hooks.json ─────────────────────────────────────────────────────────── + +test('rendered hooks.json wires both events and matches every plugin agent', (t) => { + const plugin = makePluginTree(t); + const output = path.join(plugin, 'hooks', 'hooks.json'); + const render = spawnSync( + 'python3', + [HOOKS_RENDERER, '--agents-dir', path.join(plugin, 'agents'), '--plugin-json', path.join(plugin, '.claude-plugin', 'plugin.json'), '--output', output], + { encoding: 'utf8' }, + ); + assert.equal(render.status, 0, render.stderr); + const { hooks } = JSON.parse(fs.readFileSync(output, 'utf8')); + assert.deepEqual(Object.keys(hooks), ['SessionStart', 'SubagentStart']); + assert.equal(hooks.SessionStart[0].matcher, 'startup|clear'); + for (const [event, arg] of [['SessionStart', 'agent-invocation'], ['SubagentStart', 'subagent-invocation']]) { + const [handler] = hooks[event][0].hooks; + assert.equal(handler.type, 'command'); + assert.equal(handler.async, true); + assert.equal(handler.command, `node "\${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js" ${arg}`); + } + const matcher = new RegExp(hooks.SubagentStart[0].matcher, 'u'); + for (const agent of AGENTS) { + assert.ok(matcher.test(`konductor:${agent}`), agent); + assert.equal(matcher.test(agent), false, `bare ${agent}`); + } + for (const other of ['Explore', 'other:konductor', 'konductor:k-architect-x', 'k-architec']) { + assert.equal(matcher.test(other), false, other); + } +}); + +test('hooks.json rendering fails when an agent name differs from its file name', (t) => { + const plugin = makePluginTree(t); + fs.writeFileSync(path.join(plugin, 'agents', 'k-developer.md'), '---\nname: someone-else\n---\n'); + const render = spawnSync( + 'python3', + [HOOKS_RENDERER, '--agents-dir', path.join(plugin, 'agents'), '--plugin-json', path.join(plugin, '.claude-plugin', 'plugin.json'), '--output', path.join(plugin, 'out.json')], + { encoding: 'utf8' }, + ); + assert.notEqual(render.status, 0); +}); + +test('a config without telemetry.enabled leaves telemetry on, as the CLI does', () => { + const { configDisablesTelemetry } = require('../../scripts/claude-plugin-telemetry-hook.js'); + assert.equal(configDisablesTelemetry('telemetry:\n endpoint: https://metrics.example.com/generic\nmcp:\n enabled: true\n'), false); + assert.equal(configDisablesTelemetry('{"telemetry": {"endpoint": "x"}, "mcp": {"enabled": true}}\n'), false); + assert.equal(configDisablesTelemetry('mcp:\n enabled: false\n'), false); +}); + +test('comments do not affect the telemetry.enabled reading', () => { + const { configDisablesTelemetry } = require('../../scripts/claude-plugin-telemetry-hook.js'); + assert.equal(configDisablesTelemetry('telemetry:\n enabled: true # see **docs**\n'), false); + assert.equal(configDisablesTelemetry('# telemetry enabled: false\nmcp: {}\n'), false); + assert.equal(configDisablesTelemetry('telemetry:\n enabled: false # opted out\n'), true); +}); diff --git a/tests/scripts/claude-plugin-telemetry-https-stub.js b/tests/scripts/claude-plugin-telemetry-https-stub.js new file mode 100644 index 00000000..44886eab --- /dev/null +++ b/tests/scripts/claude-plugin-telemetry-https-stub.js @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// Preloaded with `node --require` by claude-plugin-telemetry-hook.test.js. +// Replaces https.request so the hook never reaches the network, and appends +// each request it would have sent to $TELEMETRY_STUB_OUT as one JSON line. +// With TELEMETRY_STUB_MODE=hang the request never gets a response, and an open +// timer stands in for the socket until the hook destroys the request. +'use strict'; + +const fs = require('node:fs'); +const https = require('node:https'); +const { EventEmitter } = require('node:events'); + +https.request = (url, options) => { + const request = new EventEmitter(); + const chunks = []; + let socket = null; + request.setTimeout = () => request; + request.destroy = () => { + clearInterval(socket); + request.emit('close'); + }; + request.write = (chunk) => chunks.push(Buffer.from(chunk)); + const record = () => { + fs.appendFileSync( + process.env.TELEMETRY_STUB_OUT, + `${JSON.stringify({ + url: String(url), + method: options.method, + headers: options.headers, + body: Buffer.concat(chunks).toString('utf8'), + })}\n`, + ); + if (process.env.TELEMETRY_STUB_MODE === 'hang') { + socket = setInterval(() => {}, 1000); + return; + } + setImmediate(() => { + const response = new EventEmitter(); + response.resume = () => setImmediate(() => response.emit('end')); + request.emit('response', response); + }); + }; + request.end = (chunk) => { + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + record(); + }; + return request; +}; diff --git a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py index 9eebd0cc..710720a7 100644 --- a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py +++ b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py @@ -130,3 +130,27 @@ def test_assembled_tree_has_no_repo_root_plugin_layout_leftovers(assembled): # .claude-plugin/plugin.json is the flat tree's own file, written only # under `assembled`, never at the repo root. assert (assembled / ".claude-plugin" / "plugin.json").is_file() + + +def test_assembled_tree_ships_the_telemetry_hook(assembled): + """The telemetry hook, its hooks.json, and the schema copy land in the + flat tree, and the SubagentStart matcher covers every assembled agent.""" + assert (assembled / "hooks" / "konductor-telemetry.js").is_file() + assert (assembled / "docs" / "telemetry-schema.json").read_bytes() == ( + REPO_ROOT / "docs" / "telemetry-schema.json" + ).read_bytes() + + hooks = json.loads((assembled / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"] + assert hooks["SessionStart"][0]["matcher"] == "startup|clear" + for event in ("SessionStart", "SubagentStart"): + handler = hooks[event][0]["hooks"][0] + assert handler["async"] is True + assert '"${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js"' in handler["command"] + + plugin = json.loads((assembled / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + assert plugin["userConfig"]["telemetry_enabled"]["type"] == "boolean" + matcher = re.compile(hooks["SubagentStart"][0]["matcher"]) + for agent_file in (assembled / "agents").glob("*.md"): + assert matcher.search(f"{plugin['name']}:{agent_file.stem}"), agent_file.name + assert not matcher.search(agent_file.stem), agent_file.name + assert not matcher.search("Explore")