@fruition/fcp-mcp-server 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // UNROO CLAUDE CODE HEARTBEAT (Node version)
4
+ // ============================================================================
5
+ // Successor to unroo-heartbeat.sh. Reads Claude Code's hook stdin protocol
6
+ // properly (the shell version had no way to parse hook JSON, so `tool_name`,
7
+ // `transcript_path`, and `session_id` were never carried through).
8
+ //
9
+ // Wired into Claude Code via ~/.claude/settings.json hooks:
10
+ // PostToolUse * → unroo-heartbeat (per-turn token + skill delta)
11
+ // Stop → unroo-heartbeat session_end (final flush + session end)
12
+ //
13
+ // Per-turn delta design (the Unroo server expects this shape — see
14
+ // src/lib/unroo/ai-productivity-capture.ts). For each NEW assistant turn
15
+ // appended to the transcript since the last hook fired, we POST one
16
+ // heartbeat carrying that turn's `primary_model`, token usage (as deltas —
17
+ // the server uses Prisma `increment`), and any Skill tool invocations from
18
+ // that turn (server upserts +1 per skill).
19
+ //
20
+ // Scratch state at ~/.claude/unroo-tracking/<cc_session_id>.json keeps a
21
+ // line cursor into the transcript so we never double-count.
22
+ //
23
+ // Soft-fail everywhere — this MUST NOT block Claude Code, ever.
24
+ //
25
+ // Env:
26
+ // UNROO_API_KEY Required. Without it, exit 0 silently.
27
+ // UNROO_API_ENDPOINT Optional. Default https://app.unroo.io.
28
+ // ============================================================================
29
+
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { execSync } from 'node:child_process';
34
+ import https from 'node:https';
35
+ import http from 'node:http';
36
+
37
+ const API_KEY = process.env.UNROO_API_KEY;
38
+ if (!API_KEY) process.exit(0); // No key set — silent no-op (matches old behaviour).
39
+
40
+ const API_BASE = process.env.UNROO_API_ENDPOINT || 'https://app.unroo.io';
41
+ const API_URL = `${API_BASE.replace(/\/$/, '')}/api/unroo/claude-tracker/heartbeat`;
42
+
43
+ const SCRATCH_DIR = path.join(os.homedir(), '.claude', 'unroo-tracking');
44
+
45
+ // ── stdin (Claude Code hook payload, JSON) ───────────────────────────────────
46
+
47
+ function readStdin() {
48
+ return new Promise((resolve) => {
49
+ if (process.stdin.isTTY) return resolve(null);
50
+ let data = '';
51
+ let settled = false;
52
+ const done = () => {
53
+ if (settled) return;
54
+ settled = true;
55
+ try { resolve(data ? JSON.parse(data) : null); } catch { resolve(null); }
56
+ };
57
+ process.stdin.setEncoding('utf8');
58
+ process.stdin.on('data', (chunk) => { data += chunk; });
59
+ process.stdin.on('end', done);
60
+ process.stdin.on('error', () => done());
61
+ setTimeout(done, 800); // Safety — never block forever.
62
+ });
63
+ }
64
+
65
+ // ── small utility helpers ────────────────────────────────────────────────────
66
+
67
+ function sh(cmd) {
68
+ try {
69
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
70
+ } catch { return ''; }
71
+ }
72
+
73
+ function machineId() {
74
+ try {
75
+ if (fs.existsSync('/etc/machine-id')) {
76
+ return fs.readFileSync('/etc/machine-id', 'utf8').trim().slice(0, 8);
77
+ }
78
+ if (fs.existsSync('/var/lib/dbus/machine-id')) {
79
+ return fs.readFileSync('/var/lib/dbus/machine-id', 'utf8').trim().slice(0, 8);
80
+ }
81
+ const ioreg = sh('ioreg -rd1 -c IOPlatformExpertDevice');
82
+ const m = ioreg.match(/IOPlatformUUID"\s*=\s*"([^"]+)"/);
83
+ if (m) return m[1].slice(0, 8);
84
+ return sh('hostid').slice(0, 8);
85
+ } catch { return ''; }
86
+ }
87
+
88
+ function projectSlugFromCwd() {
89
+ try {
90
+ if (!fs.existsSync('.unroo')) return '';
91
+ const text = fs.readFileSync('.unroo', 'utf8');
92
+ const m = text.match(/^project_slug=(.+)$/m);
93
+ return m ? m[1].trim() : '';
94
+ } catch { return ''; }
95
+ }
96
+
97
+ // ── scratch state (per Claude Code session_id) ───────────────────────────────
98
+
99
+ function scratchPathFor(sessionId) {
100
+ if (!sessionId) return null;
101
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 80);
102
+ return path.join(SCRATCH_DIR, `${safe}.json`);
103
+ }
104
+
105
+ function readScratch(sessionId) {
106
+ const p = scratchPathFor(sessionId);
107
+ if (!p || !fs.existsSync(p)) return { last_line_index: 0 };
108
+ try {
109
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
110
+ return { last_line_index: Number(s.last_line_index) || 0 };
111
+ } catch {
112
+ return { last_line_index: 0 };
113
+ }
114
+ }
115
+
116
+ function writeScratch(sessionId, state) {
117
+ const p = scratchPathFor(sessionId);
118
+ if (!p) return;
119
+ try {
120
+ fs.mkdirSync(SCRATCH_DIR, { recursive: true });
121
+ fs.writeFileSync(p, JSON.stringify(state));
122
+ } catch { /* best-effort */ }
123
+ }
124
+
125
+ function deleteScratch(sessionId) {
126
+ const p = scratchPathFor(sessionId);
127
+ if (!p) return;
128
+ try { fs.unlinkSync(p); } catch { /* missing-ok */ }
129
+ }
130
+
131
+ // ── transcript parsing — extract NEW assistant turns since `fromIndex` ───────
132
+
133
+ /**
134
+ * Parse the transcript JSONL starting at `fromIndex`. Returns one entry per
135
+ * assistant message that carries a `message.usage` block, plus the new line
136
+ * cursor. Skills are extracted per-turn from `tool_use` blocks with name
137
+ * "Skill". Tokens come straight from the per-turn `usage`.
138
+ */
139
+ function parseNewTurns(transcriptPath, fromIndex) {
140
+ const turns = [];
141
+ let newIndex = fromIndex;
142
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
143
+ return { turns, newIndex };
144
+ }
145
+
146
+ let buf;
147
+ try { buf = fs.readFileSync(transcriptPath, 'utf8'); } catch { return { turns, newIndex }; }
148
+ const lines = buf.split('\n');
149
+
150
+ for (let i = fromIndex; i < lines.length; i++) {
151
+ newIndex = i + 1;
152
+ const line = lines[i];
153
+ if (!line) continue;
154
+
155
+ let row;
156
+ try { row = JSON.parse(line); } catch { continue; }
157
+ const msg = row.message;
158
+ if (!msg || typeof msg !== 'object') continue;
159
+ if (!msg.usage || !msg.model) continue;
160
+
161
+ const skills = [];
162
+ if (Array.isArray(msg.content)) {
163
+ for (const block of msg.content) {
164
+ if (block && block.type === 'tool_use' && block.name === 'Skill') {
165
+ const input = block.input || {};
166
+ const slug = input.skill || input.name || null;
167
+ if (slug) skills.push({ slug: String(slug), name: String(slug), source: 'local' });
168
+ }
169
+ }
170
+ }
171
+
172
+ turns.push({
173
+ model: String(msg.model),
174
+ tokens_input: Number(msg.usage.input_tokens || 0) || 0,
175
+ tokens_output: Number(msg.usage.output_tokens || 0) || 0,
176
+ tokens_cache_read: Number(msg.usage.cache_read_input_tokens || 0) || 0,
177
+ tokens_cache_write: Number(msg.usage.cache_creation_input_tokens || 0) || 0,
178
+ skills,
179
+ });
180
+ }
181
+
182
+ return { turns, newIndex };
183
+ }
184
+
185
+ // ── HTTP POST (fire-and-forget, short timeout) ───────────────────────────────
186
+
187
+ function postJson(payload) {
188
+ return new Promise((resolve) => {
189
+ let u;
190
+ try { u = new URL(API_URL); } catch { return resolve(); }
191
+ const lib = u.protocol === 'https:' ? https : http;
192
+ const body = JSON.stringify(payload);
193
+ const req = lib.request(
194
+ {
195
+ method: 'POST',
196
+ hostname: u.hostname,
197
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
198
+ path: `${u.pathname}${u.search || ''}`,
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Content-Length': Buffer.byteLength(body),
202
+ },
203
+ timeout: 3000,
204
+ },
205
+ (res) => {
206
+ res.resume();
207
+ res.on('end', () => resolve());
208
+ res.on('error', () => resolve());
209
+ },
210
+ );
211
+ req.on('error', () => resolve());
212
+ req.on('timeout', () => { req.destroy(); resolve(); });
213
+ req.write(body);
214
+ req.end();
215
+ });
216
+ }
217
+
218
+ // ── main ─────────────────────────────────────────────────────────────────────
219
+
220
+ (async () => {
221
+ const hook = await readStdin();
222
+ const argEvent = process.argv[2];
223
+ const hookEvent = hook && typeof hook.hook_event_name === 'string' ? hook.hook_event_name : null;
224
+
225
+ let eventType = argEvent || 'tool_end';
226
+ if (hookEvent === 'Stop' || hookEvent === 'SubagentStop') eventType = 'session_end';
227
+ else if (hookEvent === 'PostToolUse') eventType = 'tool_end';
228
+
229
+ const ccSessionId = (hook && hook.session_id) || null;
230
+ const transcriptPath = (hook && hook.transcript_path) || null;
231
+
232
+ const gitRemote = sh('git remote get-url origin');
233
+ const gitBranch = sh('git symbolic-ref --short HEAD');
234
+ const repoName = path.basename(process.cwd());
235
+
236
+ const baseFields = {
237
+ api_key: API_KEY,
238
+ source: 'claude-code',
239
+ git_remote_url: gitRemote || '',
240
+ git_branch: gitBranch || '',
241
+ repo_name: repoName,
242
+ machine_id: machineId(),
243
+ tool_name: (hook && hook.tool_name) || process.env.TOOL_NAME || '',
244
+ tool_description: process.env.TOOL_DESCRIPTION || '',
245
+ project_slug: projectSlugFromCwd(),
246
+ cc_session_id: ccSessionId,
247
+ actor_type: 'interactive_agent',
248
+ };
249
+
250
+ const scratch = readScratch(ccSessionId);
251
+ const { turns, newIndex } = parseNewTurns(transcriptPath, scratch.last_line_index);
252
+
253
+ // Persist the cursor before any POSTs so a crashed hook still moves forward
254
+ // (better to miss one turn than to double-count it on the next call).
255
+ writeScratch(ccSessionId, { last_line_index: newIndex });
256
+
257
+ // Each new assistant turn is posted as tool_end. The server's tool_end
258
+ // branch creates or updates the session row and accumulates the per-turn
259
+ // token delta + skills via mergeModelUsage / recordSkillInvocations.
260
+ //
261
+ // Sequential — NOT parallel — because the first POST of a brand-new
262
+ // session triggers auto-create of a claude_repo_mappings row; running
263
+ // them in parallel races the unique constraint on (org, git_remote_url)
264
+ // and one POST errors out. In practice each hook fire has 0-1 new turns
265
+ // so sequential is effectively the same speed.
266
+ for (const turn of turns) {
267
+ await postJson({
268
+ ...baseFields,
269
+ event_type: 'tool_end',
270
+ primary_model: turn.model,
271
+ tokens_input: turn.tokens_input,
272
+ tokens_output: turn.tokens_output,
273
+ tokens_cache_read: turn.tokens_cache_read,
274
+ tokens_cache_write: turn.tokens_cache_write,
275
+ skills_invoked: turn.skills,
276
+ });
277
+ }
278
+
279
+ if (eventType === 'session_end') {
280
+ // Stop / SubagentStop: the server's session_end branch only finalises
281
+ // an EXISTING session row. If no turns were processed in this hook fire
282
+ // and there's no prior tool_end on this user+repo+day, the row doesn't
283
+ // exist yet and session_end would no-op. Send a bare tool_end first to
284
+ // guarantee the session is created, then finalise.
285
+ if (turns.length === 0) {
286
+ await postJson({ ...baseFields, event_type: 'tool_end' });
287
+ }
288
+ await postJson({ ...baseFields, event_type: 'session_end' });
289
+ } else if (turns.length === 0) {
290
+ // PostToolUse with no new transcript content (e.g. multiple tool calls
291
+ // within the same assistant turn). One keepalive — the server records
292
+ // last_heartbeat_at and bumps total_tool_calls.
293
+ await postJson({ ...baseFields, event_type: eventType });
294
+ }
295
+
296
+ if (eventType === 'session_end') deleteScratch(ccSessionId);
297
+
298
+ process.exit(0);
299
+ })().catch(() => process.exit(0));
@@ -573,13 +573,45 @@ function emptyResult(dryRun) {
573
573
  dryRun,
574
574
  };
