@neurodock/cli 0.4.3 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # @neurodock/cli changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added — `neurodock install-hooks` (proactive guardrails Phases 1 + 3)
6
+
7
+ One command wires NeuroDock's proactive-guardrail layer into a fresh
8
+ install. Bundled self-contained Python scripts ship with the npm
9
+ package — no extra `pip install` step.
10
+
11
+ ```sh
12
+ neurodock install-hooks --self-test
13
+ neurodock install-hooks --install-daemon --self-test
14
+ neurodock install-hooks --uninstall
15
+ neurodock install-hooks --dry-run
16
+ ```
17
+
18
+ The command:
19
+
20
+ 1. Copies `proactive_guardrail.py` to `~/.neurodock/hooks/` (Phase 1
21
+ Claude Code hook; auto-fires chronometric / rumination /
22
+ sycophancy checks on every Nth tool use, banners on stderr).
23
+ 2. Copies `neurodock_daemon.py` to the same dir (Phase 3 host-agnostic
24
+ poller; same heuristics, native OS notifications).
25
+ 3. Idempotently merges 4 entries into `~/.claude/settings.json`
26
+ (`SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`), preserving
27
+ any existing hooks from other tools.
28
+ 4. With `--install-daemon`, registers the daemon at user-login
29
+ autostart: HKCU Run on Windows, LaunchAgent on macOS, systemd
30
+ `--user` unit on Linux.
31
+ 5. With `--self-test`, runs both scripts' built-in smoke test to
32
+ verify Python is on PATH and the heuristics fire.
33
+
34
+ **Opt-out:**
35
+
36
+ ```sh
37
+ neurodock install-hooks --uninstall # removes hook + daemon entries
38
+ export NEURODOCK_GUARDRAILS=off # disables without removing
39
+ ```
40
+
41
+ ### Fixed — Windows path-escape lockout (regression-pinned)
42
+
43
+ Pre-0.6.0 the hook command written into `settings.json` used
44
+ backslashes on Windows. Bash-style shells (Git Bash / MinGW — what
45
+ Claude Code uses for hooks on Windows) interpret `\U`, `\h`, `\p`
46
+ etc. as escape sequences and strip them, mangling the path to
47
+ `pythonscript.py` and causing every PreToolUse hook to fail, which
48
+ **blocks every tool call** until the user manually edits
49
+ `settings.json`. The 0.0.22 install command hit this. Twice.
50
+
51
+ 0.6.0 always normalises the script path to forward slashes (Python
52
+ accepts them on Windows) and always wraps it in double quotes. The
53
+ new install summary also echoes the exact hook command string so the
54
+ bug class can't recur silently.
55
+
56
+ Regression test: `packages/cli/tests/install-hooks.test.ts` pins the
57
+ contract — the test fails if any hook entry contains a backslash in
58
+ its script-path portion.
59
+
60
+ ### Internals
61
+
62
+ - New build step `scripts/copy-assets.mjs` copies `src/assets/` into
63
+ `dist/assets/` after `tsc`, so the published npm tarball contains
64
+ the bundled Python scripts.
65
+ - `package.json` `files` array already includes `dist/`, so no
66
+ manifest change needed.
67
+
68
+ ## 0.5.0
69
+
70
+ ### Changed (breaking)
71
+
72
+ - `neurodock update` now upgrades NeuroDock to the latest version
73
+ (re-installs the six MCP servers via `pip install --upgrade` or
74
+ `uv tool install` and re-wires clients). Previously, `update` only
75
+ re-shaped client config JSON without touching package versions —
76
+ which confused users who expected `update` to behave like every
77
+ other CLI's `update` verb.
78
+ - The previous behavior moved to a new verb: `neurodock sync`. Same
79
+ flags (`--client`, `--dry-run`), same code path, same semantics.
80
+ - The new `update` command accepts the same flags as `install-all`
81
+ (`--client`, `--profile`, `--installer`, `--skip-install`, `--yes`,
82
+ `--dry-run`, `--no-native-host`).
83
+
84
+ ### Added
85
+
86
+ - README now has a dedicated `## Update` section documenting the
87
+ one-liner: `npx --yes @neurodock/cli update`.
88
+
89
+ ### Migration
90
+
91
+ If you scripted against `neurodock update --client X --dry-run` to
92
+ reconcile client configs, rename the call to `neurodock sync` — the
93
+ old flags still work there. If you wanted package upgrades, the new
94
+ `neurodock update` is what you were looking for.
95
+
3
96
  ## 0.4.3
