diff --git a/.claude/skills/_shared/diagnostics.md b/.claude/skills/_shared/diagnostics.md new file mode 100644 index 0000000..c509a70 --- /dev/null +++ b/.claude/skills/_shared/diagnostics.md @@ -0,0 +1,99 @@ +# Diagnostics (end-of-skill instructions) + +Follow these steps **after all other skill steps are complete**. + +## 1. Skip check + +If this skill was invoked from within another skill (e.g., `/add-whatsapp` called during `/setup`), **skip diagnostics entirely** — the parent skill handles it. Return without doing anything below. + +## 2. Determine event type + +- `/setup` → `setup_complete` +- `/update-nanoclaw` → `update_complete` +- Any other skill → `skill_applied` + +## 3. Build event-specific data + +Estimate `error_count` from the conversation (how many errors/retries occurred during the run). + +**For `setup_complete`:** +```json +{ + "channels_selected": ["whatsapp", "telegram"], + "error_count": 0, + "failed_step": null, + "exit_code": null +} +``` +- `channels_selected`: which channels the user chose to install +- `failed_step`: if setup failed, which step (environment / container / groups / register / mounts / service / verify) +- `exit_code`: if a step failed, its exit code + +**For `skill_applied`:** +```json +{ + "skill_name": "add-telegram", + "is_upstream_skill": true, + "conflict_files": ["package.json", "src/index.ts"], + "error_count": 0 +} +``` +- `skill_name`: the skill that was run. Use the upstream skill name if it's an upstream skill, otherwise use `"custom"` +- `is_upstream_skill`: true if this is a known upstream skill +- `conflict_files`: filenames that had merge conflicts (the script will gate these against upstream — pass them all, the script filters) + +**For `update_complete`:** +```json +{ + "version_age_days": 45, + "update_method": "merge", + "conflict_files": ["package.json"], + "breaking_changes_found": false, + "breaking_changes_skills_run": [], + "error_count": 0 +} +``` +- `version_age_days`: estimate from the backup tag or commit date how many days old the previous version was +- `update_method`: "merge" or "rebase" +- `breaking_changes_found`: whether breaking changes were detected during the update +- `breaking_changes_skills_run`: which skills had to be re-run to fix breaking changes + +## 4. Dry run + +Run with `--dry-run` to get the full payload: + +```bash +npx tsx scripts/send-diagnostics.ts --event --success --data '' --dry-run +``` + +Use `--failure` instead of `--success` if the skill failed. + +If the command produces no output, the user has opted out permanently — skip the rest. + +## 5. Show the user and ask + +Show the JSON output and ask: + +> "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:" +> +> (show the JSON) +> +> **Yes** / **No** / **Never ask again** + +Use AskUserQuestion. + +## 6. Handle response + +- **Yes**: Run the same command without `--dry-run`: + ```bash + npx tsx scripts/send-diagnostics.ts --event --success --data '' + ``` + Confirm: "Diagnostics sent." + +- **No**: Do nothing. User will be asked again next time. + +- **Never ask again**: Run: + ```bash + npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();" + ``` + Confirm: "Got it — you won't be asked again." diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md index 0c46165..fe7ca8a 100644 --- a/.claude/skills/add-compact/SKILL.md +++ b/.claude/skills/add-compact/SKILL.md @@ -133,3 +133,8 @@ npm test - **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. - **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. - **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index e46bd3e..f4e98aa 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -201,3 +201,8 @@ The Discord bot supports: - @mention translation (Discord `<@botId>` → NanoClaw trigger format) - Message splitting for responses over 2000 characters - Typing indicators while the agent processes + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index 781a0eb..b51a098 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -218,3 +218,8 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp 6. Rebuild and restart 7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` 8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md index 072bf7b..d42e394 100644 --- a/.claude/skills/add-image-vision/SKILL.md +++ b/.claude/skills/add-image-vision/SKILL.md @@ -92,3 +92,8 @@ All tests must pass and build must be clean before proceeding. - **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections. - **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify. - **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..a28b8ea 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -151,3 +151,8 @@ The agent is trying to run `ollama` CLI inside the container instead of using th ### Agent doesn't use Ollama tools The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index f4c1982..12eb58c 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -288,3 +288,8 @@ To remove Parallel AI integration: 3. Remove Web Research Tools section from groups/main/CLAUDE.md 4. Rebuild: `./container/build.sh && npm run build` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md index a01e530..960d7fb 100644 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ b/.claude/skills/add-pdf-reader/SKILL.md @@ -102,3 +102,8 @@ The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Co ### WhatsApp PDF not detected Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md index de86768..9eacebd 100644 --- a/.claude/skills/add-reactions/SKILL.md +++ b/.claude/skills/add-reactions/SKILL.md @@ -115,3 +115,8 @@ Ask the agent to react to a message via the `react_to_message` MCP tool. Check y - Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat - Verify WhatsApp is connected: check logs for connection status + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 4c86e19..32a2cf0 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -205,3 +205,8 @@ The Slack channel supports: - **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent. - **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup. - **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md index ac4922c..b6e5923 100644 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ b/.claude/skills/add-telegram-swarm/SKILL.md @@ -382,3 +382,8 @@ To remove Agent Swarm support while keeping basic Telegram: 6. Remove Agent Teams section from group CLAUDE.md files 7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit 8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index 10f25ab..86a137f 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -220,3 +220,8 @@ To remove Telegram integration: 4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"` 5. Uninstall: `npm uninstall grammy` 6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md index 8ccec32..d9f44b6 100644 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ b/.claude/skills/add-voice-transcription/SKILL.md @@ -146,3 +146,8 @@ Check logs for the specific error. Common causes: ### Agent doesn't respond to voice notes Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 0774799..c22a835 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -370,3 +370,8 @@ To remove WhatsApp integration: 2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` 3. Sync env: `mkdir -p data/env && cp .env data/env/env` 4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md index caf9c22..bcd1929 100644 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ b/.claude/skills/convert-to-apple-container/SKILL.md @@ -173,3 +173,8 @@ Check directory permissions on the host. The container runs as uid 1000. | `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop | | `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | | `container/build.sh` | Default runtime: `docker` → `container` | + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 614a979..310f1ed 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -108,3 +108,8 @@ User: "Add Telegram as an input channel" 3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`) 4. Add the channel to `main()` in `src/index.ts` 5. Tell user how to authenticate and test + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 03c34de..e0fc3c7 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -347,3 +347,8 @@ echo -e "\n8. Session continuity working?" SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l) [ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues" ``` + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/get-qodo-rules/SKILL.md b/.claude/skills/get-qodo-rules/SKILL.md index 69abaf7..4a2cf16 100644 --- a/.claude/skills/get-qodo-rules/SKILL.md +++ b/.claude/skills/get-qodo-rules/SKILL.md @@ -120,3 +120,8 @@ See `~/.qodo/config.json` for API key setup. Set `QODO_ENVIRONMENT_NAME` env var - **Not in git repo** - Inform the user that a git repository is required and exit gracefully; do not attempt code generation - **No API key** - Inform the user with setup instructions; set `QODO_API_KEY` or create `~/.qodo/config.json` - **No rules found** - Inform the user; set up rules at app.qodo.ai + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/qodo-pr-resolver/SKILL.md b/.claude/skills/qodo-pr-resolver/SKILL.md index c0cbe22..165bbe2 100644 --- a/.claude/skills/qodo-pr-resolver/SKILL.md +++ b/.claude/skills/qodo-pr-resolver/SKILL.md @@ -324,3 +324,8 @@ Use the inline comment ID preserved during deduplication (Step 3b) to reply dire See [providers.md § Reply to Inline Comments](./resources/providers.md#reply-to-inline-comments) for provider-specific commands and reply format. Keep replies short (one line). If a reply fails, log it and continue. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index d173927..170e303 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -216,3 +216,8 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ **Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change. **Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw` + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index b0b478c..5e93c6c 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -233,3 +233,8 @@ Tell the user: - Restart the service to apply changes: - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` - If running manually: restart `npm run dev` + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/update-skills/SKILL.md b/.claude/skills/update-skills/SKILL.md index cbbff39..ade436b 100644 --- a/.claude/skills/update-skills/SKILL.md +++ b/.claude/skills/update-skills/SKILL.md @@ -128,3 +128,8 @@ Show: - Any conflicts that were resolved (list files) If the service is running, remind the user to restart it to pick up changes. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md index ec18a09..b7d9814 100644 --- a/.claude/skills/use-local-whisper/SKILL.md +++ b/.claude/skills/use-local-whisper/SKILL.md @@ -150,3 +150,8 @@ whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt **Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. **Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. + + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md index 29a7be6..86ae041 100644 --- a/.claude/skills/x-integration/SKILL.md +++ b/.claude/skills/x-integration/SKILL.md @@ -414,4 +414,8 @@ docker run nanoclaw-agent ls -la /app/src/skills/ - `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`) - `data/x-auth.json` - Auth state marker (in `.gitignore`) - Only main group can use X tools (enforced in `agent.ts` and `host.ts`) -- Scripts run as subprocesses with limited environment \ No newline at end of file +- Scripts run as subprocesses with limited environment + +## Diagnostics (Optional) + +After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`. diff --git a/scripts/send-diagnostics.ts b/scripts/send-diagnostics.ts new file mode 100644 index 0000000..157307c --- /dev/null +++ b/scripts/send-diagnostics.ts @@ -0,0 +1,310 @@ +/** + * send-diagnostics.ts — opt-in, privacy-first diagnostics for NanoClaw. + * + * Collects system info, accepts event-specific data via --data JSON arg, + * gates conflict filenames against upstream, and sends to PostHog. + * + * Usage: + * npx tsx scripts/send-diagnostics.ts \ + * --event \ + * [--success|--failure] \ + * [--data ''] \ + * [--dry-run] + * + * Never exits non-zero on telemetry failures. + */ + +import { execSync } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +const POSTHOG_ENDPOINT = 'https://us.i.posthog.com/capture/'; +const POSTHOG_TOKEN = 'phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP'; +const SEND_TIMEOUT_MS = 5000; + +const PROJECT_ROOT = path.resolve(import.meta.dirname, '..'); +const STATE_YAML_PATH = path.join(PROJECT_ROOT, '.nanoclaw', 'state.yaml'); + +// --- Args --- + +function parseArgs(): { + event: string; + success?: boolean; + data: Record; + dryRun: boolean; +} { + const args = process.argv.slice(2); + let event = ''; + let success: boolean | undefined; + let data: Record = {}; + let dryRun = false; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--event': + event = args[++i] || ''; + break; + case '--success': + success = true; + break; + case '--failure': + success = false; + break; + case '--data': + try { + data = JSON.parse(args[++i] || '{}'); + } catch { + console.error('Warning: --data JSON parse failed, ignoring'); + } + break; + case '--dry-run': + dryRun = true; + break; + } + } + + if (!event) { + console.error('Error: --event is required'); + process.exit(0); // exit 0 — never fail on diagnostics + } + + return { event, success, data, dryRun }; +} + +// --- State (neverAsk) --- + +function readState(): Record { + try { + const raw = fs.readFileSync(STATE_YAML_PATH, 'utf-8'); + return parseYaml(raw) || {}; + } catch { + return {}; + } +} + +function isNeverAsk(): boolean { + const state = readState(); + return state.neverAsk === true; +} + +export function setNeverAsk(): void { + const state = readState(); + state.neverAsk = true; + const dir = path.dirname(STATE_YAML_PATH); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(STATE_YAML_PATH, stringifyYaml(state)); +} + +// --- Git helpers --- + +/** Resolve the upstream remote ref (could be 'upstream/main' or 'origin/main'). */ +function resolveUpstreamRef(): string | null { + for (const ref of ['upstream/main', 'origin/main']) { + try { + execSync(`git rev-parse --verify ${ref}`, { + cwd: PROJECT_ROOT, + stdio: 'ignore', + }); + return ref; + } catch { + continue; + } + } + return null; +} + +// --- System info --- + +function getNanoclawVersion(): string { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf-8'), + ); + return pkg.version || 'unknown'; + } catch { + return 'unknown'; + } +} + +function getNodeMajorVersion(): number | null { + const match = process.version.match(/^v(\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +function getContainerRuntime(): string { + try { + const src = fs.readFileSync( + path.join(PROJECT_ROOT, 'src', 'container-runtime.ts'), + 'utf-8', + ); + const match = src.match(/CONTAINER_RUNTIME_BIN\s*=\s*['"]([^'"]+)['"]/); + return match ? match[1] : 'unknown'; + } catch { + return 'unknown'; + } +} + +function isUpstreamCommit(): boolean { + const ref = resolveUpstreamRef(); + if (!ref) return false; + try { + const head = execSync('git rev-parse HEAD', { + encoding: 'utf-8', + cwd: PROJECT_ROOT, + stdio: ['pipe', 'pipe', 'ignore'], + }).trim(); + execSync(`git merge-base --is-ancestor ${head} ${ref}`, { + cwd: PROJECT_ROOT, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +function collectSystemInfo(): Record { + return { + nanoclaw_version: getNanoclawVersion(), + os_platform: process.platform, + arch: process.arch, + node_major_version: getNodeMajorVersion(), + container_runtime: getContainerRuntime(), + is_upstream_commit: isUpstreamCommit(), + }; +} + +// --- Conflict filename gating --- + +function getUpstreamFiles(): Set | null { + const ref = resolveUpstreamRef(); + if (!ref) return null; + try { + const output = execSync(`git ls-tree -r --name-only ${ref}`, { + encoding: 'utf-8', + cwd: PROJECT_ROOT, + stdio: ['pipe', 'pipe', 'ignore'], + }); + return new Set(output.trim().split('\n').filter(Boolean)); + } catch { + return null; + } +} + +function gateConflictFiles(data: Record): void { + if (!Array.isArray(data.conflict_files)) return; + + const rawFiles: string[] = data.conflict_files; + const upstreamFiles = getUpstreamFiles(); + const totalCount = rawFiles.length; + + if (!upstreamFiles) { + // Can't verify — fail-closed + data.conflict_files = []; + data.conflict_count = totalCount; + data.has_non_upstream_conflicts = totalCount > 0; + return; + } + + const safe: string[] = []; + let hasNonUpstream = false; + + for (const file of rawFiles) { + if (upstreamFiles.has(file)) { + safe.push(file); + } else { + hasNonUpstream = true; + } + } + + data.conflict_files = safe; + data.conflict_count = totalCount; + data.has_non_upstream_conflicts = hasNonUpstream; +} + +// --- Build & send --- + +function buildPayload( + event: string, + systemInfo: Record, + eventData: Record, + success?: boolean, +): Record { + const properties: Record = { + $process_person_profile: false, + $lib: 'nanoclaw-diagnostics', + ...systemInfo, + ...eventData, + }; + + if (success !== undefined) { + properties.success = success; + } + + return { + api_key: POSTHOG_TOKEN, + event, + distinct_id: crypto.randomUUID(), + properties, + }; +} + +async function sendToPostHog( + payload: Record, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); + + try { + const response = await fetch(POSTHOG_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (response.ok) { + console.log('Diagnostics sent successfully.'); + } else { + console.error( + `Diagnostics send failed (HTTP ${response.status}). This is fine.`, + ); + } + } catch (err) { + console.error('Diagnostics send failed (network error). This is fine.'); + } finally { + clearTimeout(timeout); + } +} + +// --- Main --- + +async function main(): Promise { + try { + if (isNeverAsk()) { + // User opted out permanently — exit silently + return; + } + + const { event, success, data, dryRun } = parseArgs(); + + // Gate conflict filenames before building payload + gateConflictFiles(data); + + const systemInfo = collectSystemInfo(); + const payload = buildPayload(event, systemInfo, data, success); + + if (dryRun) { + console.log(JSON.stringify(payload, null, 2)); + return; + } + + await sendToPostHog(payload); + } catch (err) { + // Never fail on diagnostics + console.error('Diagnostics error (this is fine):', (err as Error).message); + } +} + +main();