@neurodock/cli 0.4.3 → 0.6.1

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,506 @@
1
+ #!/usr/bin/env python3
2
+ """NeuroDock proactive-guardrail daemon (Phase 3).
3
+
4
+ A long-running poller that watches the same state files the Phase 1
5
+ Claude Code hook updates (`~/.neurodock/state/guardrail-session.json`
6
+ and `~/.neurodock/state/guardrail-prompts.json`), evaluates the same
7
+ hyperfocus / rumination heuristics, and surfaces interventions via the
8
+ host OS's native notification channel.
9
+
10
+ Why a separate daemon when the Phase 1 hook already exists:
11
+ - The hook only fires inside Claude Code. It can't catch you working
12
+ in a terminal at 02:00, doomscrolling at 02:30, or back in
13
+ Claude Code at 03:00 with a stale `started_at`.
14
+ - The daemon is host-agnostic: it polls on a wall-clock tick, runs
15
+ the same heuristics, and notifies you whether you're typing in
16
+ your IDE, in chat, or doing nothing at all.
17
+
18
+ Install (one-time, bundled with `@neurodock/cli`):
19
+
20
+ neurodock install-hooks --install-daemon
21
+
22
+ …which calls into this script's `install` subcommand to set up an
23
+ auto-start entry (Windows: HKCU Run key; macOS: LaunchAgent plist;
24
+ Linux: ~/.config/systemd/user/neurodock-guardrail.service).
25
+
26
+ Run manually:
27
+
28
+ python ~/.neurodock/hooks/neurodock_daemon.py run
29
+ python ~/.neurodock/hooks/neurodock_daemon.py install
30
+ python ~/.neurodock/hooks/neurodock_daemon.py uninstall
31
+ python ~/.neurodock/hooks/neurodock_daemon.py self-test
32
+
33
+ Opt-out at any time:
34
+
35
+ export NEURODOCK_GUARDRAILS=off # disables daemon AND hook
36
+ python ~/.neurodock/hooks/neurodock_daemon.py uninstall
37
+
38
+ Pure stdlib. No pip install. Works on Python 3.11+.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import platform
46
+ import subprocess
47
+ import sys
48
+ import time
49
+ from datetime import datetime, timedelta, timezone
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ VERSION = "0.0.1"
54
+ STATE_DIR = Path.home() / ".neurodock" / "state"
55
+ HOOK_DIR = Path.home() / ".neurodock" / "hooks"
56
+ SESSION_FILE = STATE_DIR / "guardrail-session.json"
57
+ PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
58
+ DAEMON_STATE_FILE = STATE_DIR / "daemon.json"
59
+ LOG_FILE = STATE_DIR / "daemon-log.jsonl"
60
+
61
+ # Tick every 5 minutes by default. Tunable via env for testing.
62
+ TICK_SECONDS = int(os.environ.get("NEURODOCK_DAEMON_TICK_SECONDS", "300"))
63
+
64
+ # Dedup window — don't re-fire the same signal within this many seconds.
65
+ DEDUP_SECONDS = 30 * 60
66
+
67
+ # Heuristic thresholds — mirror the Phase 1 hook so the user gets a
68
+ # consistent experience whether they're in Claude Code or not.
69
+ HYPERFOCUS_BREAK_MINUTES = 90
70
+ DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
71
+ LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
72
+
73
+
74
+ def main() -> int:
75
+ if os.environ.get("NEURODOCK_GUARDRAILS", "").lower() == "off":
76
+ return 0
77
+ if len(sys.argv) < 2:
78
+ sys.stderr.write(_usage())
79
+ return 1
80
+ cmd = sys.argv[1]
81
+ if cmd == "run":
82
+ return _run_forever()
83
+ if cmd == "tick":
84
+ return _run_one_tick()
85
+ if cmd == "install":
86
+ return _install_autostart()
87
+ if cmd == "uninstall":
88
+ return _uninstall_autostart()
89
+ if cmd == "self-test":
90
+ return _self_test()
91
+ sys.stderr.write(_usage())
92
+ return 1
93
+
94
+
95
+ def _usage() -> str:
96
+ return (
97
+ f"NeuroDock proactive-guardrail daemon v{VERSION}\n"
98
+ " run poll forever (foreground)\n"
99
+ " tick run one evaluation cycle and exit\n"
100
+ " install register autostart for the current user\n"
101
+ " uninstall remove autostart entry\n"
102
+ " self-test smoke-test the heuristics\n"
103
+ )
104
+
105
+
106
+ # ── Run loop ─────────────────────────────────────────────────────────────
107
+
108
+
109
+ def _run_forever() -> int:
110
+ """Block forever, ticking every TICK_SECONDS. Exits silently on SIGINT."""
111
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
112
+ _log("daemon-start", {"tick_seconds": TICK_SECONDS, "version": VERSION})
113
+ try:
114
+ while True:
115
+ _run_one_tick()
116
+ time.sleep(TICK_SECONDS)
117
+ except KeyboardInterrupt:
118
+ _log("daemon-stop", {"reason": "sigint"})
119
+ return 0
120
+ except Exception as exc: # noqa: BLE001 — must not crash autostart
121
+ _log("daemon-error", {"error": str(exc)})
122
+ return 1
123
+
124
+
125
+ def _run_one_tick() -> int:
126
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
127
+ state = _load_daemon_state()
128
+ now = _now()
129
+ signal = _evaluate(now)
130
+ if signal is None:
131
+ return 0
132
+ # Dedup: don't re-fire the same signal kind within DEDUP_SECONDS.
133
+ last = state.get("last_surfaced", {})
134
+ last_kind = last.get("kind")
135
+ last_at = last.get("at")
136
+ if (
137
+ last_kind == signal["kind"]
138
+ and isinstance(last_at, str)
139
+ ):
140
+ try:
141
+ elapsed = (now - datetime.fromisoformat(last_at)).total_seconds()
142
+ if elapsed < DEDUP_SECONDS:
143
+ return 0
144
+ except ValueError:
145
+ pass
146
+ _surface(signal)
147
+ state["last_surfaced"] = {
148
+ "kind": signal["kind"],
149
+ "at": now.isoformat(),
150
+ }
151
+ _save_daemon_state(state)
152
+ return 0
153
+
154
+
155
+ # ── Heuristic evaluation ─────────────────────────────────────────────────
156
+
157
+
158
+ def _evaluate(now: datetime) -> dict[str, Any] | None:
159
+ """Return a signal dict, or None when nothing trips this tick."""
160
+ session = _load_session()
161
+ started_iso = session.get("started_at") if isinstance(session, dict) else None
162
+
163
+ # Deep-night check first — most universally applicable signal.
164
+ hour = now.hour
165
+ band: str | None = None
166
+ if hour in DEEP_NIGHT_HOURS:
167
+ band = "deep_night"
168
+ elif hour in LATE_NIGHT_HOURS:
169
+ band = "late_night"
170
+ if band:
171
+ return {
172
+ "kind": "clock_band",
173
+ "band": band,
174
+ "hour": hour,
175
+ "title": "NeuroDock — late-night check",
176
+ "message": (
177
+ f"It's {band.replace('_', ' ')} local ({hour:02d}:00). "
178
+ "If you can save and stop, that's the kindest move."
179
+ ),
180
+ }
181
+
182
+ # Hyperfocus on an open Claude Code session.
183
+ if isinstance(started_iso, str):
184
+ try:
185
+ started = datetime.fromisoformat(started_iso)
186
+ except ValueError:
187
+ started = None
188
+ if started is not None:
189
+ elapsed_min = (now - started).total_seconds() / 60.0
190
+ if elapsed_min >= HYPERFOCUS_BREAK_MINUTES:
191
+ return {
192
+ "kind": "hyperfocus",
193
+ "elapsed_min": int(elapsed_min),
194
+ "title": "NeuroDock — hyperfocus check",
195
+ "message": (
196
+ f"This Claude Code session has been open "
197
+ f"{int(elapsed_min)} min. Worth a real break — "
198
+ "walk, hydrate, switch context for 10 min."
199
+ ),
200
+ }
201
+
202
+ return None
203
+
204
+
205
+ # ── Native notification ──────────────────────────────────────────────────
206
+
207
+
208
+ def _surface(signal: dict[str, Any]) -> None:
209
+ title = signal.get("title", "NeuroDock")
210
+ message = signal.get("message", "")
211
+ _log("surface", {"kind": signal.get("kind"), "title": title})
212
+ system = platform.system()
213
+ try:
214
+ if system == "Windows":
215
+ _notify_windows(title, message)
216
+ elif system == "Darwin":
217
+ _notify_macos(title, message)
218
+ else:
219
+ _notify_linux(title, message)
220
+ except Exception as exc: # noqa: BLE001 — never crash on a notify failure
221
+ _log("notify-error", {"error": str(exc)})
222
+
223
+
224
+ def _notify_windows(title: str, message: str) -> None:
225
+ # Use PowerShell BurntToast-free approach: New-BurntToastNotification
226
+ # isn't always present. Use the built-in toast API via XML through
227
+ # the Windows Runtime instead. Falls back to msg.exe on failure.
228
+ ps_script = (
229
+ '[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null;'
230
+ '$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent('
231
+ '[Windows.UI.Notifications.ToastTemplateType]::ToastText02);'
232
+ f'$template.GetElementsByTagName("text").Item(0).AppendChild($template.CreateTextNode("{_escape(title)}")) | Out-Null;'
233
+ f'$template.GetElementsByTagName("text").Item(1).AppendChild($template.CreateTextNode("{_escape(message)}")) | Out-Null;'
234
+ '$notify = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("NeuroDock");'
235
+ '$notify.Show([Windows.UI.Notifications.ToastNotification]::new($template));'
236
+ )
237
+ subprocess.run(
238
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
239
+ check=False,
240
+ capture_output=True,
241
+ timeout=10,
242
+ )
243
+
244
+
245
+ def _notify_macos(title: str, message: str) -> None:
246
+ script = (
247
+ f'display notification "{_escape(message)}" with title "{_escape(title)}"'
248
+ )
249
+ subprocess.run(
250
+ ["osascript", "-e", script],
251
+ check=False,
252
+ capture_output=True,
253
+ timeout=10,
254
+ )
255
+
256
+
257
+ def _notify_linux(title: str, message: str) -> None:
258
+ # Most desktop environments have notify-send. We don't fail loud if
259
+ # it's absent — Linux server users without a display server will
260
+ # see the daemon log entries but no toast.
261
+ subprocess.run(
262
+ ["notify-send", "-a", "NeuroDock", title, message],
263
+ check=False,
264
+ capture_output=True,
265
+ timeout=10,
266
+ )
267
+
268
+
269
+ def _escape(s: str) -> str:
270
+ return s.replace('"', '\\"').replace("\n", " ").replace("\r", " ")
271
+
272
+
273
+ # ── Autostart wiring ─────────────────────────────────────────────────────
274
+
275
+
276
+ def _install_autostart() -> int:
277
+ """Register a per-user autostart entry on the current OS."""
278
+ system = platform.system()
279
+ daemon_script = (HOOK_DIR / "neurodock_daemon.py").resolve()
280
+ if not daemon_script.exists():
281
+ sys.stderr.write(
282
+ f"daemon script not found at {daemon_script} — run "
283
+ "`neurodock install-hooks` first.\n"
284
+ )
285
+ return 1
286
+ if system == "Windows":
287
+ return _install_windows_autostart(daemon_script)
288
+ if system == "Darwin":
289
+ return _install_macos_launchagent(daemon_script)
290
+ return _install_linux_systemd_user_unit(daemon_script)
291
+
292
+
293
+ def _uninstall_autostart() -> int:
294
+ system = platform.system()
295
+ if system == "Windows":
296
+ return _uninstall_windows_autostart()
297
+ if system == "Darwin":
298
+ return _uninstall_macos_launchagent()
299
+ return _uninstall_linux_systemd_user_unit()
300
+
301
+
302
+ def _install_windows_autostart(daemon_script: Path) -> int:
303
+ # Register an HKCU Run entry so the daemon launches at user logon.
304
+ # No admin rights required.
305
+ cmd = (
306
+ f'python "{str(daemon_script).replace(chr(92), "/")}" run'
307
+ )
308
+ try:
309
+ subprocess.run(
310
+ [
311
+ "reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
312
+ "/v", "NeuroDockGuardrail",
313
+ "/t", "REG_SZ",
314
+ "/d", cmd,
315
+ "/f",
316
+ ],
317
+ check=True,
318
+ capture_output=True,
319
+ timeout=10,
320
+ )
321
+ sys.stdout.write(
322
+ "Registered HKCU Run entry. Daemon will launch at next logon.\n"
323
+ )
324
+ return 0
325
+ except subprocess.CalledProcessError as exc:
326
+ sys.stderr.write(f"reg add failed: {exc.stderr.decode(errors='ignore')}\n")
327
+ return 1
328
+
329
+
330
+ def _uninstall_windows_autostart() -> int:
331
+ try:
332
+ subprocess.run(
333
+ [
334
+ "reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
335
+ "/v", "NeuroDockGuardrail",
336
+ "/f",
337
+ ],
338
+ check=True,
339
+ capture_output=True,
340
+ timeout=10,
341
+ )
342
+ return 0
343
+ except subprocess.CalledProcessError:
344
+ # Already absent — fine.
345
+ return 0
346
+
347
+
348
+ def _install_macos_launchagent(daemon_script: Path) -> int:
349
+ agent_dir = Path.home() / "Library" / "LaunchAgents"
350
+ agent_dir.mkdir(parents=True, exist_ok=True)
351
+ plist = agent_dir / "org.neurodock.guardrail.plist"
352
+ contents = f"""<?xml version="1.0" encoding="UTF-8"?>
353
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
354
+ <plist version="1.0">
355
+ <dict>
356
+ <key>Label</key><string>org.neurodock.guardrail</string>
357
+ <key>ProgramArguments</key>
358
+ <array>
359
+ <string>/usr/bin/env</string>
360
+ <string>python3</string>
361
+ <string>{daemon_script}</string>
362
+ <string>run</string>
363
+ </array>
364
+ <key>RunAtLoad</key><true/>
365
+ <key>KeepAlive</key><true/>
366
+ <key>StandardOutPath</key><string>{LOG_FILE}</string>
367
+ <key>StandardErrorPath</key><string>{LOG_FILE}</string>
368
+ </dict>
369
+ </plist>
370
+ """
371
+ plist.write_text(contents, encoding="utf-8")
372
+ subprocess.run(["launchctl", "load", str(plist)], check=False, capture_output=True)
373
+ sys.stdout.write(f"Wrote LaunchAgent at {plist}\n")
374
+ return 0
375
+
376
+
377
+ def _uninstall_macos_launchagent() -> int:
378
+ plist = Path.home() / "Library" / "LaunchAgents" / "org.neurodock.guardrail.plist"
379
+ if not plist.exists():
380
+ return 0
381
+ subprocess.run(["launchctl", "unload", str(plist)], check=False, capture_output=True)
382
+ plist.unlink(missing_ok=True)
383
+ return 0
384
+
385
+
386
+ def _install_linux_systemd_user_unit(daemon_script: Path) -> int:
387
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
388
+ unit_dir.mkdir(parents=True, exist_ok=True)
389
+ unit = unit_dir / "neurodock-guardrail.service"
390
+ contents = f"""[Unit]
391
+ Description=NeuroDock proactive guardrail daemon
392
+
393
+ [Service]
394
+ ExecStart=/usr/bin/env python3 {daemon_script} run
395
+ Restart=always
396
+ RestartSec=10
397
+
398
+ [Install]
399
+ WantedBy=default.target
400
+ """
401
+ unit.write_text(contents, encoding="utf-8")
402
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
403
+ subprocess.run(
404
+ ["systemctl", "--user", "enable", "--now", "neurodock-guardrail.service"],
405
+ check=False,
406
+ capture_output=True,
407
+ )
408
+ sys.stdout.write(f"Wrote systemd user unit at {unit}\n")
409
+ return 0
410
+
411
+
412
+ def _uninstall_linux_systemd_user_unit() -> int:
413
+ unit = (
414
+ Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
415
+ )
416
+ subprocess.run(
417
+ ["systemctl", "--user", "disable", "--now", "neurodock-guardrail.service"],
418
+ check=False,
419
+ capture_output=True,
420
+ )
421
+ if unit.exists():
422
+ unit.unlink(missing_ok=True)
423
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
424
+ return 0
425
+
426
+
427
+ # ── State + logging ──────────────────────────────────────────────────────
428
+
429
+
430
+ def _load_session() -> dict[str, Any]:
431
+ try:
432
+ with SESSION_FILE.open("r", encoding="utf-8") as fh:
433
+ data = json.load(fh)
434
+ return data if isinstance(data, dict) else {}
435
+ except (OSError, json.JSONDecodeError):
436
+ return {}
437
+
438
+
439
+ def _load_daemon_state() -> dict[str, Any]:
440
+ try:
441
+ with DAEMON_STATE_FILE.open("r", encoding="utf-8") as fh:
442
+ data = json.load(fh)
443
+ return data if isinstance(data, dict) else {}
444
+ except (OSError, json.JSONDecodeError):
445
+ return {}
446
+
447
+
448
+ def _save_daemon_state(state: dict[str, Any]) -> None:
449
+ try:
450
+ with DAEMON_STATE_FILE.open("w", encoding="utf-8") as fh:
451
+ json.dump(state, fh)
452
+ except OSError:
453
+ pass
454
+
455
+
456
+ def _log(event: str, data: dict[str, Any]) -> None:
457
+ try:
458
+ entry = {
459
+ "at": _now().isoformat(),
460
+ "event": event,
461
+ "version": VERSION,
462
+ **data,
463
+ }
464
+ with LOG_FILE.open("a", encoding="utf-8") as fh:
465
+ fh.write(json.dumps(entry) + "\n")
466
+ except OSError:
467
+ pass
468
+
469
+
470
+ def _now() -> datetime:
471
+ return datetime.now(timezone.utc).astimezone()
472
+
473
+
474
+ # ── Self-test ────────────────────────────────────────────────────────────
475
+
476
+
477
+ def _self_test() -> int:
478
+ ok = True
479
+ # Deep-night: synthesize a fake now at 02:00.
480
+ now = _now().replace(hour=2)
481
+ signal = _evaluate(now)
482
+ if signal is None or signal.get("kind") != "clock_band":
483
+ sys.stderr.write(f"FAIL: deep-night should fire at 02:00, got {signal}\n")
484
+ ok = False
485
+ # Hyperfocus: write a fake session 200 min ago, evaluate at midday.
486
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
487
+ fake_start = (now.replace(hour=12) - timedelta(minutes=200)).isoformat()
488
+ SESSION_FILE.write_text(
489
+ json.dumps({"started_at": fake_start, "tool_count": 5}),
490
+ encoding="utf-8",
491
+ )
492
+ midday = _now().replace(hour=12)
493
+ signal = _evaluate(midday)
494
+ if signal is None or signal.get("kind") != "hyperfocus":
495
+ sys.stderr.write(f"FAIL: hyperfocus should fire after 200 min, got {signal}\n")
496
+ ok = False
497
+ # Cleanup
498
+ SESSION_FILE.unlink(missing_ok=True)
499
+ if ok:
500
+ sys.stdout.write(f"OK: neurodock_daemon v{VERSION} self-test passed.\n")
501
+ return 0
502
+ return 1
503
+
504
+
505
+ if __name__ == "__main__":
506
+ sys.exit(main())