@xmarts/genius-setup 1.8.0 → 1.10.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/genius-setup.js +114 -8
- package/hooks/genius_inject.py +24 -6
- package/hooks/genius_presence.py +224 -0
- package/package.json +3 -2
package/bin/genius-setup.js
CHANGED
|
@@ -123,16 +123,17 @@ function runSetup(token) {
|
|
|
123
123
|
if (secret) { try { fs.chmodSync(p, 0o600); } catch (_) {} }
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// 1) ~/.genius/config.json + hook
|
|
126
|
+
// 1) ~/.genius/config.json + hook. MERGE into any existing config so state we
|
|
127
|
+
// track across runs (e.g. managed_mcp_keys for MCP de-provisioning) survives.
|
|
127
128
|
ensureDir(HOOKS_DIR);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
const cfgPath0 = path.join(GENIUS_DIR, 'config.json');
|
|
130
|
+
const cfg = readJson(cfgPath0);
|
|
131
|
+
cfg.base_url = BASE;
|
|
132
|
+
cfg.token = token;
|
|
133
|
+
cfg.capture_endpoint = BASE + '/xma/genius/v1/capture/transcript';
|
|
134
|
+
cfg.inject_endpoint = BASE + '/xma/genius/v1/inject';
|
|
134
135
|
if (CLIENT) cfg.target_client = CLIENT;
|
|
135
|
-
writeJson(
|
|
136
|
+
writeJson(cfgPath0, cfg, true); // secret: 0600
|
|
136
137
|
|
|
137
138
|
// Copy the hooks shipped alongside this script (capture + per-turn inject).
|
|
138
139
|
function copyHook(fname) {
|
|
@@ -149,6 +150,7 @@ function runSetup(token) {
|
|
|
149
150
|
const hookDst = copyHook('genius_capture.py');
|
|
150
151
|
const injectDst = copyHook('genius_inject.py');
|
|
151
152
|
const guardDst = copyHook('plan_committee_guard.py');
|
|
153
|
+
const presenceDst = copyHook('genius_presence.py');
|
|
152
154
|
|
|
153
155
|
// 2) ~/.claude.json mcpServers (merge, no clobber)
|
|
154
156
|
const claudeJsonPath = path.join(HOME, '.claude.json');
|
|
@@ -186,6 +188,18 @@ function runSetup(token) {
|
|
|
186
188
|
// (XMA_PLAN_GUARD_MODE=block|warn|off). Unlike the fire-and-forget hooks above, this
|
|
187
189
|
// one can return {"decision":"block"} — by design — but never traps a session.
|
|
188
190
|
addHook('Stop', guardDst);
|
|
191
|
+
// Presence heartbeat (time-tracking "amarre"): binds worked time to THIS machine's
|
|
192
|
+
// clock — counts VSCode edits + executing loops/workflows, excludes idle-open VSCode.
|
|
193
|
+
// Unlike every hook above, this is a LONG-LIVED daemon (loops up to ~18h), so it MUST be
|
|
194
|
+
// launched DETACHED (nohup ... & disown) — never as a blocking `python3 X || true`, which
|
|
195
|
+
// would freeze every SessionStart. A single-instance lock + self-terminate make repeated
|
|
196
|
+
// SessionStart launches safe. POSIX form; mirrors the proven admin-bootstrap installer.
|
|
197
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart || [];
|
|
198
|
+
if (!JSON.stringify(settings.hooks.SessionStart).includes('genius_presence.py')) {
|
|
199
|
+
const presenceCmd = 'nohup python3 ' + JSON.stringify(presenceDst) +
|
|
200
|
+
' --cwd "${CLAUDE_PROJECT_DIR:-$PWD}" >/dev/null 2>&1 & disown 2>/dev/null; true';
|
|
201
|
+
settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: presenceCmd }] });
|
|
202
|
+
}
|
|
189
203
|
writeJson(settingsPath, settings);
|
|
190
204
|
|
|
191
205
|
// 4) ~/.claude/CLAUDE.md — install/refresh the Genius Protocol between auto-managed
|
|
@@ -211,12 +225,104 @@ function runSetup(token) {
|
|
|
211
225
|
log('WARN: could not write CLAUDE.md protocol (' + e.message + ')');
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
// 5) Fan out the consultant's OTHER MCP servers (asana, clickup, google, ...).
|
|
229
|
+
// Their non-secret shape (manifest) + per-device secret VALUES come from the
|
|
230
|
+
// Bearer-authed /xma/genius/v1/mcp/secrets endpoint. We write Claude Code
|
|
231
|
+
// always and Claude Desktop only if it is installed. Fail-open: a fetch
|
|
232
|
+
// error never breaks onboarding. Re-runs (self-update / epoch bump) converge.
|
|
233
|
+
function desktopConfigPath() {
|
|
234
|
+
try {
|
|
235
|
+
if (process.platform === 'darwin')
|
|
236
|
+
return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
237
|
+
if (process.platform === 'win32')
|
|
238
|
+
return path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
239
|
+
return path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json');
|
|
240
|
+
} catch (_) { return ''; }
|
|
241
|
+
}
|
|
242
|
+
function mcpEntry(srv, secrets, host) {
|
|
243
|
+
// One mcpServers entry, or null if a required secret is missing (never write a
|
|
244
|
+
// half-broken server) or it is not portable to this host.
|
|
245
|
+
const env = {};
|
|
246
|
+
let ok = true;
|
|
247
|
+
(srv.env_keys || []).forEach((k) => { if (secrets[k]) env[k] = secrets[k]; else ok = false; });
|
|
248
|
+
if (!ok) return null;
|
|
249
|
+
const isHttp = srv.transport === 'http' || srv.transport === 'sse';
|
|
250
|
+
if (host === 'desktop') {
|
|
251
|
+
if (srv.host_targets !== 'both' || srv.desktop_strategy === 'skip') return null;
|
|
252
|
+
if (isHttp) return { command: 'npx', args: ['-y', 'mcp-remote', srv.url] }; // stdio-only Desktop
|
|
253
|
+
const e = { command: srv.command, args: srv.args || [] };
|
|
254
|
+
if (Object.keys(env).length) e.env = env;
|
|
255
|
+
return e;
|
|
256
|
+
}
|
|
257
|
+
if (isHttp) return { type: srv.transport, url: srv.url };
|
|
258
|
+
const e = { type: 'stdio', command: srv.command, args: srv.args || [] };
|
|
259
|
+
if (Object.keys(env).length) e.env = env;
|
|
260
|
+
return e;
|
|
261
|
+
}
|
|
262
|
+
function writeHostMcps(cfgPath, manifest, secrets, host, prevKeys) {
|
|
263
|
+
let obj = readJson(cfgPath);
|
|
264
|
+
if (!obj || typeof obj !== 'object') obj = {};
|
|
265
|
+
backup(cfgPath);
|
|
266
|
+
obj.mcpServers = obj.mcpServers || {};
|
|
267
|
+
// De-provision: drop servers WE previously managed that are no longer granted.
|
|
268
|
+
(prevKeys || []).forEach((k) => {
|
|
269
|
+
if (obj.mcpServers[k] && !manifest.find((s) => s.key === k)) delete obj.mcpServers[k];
|
|
270
|
+
});
|
|
271
|
+
const nowKeys = [];
|
|
272
|
+
manifest.forEach((srv) => {
|
|
273
|
+
const entry = mcpEntry(srv, secrets, host);
|
|
274
|
+
if (entry) { obj.mcpServers[srv.key] = entry; nowKeys.push(srv.key); }
|
|
275
|
+
else if (obj.mcpServers[srv.key] && (prevKeys || []).includes(srv.key)) {
|
|
276
|
+
delete obj.mcpServers[srv.key]; // lost its secret / portability -> remove
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
writeJson(cfgPath, obj, true); // may carry env secrets -> 0600
|
|
280
|
+
return nowKeys;
|
|
281
|
+
}
|
|
282
|
+
function provisionMcps(token) {
|
|
283
|
+
let url;
|
|
284
|
+
try { url = new URL(BASE + '/xma/genius/v1/mcp/secrets'); } catch (_) { return; }
|
|
285
|
+
const mod = url.protocol === 'http:' ? require('http') : require('https');
|
|
286
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: {} });
|
|
287
|
+
const req = mod.request({
|
|
288
|
+
hostname: url.hostname, port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
289
|
+
path: url.pathname, method: 'POST',
|
|
290
|
+
headers: {
|
|
291
|
+
'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload),
|
|
292
|
+
'Authorization': 'Bearer ' + token, 'User-Agent': 'XmartsGenius-Setup/1.6.0',
|
|
293
|
+
},
|
|
294
|
+
timeout: 15000,
|
|
295
|
+
}, (res) => {
|
|
296
|
+
let data = ''; res.on('data', (c) => { data += c; });
|
|
297
|
+
res.on('end', () => {
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(data); const r = parsed.result || parsed;
|
|
300
|
+
if (!r || r.status !== 'ok' || !Array.isArray(r.manifest)) return;
|
|
301
|
+
const secrets = r.secrets || {};
|
|
302
|
+
const cfgPath = path.join(GENIUS_DIR, 'config.json');
|
|
303
|
+
const prev = (readJson(cfgPath).managed_mcp_keys) || [];
|
|
304
|
+
const managed = writeHostMcps(path.join(HOME, '.claude.json'), r.manifest, secrets, 'code', prev);
|
|
305
|
+
const dp = desktopConfigPath();
|
|
306
|
+
if (dp && (fs.existsSync(dp) || fs.existsSync(path.dirname(dp)))) {
|
|
307
|
+
try { writeHostMcps(dp, r.manifest, secrets, 'desktop', prev); } catch (_) {}
|
|
308
|
+
}
|
|
309
|
+
try { const c = readJson(cfgPath); c.managed_mcp_keys = managed; writeJson(cfgPath, c, true); } catch (_) {}
|
|
310
|
+
if (managed.length) log(' - MCP fan-out : ' + managed.join(', '));
|
|
311
|
+
} catch (_) {}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
req.on('error', () => {}); req.on('timeout', () => { try { req.destroy(); } catch (_) {} });
|
|
315
|
+
req.write(payload); req.end();
|
|
316
|
+
}
|
|
317
|
+
provisionMcps(token);
|
|
318
|
+
|
|
214
319
|
log('');
|
|
215
320
|
log(' Xmarts Genius is set up.');
|
|
216
321
|
log(' - MCP server : beesmart_genius -> ' + BASE + '/mcp/consultant');
|
|
217
322
|
log(' - Capture hook : ' + hookDst + ' (SessionEnd / PreCompact / Stop)');
|
|
218
323
|
log(' - Inject hook : ' + injectDst + ' (UserPromptSubmit / SessionStart)');
|
|
219
324
|
log(' - Plan/committee : ' + guardDst + ' (Stop — enforces plan/comité → Brain; XMA_PLAN_GUARD_MODE=block|warn|off)');
|
|
325
|
+
log(' - Presence : ' + presenceDst + ' (SessionStart, detached — honest time-tracking amarre)');
|
|
220
326
|
log(' - Config : ' + path.join(GENIUS_DIR, 'config.json'));
|
|
221
327
|
log(' - Protocol : ' + path.join(CLAUDE_DIR, 'CLAUDE.md') + ' (auto-managed)');
|
|
222
328
|
if (CLIENT) log(' - Default client : ' + CLIENT);
|
package/hooks/genius_inject.py
CHANGED
|
@@ -25,6 +25,7 @@ TIMEOUT = 3.5
|
|
|
25
25
|
SELF_UPDATE_TTL = 86400 # at most once/day
|
|
26
26
|
SELF_UPDATE_STAMP = os.path.join(HOME, ".genius", ".last_selfupdate")
|
|
27
27
|
FORCE_STAMP = os.path.join(HOME, ".genius", ".last_forced_epoch")
|
|
28
|
+
MCP_EPOCH_STAMP = os.path.join(HOME, ".genius", ".last_mcp_epoch")
|
|
28
29
|
|
|
29
30
|
SESSION_PRIMER = (
|
|
30
31
|
"[Xmarts Genius — Brain connected]\n"
|
|
@@ -77,9 +78,10 @@ def _inject(cfg, prompt):
|
|
|
77
78
|
result = data.get("result", data) or {}
|
|
78
79
|
return ((result.get("context", "") or ""),
|
|
79
80
|
result.get("hook_force_epoch"),
|
|
80
|
-
result.get("hook_pin_version")
|
|
81
|
+
result.get("hook_pin_version"),
|
|
82
|
+
result.get("mcp_manifest_epoch"))
|
|
81
83
|
except Exception:
|
|
82
|
-
return "", None, None
|
|
84
|
+
return "", None, None, None
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
# M6 (Phase-0 integrity gate): NEVER resolve `@latest`. An unsigned `npx @latest`
|
|
@@ -101,7 +103,7 @@ def _pkg_spec(pin_version):
|
|
|
101
103
|
return "@xmarts/genius-setup@%s" % PINNED_FALLBACK
|
|
102
104
|
|
|
103
105
|
|
|
104
|
-
def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
106
|
+
def _maybe_self_update(cfg, force_epoch=None, pin_version=None, mcp_epoch=None):
|
|
105
107
|
"""Keep the consultant on the SERVER-PINNED Genius package with ZERO action: at most
|
|
106
108
|
once/day, silently re-run the installer in the background. New hooks, MCP
|
|
107
109
|
tools, settings and the CLAUDE.md protocol then flow automatically — the
|
|
@@ -129,6 +131,18 @@ def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
|
129
131
|
forced = fe > last
|
|
130
132
|
except Exception:
|
|
131
133
|
forced = False
|
|
134
|
+
# MCP fan-out reconvergence: a newer manifest epoch forces an immediate
|
|
135
|
+
# re-run (re-provisions ~/.claude.json + Desktop), bypassing the daily TTL.
|
|
136
|
+
if not forced and mcp_epoch:
|
|
137
|
+
try:
|
|
138
|
+
me = int(mcp_epoch)
|
|
139
|
+
lastm = 0
|
|
140
|
+
if os.path.exists(MCP_EPOCH_STAMP):
|
|
141
|
+
with open(MCP_EPOCH_STAMP) as fh:
|
|
142
|
+
lastm = int((fh.read() or "0").strip() or 0)
|
|
143
|
+
forced = me > lastm
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
132
146
|
if not forced and os.path.exists(SELF_UPDATE_STAMP) and (
|
|
133
147
|
time.time() - os.path.getmtime(SELF_UPDATE_STAMP)) < SELF_UPDATE_TTL:
|
|
134
148
|
return
|
|
@@ -140,6 +154,9 @@ def _maybe_self_update(cfg, force_epoch=None, pin_version=None):
|
|
|
140
154
|
if force_epoch:
|
|
141
155
|
with open(FORCE_STAMP, "w") as fh:
|
|
142
156
|
fh.write(str(int(force_epoch)))
|
|
157
|
+
if mcp_epoch:
|
|
158
|
+
with open(MCP_EPOCH_STAMP, "w") as fh:
|
|
159
|
+
fh.write(str(int(mcp_epoch)))
|
|
143
160
|
except Exception:
|
|
144
161
|
return
|
|
145
162
|
env = dict(os.environ)
|
|
@@ -174,11 +191,12 @@ def main():
|
|
|
174
191
|
_maybe_self_update(cfg) # TTL-only on priming
|
|
175
192
|
_emit("SessionStart", SESSION_PRIMER)
|
|
176
193
|
else:
|
|
177
|
-
context, force_epoch, pin_version = _inject(cfg, event.get("prompt") or "")
|
|
194
|
+
context, force_epoch, pin_version, mcp_epoch = _inject(cfg, event.get("prompt") or "")
|
|
178
195
|
# The inject call already round-tripped the server, so honour any
|
|
179
196
|
# admin-pushed force-update epoch here (bypasses the daily TTL) and install
|
|
180
|
-
# the exact server-pinned version (never @latest).
|
|
181
|
-
|
|
197
|
+
# the exact server-pinned version (never @latest). A newer MCP manifest
|
|
198
|
+
# epoch likewise forces an immediate re-provision of the fan-out servers.
|
|
199
|
+
_maybe_self_update(cfg, force_epoch, pin_version, mcp_epoch)
|
|
182
200
|
_emit("UserPromptSubmit", context)
|
|
183
201
|
|
|
184
202
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Genius presence heartbeat (amarre v2, Phase 2) — CONSERVATIVE.
|
|
3
|
+
|
|
4
|
+
Emits one presence ping (~every PING_INTERVAL) to the Genius capture/presence endpoint
|
|
5
|
+
ONLY when there is REAL recent activity, so it binds worked-time to the consultant's
|
|
6
|
+
machine clock without counting an idle-open VSCode:
|
|
7
|
+
|
|
8
|
+
ACTIVE := a workspace SOURCE file was edited in the last IDLE_WINDOW (human work)
|
|
9
|
+
OR the Claude transcript / agent files grew in the last IDLE_WINDOW
|
|
10
|
+
(a /loop or Workflow is executing — counts even with no human interaction)
|
|
11
|
+
IDLE := neither -> NO ping (VSCode open but nothing running is NOT counted)
|
|
12
|
+
|
|
13
|
+
Metadata only: workspace path + git branch (for project classification + retroactive
|
|
14
|
+
attribution) + machine-clock timestamp + tz. No transcript content. Never blocks, always
|
|
15
|
+
exit 0. Single-instance per machine (lockfile). Self-terminates when the Claude session
|
|
16
|
+
is gone (transcript dir idle > STOP_IDLE) or after MAX_LIFETIME.
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
HOME = os.path.expanduser("~")
|
|
27
|
+
CFG = os.path.join(HOME, ".genius", "config.json")
|
|
28
|
+
LOCK = os.path.join(HOME, ".genius", "presence.lock")
|
|
29
|
+
STOP_FLAG = os.path.join(HOME, ".genius", "presence.stop")
|
|
30
|
+
PROJECTS = os.path.join(HOME, ".claude", "projects")
|
|
31
|
+
|
|
32
|
+
PING_INTERVAL = 300 # 5 min between ticks
|
|
33
|
+
IDLE_WINDOW = 600 # a file touched in the last 10 min => active this tick
|
|
34
|
+
STOP_IDLE = 2700 # Claude dir quiet 45 min => session over, exit
|
|
35
|
+
MAX_LIFETIME = 18 * 3600 # hard stop
|
|
36
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".next", "dist", "build",
|
|
37
|
+
".venv", "venv", ".mypy_cache", ".pytest_cache", "outbox"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cfg():
|
|
41
|
+
try:
|
|
42
|
+
with open(CFG) as fh:
|
|
43
|
+
return json.load(fh)
|
|
44
|
+
except Exception:
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _recent(path, window_s, budget_s=2.0):
|
|
49
|
+
"""True if any non-skipped file under path has mtime within window_s. Bounded by a
|
|
50
|
+
small wall-clock budget so a huge tree never stalls a tick. Early-returns."""
|
|
51
|
+
cutoff = time.time() - window_s
|
|
52
|
+
deadline = time.time() + budget_s
|
|
53
|
+
try:
|
|
54
|
+
for root, dirs, files in os.walk(path):
|
|
55
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
56
|
+
for f in files:
|
|
57
|
+
try:
|
|
58
|
+
if os.path.getmtime(os.path.join(root, f)) >= cutoff:
|
|
59
|
+
return True
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
if time.time() > deadline:
|
|
63
|
+
break
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _project_dir(cwd):
|
|
70
|
+
"""Best-effort: the ~/.claude/projects/<slug> dir for this cwd, else the most
|
|
71
|
+
recently-modified project dir (the active session)."""
|
|
72
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "-", cwd)
|
|
73
|
+
cand = os.path.join(PROJECTS, slug)
|
|
74
|
+
if os.path.isdir(cand):
|
|
75
|
+
return cand
|
|
76
|
+
# fallback: most-recently-touched project dir
|
|
77
|
+
try:
|
|
78
|
+
best, best_m = None, 0
|
|
79
|
+
for d in os.listdir(PROJECTS):
|
|
80
|
+
p = os.path.join(PROJECTS, d)
|
|
81
|
+
if os.path.isdir(p):
|
|
82
|
+
m = os.path.getmtime(p)
|
|
83
|
+
if m > best_m:
|
|
84
|
+
best, best_m = p, m
|
|
85
|
+
return best
|
|
86
|
+
except OSError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_branch(cwd):
|
|
91
|
+
try:
|
|
92
|
+
out = subprocess.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
93
|
+
capture_output=True, text=True, timeout=3)
|
|
94
|
+
b = (out.stdout or "").strip()
|
|
95
|
+
return b if b and b != "HEAD" else ""
|
|
96
|
+
except Exception:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _post(cfg, payload):
|
|
101
|
+
base = (cfg.get("base_url") or "").rstrip("/")
|
|
102
|
+
token = cfg.get("token") or ""
|
|
103
|
+
if not base or not token:
|
|
104
|
+
return False
|
|
105
|
+
url = base + "/xma/genius/v1/capture/presence"
|
|
106
|
+
body = json.dumps({"jsonrpc": "2.0", "method": "call",
|
|
107
|
+
"params": payload, "id": 1}).encode()
|
|
108
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
109
|
+
req.add_header("Content-Type", "application/json")
|
|
110
|
+
req.add_header("Authorization", "Bearer " + token)
|
|
111
|
+
req.add_header("User-Agent", "GeniusPresence/1.0") # WAF blocks default Python-urllib UA
|
|
112
|
+
try:
|
|
113
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
114
|
+
json.loads(resp.read().decode("utf-8", "replace") or "{}")
|
|
115
|
+
return True
|
|
116
|
+
except Exception:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _work_mode(cwd, claude_dir):
|
|
121
|
+
"""Label HOW the work is happening (owner: loop/workflow vs short interactions).
|
|
122
|
+
A workspace edit = the human is present -> interactive. Otherwise, if a Workflow/
|
|
123
|
+
subagent run is active -> autonomous (Claude working alone, e.g. overnight). Default
|
|
124
|
+
interactive (a normal Claude turn)."""
|
|
125
|
+
if _recent(cwd, IDLE_WINDOW):
|
|
126
|
+
return "interactive"
|
|
127
|
+
if claude_dir:
|
|
128
|
+
for sub in ("subagents", os.path.join("subagents", "workflows")):
|
|
129
|
+
if _recent(os.path.join(claude_dir, sub), IDLE_WINDOW, budget_s=1.5):
|
|
130
|
+
return "autonomous"
|
|
131
|
+
return "interactive"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _ping(cfg, cwd, session_ref, claude_dir):
|
|
135
|
+
payload = {
|
|
136
|
+
"session_ref": session_ref,
|
|
137
|
+
"occurred_at_epoch": time.time(),
|
|
138
|
+
"utc_offset_minutes": -int(time.timezone // 60) if not time.daylight
|
|
139
|
+
else -int(time.altzone // 60),
|
|
140
|
+
"workspace": cwd[:256],
|
|
141
|
+
"git_branch": _git_branch(cwd)[:128],
|
|
142
|
+
"surface": "vscode",
|
|
143
|
+
"work_mode": _work_mode(cwd, claude_dir),
|
|
144
|
+
}
|
|
145
|
+
return _post(cfg, payload)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_active(cwd, claude_dir):
|
|
149
|
+
"""Conservative: real work signal in the last IDLE_WINDOW — human edit OR Claude
|
|
150
|
+
execution (loop/workflow). Idle-open VSCode touches no file -> not active."""
|
|
151
|
+
if _recent(cwd, IDLE_WINDOW):
|
|
152
|
+
return True
|
|
153
|
+
if claude_dir and _recent(claude_dir, IDLE_WINDOW):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _claude_idle_too_long(claude_dir):
|
|
159
|
+
return not (claude_dir and _recent(claude_dir, STOP_IDLE, budget_s=2.0))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main():
|
|
163
|
+
args = sys.argv[1:]
|
|
164
|
+
once = "--once" in args
|
|
165
|
+
cwd = os.getcwd()
|
|
166
|
+
session_ref = "vscode:%s" % time.strftime("%Y-%m-%d")
|
|
167
|
+
for i, a in enumerate(args):
|
|
168
|
+
if a == "--cwd" and i + 1 < len(args):
|
|
169
|
+
cwd = args[i + 1]
|
|
170
|
+
if a == "--session" and i + 1 < len(args):
|
|
171
|
+
session_ref = args[i + 1]
|
|
172
|
+
cfg = _cfg()
|
|
173
|
+
claude_dir = _project_dir(cwd)
|
|
174
|
+
|
|
175
|
+
if once:
|
|
176
|
+
active = _is_active(cwd, claude_dir)
|
|
177
|
+
ok = _ping(cfg, cwd, session_ref, claude_dir) if active else False
|
|
178
|
+
print(json.dumps({"active": active, "posted": ok, "cwd": cwd,
|
|
179
|
+
"claude_dir": claude_dir, "session_ref": session_ref}))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# single-instance lock
|
|
183
|
+
try:
|
|
184
|
+
if os.path.exists(LOCK):
|
|
185
|
+
with open(LOCK) as fh:
|
|
186
|
+
pid = int((fh.read() or "0").strip() or 0)
|
|
187
|
+
if pid:
|
|
188
|
+
try:
|
|
189
|
+
os.kill(pid, 0) # alive?
|
|
190
|
+
return # another daemon owns it
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
with open(LOCK, "w") as fh:
|
|
194
|
+
fh.write(str(os.getpid()))
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
start = time.time()
|
|
199
|
+
try:
|
|
200
|
+
while True:
|
|
201
|
+
if os.path.exists(STOP_FLAG):
|
|
202
|
+
break
|
|
203
|
+
if time.time() - start > MAX_LIFETIME:
|
|
204
|
+
break
|
|
205
|
+
claude_dir = _project_dir(cwd) # refresh (slug may resolve later)
|
|
206
|
+
if _is_active(cwd, claude_dir):
|
|
207
|
+
_ping(cfg, cwd, session_ref, claude_dir)
|
|
208
|
+
elif _claude_idle_too_long(claude_dir):
|
|
209
|
+
break # session truly over
|
|
210
|
+
time.sleep(PING_INTERVAL)
|
|
211
|
+
finally:
|
|
212
|
+
try:
|
|
213
|
+
if os.path.exists(LOCK):
|
|
214
|
+
os.remove(LOCK)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
try:
|
|
221
|
+
main()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
sys.exit(0)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmarts/genius-setup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "One-command, self-updating onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session/time capture + plan/committee→Brain enforcement + CLAUDE.md protocol). Secret-free onboarding via single-use setup-token exchange
|
|
3
|
+
"version": "1.10.0",
|
|
4
|
+
"description": "One-command, self-updating onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session/time capture + presence heartbeat for honest time-tracking 'amarre' + plan/committee→Brain enforcement + CLAUDE.md protocol + MCP fan-out: provisions the consultant's granted MCP servers into Claude Code & Claude Desktop). Secret-free onboarding via single-use setup-token exchange. Installs once; self-updates daily so new tools/skills/features arrive with zero action.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"genius-setup": "bin/genius-setup.js"
|
|
7
7
|
},
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"hooks/genius_inject.py",
|
|
11
11
|
"hooks/genius_capture.py",
|
|
12
12
|
"hooks/plan_committee_guard.py",
|
|
13
|
+
"hooks/genius_presence.py",
|
|
13
14
|
"CLAUDE_PROTOCOL.md",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|