@xmarts/genius-setup 1.9.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 +14 -0
- package/hooks/genius_presence.py +224 -0
- package/package.json +3 -2
package/bin/genius-setup.js
CHANGED
|
@@ -150,6 +150,7 @@ function runSetup(token) {
|
|
|
150
150
|
const hookDst = copyHook('genius_capture.py');
|
|
151
151
|
const injectDst = copyHook('genius_inject.py');
|
|
152
152
|
const guardDst = copyHook('plan_committee_guard.py');
|
|
153
|
+
const presenceDst = copyHook('genius_presence.py');
|
|
153
154
|
|
|
154
155
|
// 2) ~/.claude.json mcpServers (merge, no clobber)
|
|
155
156
|
const claudeJsonPath = path.join(HOME, '.claude.json');
|
|
@@ -187,6 +188,18 @@ function runSetup(token) {
|
|
|
187
188
|
// (XMA_PLAN_GUARD_MODE=block|warn|off). Unlike the fire-and-forget hooks above, this
|
|
188
189
|
// one can return {"decision":"block"} — by design — but never traps a session.
|
|
189
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
|
+
}
|
|
190
203
|
writeJson(settingsPath, settings);
|
|
191
204
|
|
|
192
205
|
// 4) ~/.claude/CLAUDE.md — install/refresh the Genius Protocol between auto-managed
|
|
@@ -309,6 +322,7 @@ function runSetup(token) {
|
|
|
309
322
|
log(' - Capture hook : ' + hookDst + ' (SessionEnd / PreCompact / Stop)');
|
|
310
323
|
log(' - Inject hook : ' + injectDst + ' (UserPromptSubmit / SessionStart)');
|
|
311
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)');
|
|
312
326
|
log(' - Config : ' + path.join(GENIUS_DIR, 'config.json'));
|
|
313
327
|
log(' - Protocol : ' + path.join(CLAUDE_DIR, 'CLAUDE.md') + ' (auto-managed)');
|
|
314
328
|
if (CLIENT) log(' - Default client : ' + CLIENT);
|
|
@@ -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 + 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.",
|
|
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
|
],
|