575
575
  }
576
+ /**
577
+ * Decide whether this run should be a dry run, and persist auto-opt-in when
578
+ * appropriate. Returns the resolved `dryRun` boolean and mutates `state` to
579
+ * record auto-opt-in so subsequent runs don't re-log the upgrade banner.
580
+ *
581
+ * Precedence:
582
+ * 1. Explicit `opts.dryRun` always wins (tests, --dry-run flag, callers
583
+ * that mean "I know what I'm doing").
584
+ * 2. Already opted in -> real run.
585
+ * 3. Proxy mode (FCP_API_TOKEN -> FCP proxy -> Unroo) -> auto-opt-in. Only
586
+ * Fruition team members can mint an FCP API key, so reaching the proxy
587
+ * is itself proof of team membership. Persist optedIn=true so we log the
588
+ * auto-enable message exactly once.
589
+ * 4. Direct mode (personal UNROO_API_KEY) -> stay dry-run until the user
590
+ * runs `fcp-mcp-server sync-skills --enable`. Direct mode is the
591
+ * power-user / script path; we don't want to assume intent there.
592
+ */
593
+ function resolveDryRun(opts, state, transport, log) {
594
+ if (opts.dryRun !== undefined)
595
+ return opts.dryRun;
596
+ if (state.optedIn === true)
597
+ return false;
598
+ if (transport.configured && transport.mode === 'proxy') {
599
+ state.optedIn = true;
600
+ log('[skills-sync] auto-enabled for Fruition team member ' +
601
+ '(reached via FCP proxy). Skills with name+description in ' +
602
+ 'frontmatter will be pushed; scratch (_*, .*, digit-prefixed) ' +
603
+ 'and private:true skills are skipped. To disable, delete ' +
604
+ '~/.claude/skills/.sync-state.json or set private:true.');
605
+ return false;
606
+ }
607
+ return true;
608
+ }
576
609
  /** Push local skills up to Unroo. */
