@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.
@@ -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.9.0",
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
  ],