4
97
 
5
98
  ### Changed
package/README.md CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  The `neurodock` installer and diagnostic CLI for [NeuroDock](https://neurodock.org/) — a local-first cognitive substrate for neurodivergent professionals.
4
4
 
5
- Status: **v0.4.3**.
5
+ Status: **v0.6.0**.
6
6
 
7
7
  ## Quickstart
8
8
 
9
9
  ```bash
10
10
  # From a published package:
11
- npx --yes @neurodock/cli install-all
11
+ npx --yes @neurodock/cli@latest install-all
12
12
 
13
13
  # Or step-by-step, also from npm:
14
- npx --yes @neurodock/cli init
14
+ npx --yes @neurodock/cli@latest init
15
15
 
16
16
  # From a fresh clone of the monorepo:
17
17
  pnpm install
@@ -38,7 +38,8 @@ install, `install-all` runs the `pip install` step automatically.
38
38
  | `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`). |
39
39
  | `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability. |
40
40
  | `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default). |
41
- | `neurodock update` | Re-run install adapters; rewrite stale NeuroDock MCP entries in client configs. Non-NeuroDock entries are preserved. |
41
+ | `neurodock update` | Upgrade NeuroDock to the latest version — re-installs the six MCP servers via pip/uv and re-wires client configs. |
42
+ | `neurodock sync` | Re-shape stale NeuroDock MCP entries in existing client configs. No package upgrade. Non-NeuroDock entries are preserved. |
42
43
  | `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`. |
43
44
  | `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool. |
44
45
  | `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly. |
@@ -92,21 +93,47 @@ What `init` does, in order:
92
93
  Idempotent. Re-running with no changes is a no-op. Collisions on a previous
93
94
  key are skipped unless `--yes` is supplied.
94
95
 
95
- ### `neurodock validate` / `update` / `uninstall`
96
+ ### `neurodock update`
97
+
98
+ ```
99
+ neurodock update [--client=claude-desktop|claude-code|cursor|all] \
100
+ [--profile=minimal|example] \
101
+ [--installer=uv|pip|auto] \
102
+ [--skip-install] [--yes] [--dry-run] [--no-native-host]
103
+ ```
104
+
105
+ One-command upgrade. Same code path as `install-all` — re-runs
106
+ `pip install --upgrade` (or `uv tool install`) for every NeuroDock MCP
107
+ server, re-wires the detected MCP clients, and re-registers the
108
+ optional native-messaging host. Exit codes match `install-all`:
109
+ `0` ok, `1` an entrypoint is missing from PATH, `2` init failed.
110
+
111
+ ### `neurodock sync`
112
+
113
+ ```
114
+ neurodock sync [--client <id>] [--dry-run]
115
+ ```
116
+
117
+ Re-shape stale NeuroDock MCP entries in existing client configs
118
+ (version drift, command/args/cwd changes) without upgrading any
119
+ packages. Non-NeuroDock entries round-trip untouched. Useful when the
120
+ desired wiring shape changed but you don't need a package upgrade.
121
+
122
+ Before 0.5.0 this lived under `neurodock update`. The verb moved
123
+ because users typed `neurodock update` expecting a version upgrade.
124
+
125
+ ### `neurodock validate` / `uninstall`
96
126
 
97
127
  ```
98
128
  neurodock validate [--file <path>] [--strict]
99
- neurodock update [--client <id>] [--dry-run]
100
129
  neurodock uninstall [--client <id>] [--yes] [--purge] [--dry-run]
101
130
  ```
102
131
 
103
132
  `validate` runs Ajv against `profile.schema.json` and reports field-path
104
- violations. `update` rewrites stale NeuroDock MCP entries (version drift,
105
- command/args/cwd changes); non-NeuroDock entries round-trip untouched.
106
- `uninstall` removes NeuroDock entries from every detected client config;
107
- asks (interactively) whether to delete `~/.neurodock/profile.yaml` and
108
- `~/.neurodock/cognitive-graph.sqlite` (default: no). `--purge` deletes
109
- those without prompting.
133
+ violations. `uninstall` removes NeuroDock entries from every detected
134
+ client config; asks (interactively) whether to delete
135
+ `~/.neurodock/profile.yaml` and `~/.neurodock/cognitive-graph.sqlite`
136
+ (default: no). `--purge` deletes those without prompting.
110
137
 
111
138
  ### `neurodock examples`
112
139
 
@@ -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())