577
610
  export async function pushSkills(opts = {}) {
578
611
  const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
579
- // Fresh workstation -> force dry-run unless explicitly opted in OR caller
580
- // passed dryRun:false meaning "I know what I'm doing".
581
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
582
612
  const transport = resolveTransport(opts);
613
+ const log = opts.log ?? ((m) => console.error(m));
614
+ const dryRun = resolveDryRun(opts, state, transport, log);
583
615
  const ctx = makeCtx(opts, dryRun, transport);
584
616
  const result = emptyResult(dryRun);
585
617
  if (!transport.configured) {
@@ -595,8 +627,9 @@ export async function pushSkills(opts = {}) {
595
627
  /** Pull team skills from Unroo down to local. */
596
628
  export async function pullSkills(opts = {}) {
597
629
  const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
598
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
599
630
  const transport = resolveTransport(opts);
631
+ const log = opts.log ?? ((m) => console.error(m));
632
+ const dryRun = resolveDryRun(opts, state, transport, log);
600
633
  const ctx = makeCtx(opts, dryRun, transport);
601
634
  const result = emptyResult(dryRun);
602
635
  if (!transport.configured) {
@@ -612,8 +645,9 @@ export async function pullSkills(opts = {}) {
612
645
  /** Bidirectional sync: pull then push. */
613
646
  export async function syncSkills(opts = {}) {
614
647
  const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
615
- const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
616
648
  const transport = resolveTransport(opts);
649
+ const log = opts.log ?? ((m) => console.error(m));
650
+ const dryRun = resolveDryRun(opts, state, transport, log);
617
651
  const ctx = makeCtx(opts, dryRun, transport);
618
652
  const result = emptyResult(dryRun);
619
653
  if (!transport.configured) {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "fcp-mcp-server": "./dist/index.js",
9
- "unroo-heartbeat": "./bin/unroo-heartbeat.sh"
9
+ "unroo-heartbeat": "./bin/unroo-heartbeat.js"
10
10
  },
11
11
  "files": [
12
12
  "dist",