@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.
- package/CHANGELOG.md +128 -0
- package/README.md +40 -12
- package/dist/assets/hooks/neurodock_daemon.py +506 -0
- package/dist/assets/hooks/proactive_guardrail.py +520 -0
- package/dist/commands/install-hooks.d.ts +20 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/install-hooks.js +356 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/sync.d.ts +24 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +179 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.d.ts +13 -22
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +11 -176
- package/dist/commands/update.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -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())
|