@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.
- package/bin/unroo-heartbeat.js +299 -0
- package/dist/skills-sync.js +39 -5
- package/package.json +2 -2
|
@@ -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));
|
package/dist/skills-sync.js
CHANGED
|
@@ -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.
|
|
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.
|
|
9
|
+
"unroo-heartbeat": "./bin/unroo-heartbeat.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|