@neurodock/cli 0.6.2 → 0.7.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 CHANGED
@@ -1,5 +1,78 @@
1
1
  # @neurodock/cli changelog
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Fixed — Windows: daemon autostart no longer flashes a black console window
6
+
7
+ The 0.7.0 HKCU Run entry used `python.exe` (console subsystem) which
8
+ flashed a visible terminal window on every Windows login. Switched to
9
+ `pythonw.exe` (windows subsystem — same interpreter, no console).
10
+ The post-install detached spawn in `install-hooks` does the same and
11
+ also passes `windowsHide: true` on the spawn options. No behavioural
12
+ change on macOS or Linux. Existing 0.7.0 installs should re-run
13
+ `neurodock install-hooks --install-daemon` to refresh the Run entry.
14
+
15
+ ## 0.7.0
16
+
17
+ ### Fixed — proactive-guardrail wiring after 2026-05-26 silent-failure incident
18
+
19
+ The user spent 6 hours coding without a single break warning from the
20
+ substrate. Diagnosis: the Phase 1 hook was writing a degraded session
21
+ shape (`{tool_count: N}` with no `started_at`), and the Phase 3 daemon
22
+ had never been started on the machine — only registered for autostart,
23
+ which would have fired at next login (i.e. never, in a long session).
24
+ Three plumbing fixes, no new heuristics or severity tiers (clinical
25
+ ETHICS contract honoured — `mcp-guardrail` unchanged):
26
+
27
+ - **Phase 1 hook defensive bootstrap**: on `PreToolUse`, if
28
+ `started_at` is missing from session state, set it to `now()`
29
+ rather than silently degrade. Adds `last_active_at` on each
30
+ PreToolUse for the same reason. The existing elapsed-time
31
+ heuristic can finally compute against a real anchor.
32
+ - **`neurodock install-hooks --install-daemon` now actually starts
33
+ the daemon** after registering autostart, via a detached + unref'd
34
+ spawn. Previously it only set up the HKCU Run / LaunchAgent /
35
+ systemd-user entry, which means the user had to log out + back in
36
+ to see the first daemon tick.
37
+ - **`neurodock guardrail status`**: new read-only diagnostic
38
+ subcommand that prints which pieces of the wiring are present
39
+ (hook registered? session shape healthy? daemon script on disk?
40
+ autostart registered? daemon alive in last 15 min?) so the user
41
+ can see at a glance which piece is missing before another long
42
+ session goes silent.
43
+
44
+ No heuristic thresholds touched. No clinical sign-off required.
45
+
46
+ ### Security — atomic writes for CLI scaffolding (TOCTOU defence)
47
+
48
+ `neurodock init`, `neurodock install-hooks`, and `neurodock plugin enable` previously
49
+ used an `existsSync` check followed by a separate `writeFileSync`, leaving a
50
+ TOCTOU (time-of-check / time-of-use) window in which a local attacker could swap the
51
+ target path for a symlink between the two calls. All three sites now use atomic
52
+ write primitives: new-file creation uses `O_CREAT | O_EXCL` (one kernel operation
53
+ that makes the check-and-create atomic), while update-in-place sites write to a
54
+ unique `.pid.ts.tmp` sibling and rename it into place — rename is atomic on POSIX
55
+ and best-effort on Windows. A shared helper `src/util/atomic-write.ts` encapsulates
56
+ both patterns.
57
+
58
+ ### Fixed — Python hooks no longer swallow exceptions silently
59
+
60
+ Seven `except …: pass` blocks in `proactive_guardrail.py` and `neurodock_daemon.py`
61
+ were changed to `except Exception as exc:` with structured logging. The affected
62
+ sites are session-end timestamp parsing, session-file saves, prompt-file saves, log
63
+ writes, and the daemon dedup-timestamp parse. `KeyboardInterrupt` and `SystemExit`
64
+ continue to propagate because `except Exception` excludes them by design. The log
65
+ write itself falls back to a minimal `sys.stderr` line to avoid infinite recursion.
66
+
67
+ ### Fixed — notification-text escape hardened (M5)
68
+
69
+ `_escape()` in `neurodock_daemon.py` was split into two context-aware functions,
70
+ `_escape_ps()` (PowerShell) and `_escape_as()` (AppleScript). Both strip the full
71
+ set of shell-metacharacters that the original single-function omitted: backtick,
72
+ `$(){}`, `;&|<>`, and backslash for PowerShell; `\\&|;\`` for AppleScript.
73
+ Unit tests in `packages/cli/src/assets/hooks/test_daemon_escape.py` assert that
74
+ injection payloads containing all dangerous characters produce literal-text output.
75
+
3
76
  ## 0.6.2
4
77
 
5
78
  ### Fixed — `neurodock --version` was hardcoded and stale (read package.json instead)
package/README.md CHANGED
@@ -2,7 +2,7 @@
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.6.0**.
5
+ Status: **v0.6.2**.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -32,26 +32,26 @@ install, `install-all` runs the `pip install` step automatically.
32
32
 
33
33
  ## Commands
34
34
 
35
- | Command | What it does |
36
- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
37
- | `neurodock install-all` | One-command first-time install: pip-install the six servers, wire every detected client, copy the starter profile. |
38
- | `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`). |
39
- | `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability. |
40
- | `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default). |
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. |
43
- | `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`. |
44
- | `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool. |
45
- | `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly. |
46
- | `neurodock host uninstall` | Remove all NeuroDock native-host manifests and registry pointers. |
47
- | `neurodock profile show` | Print the resolved profile with loader defaults applied. |
48
- | `neurodock profile validate` | Validate `~/.neurodock/profile.yaml` against the v0.1 schema. |
49
- | `neurodock plugin add` | Install a plugin from a local directory into `~/.neurodock/plugins/`. |
50
- | `neurodock plugin remove` | Uninstall a plugin (alias: `plugin uninstall`). |
51
- | `neurodock plugin list` | List installed plugins and their enabled state. `--json` for scripting. |
52
- | `neurodock plugin enable` | Activate an installed plugin (writes a `.enabled` marker file). |
53
- | `neurodock plugin disable` | Deactivate an installed plugin without deleting its files. |
54
- | `neurodock plugin validate` | Schema-validate a plugin manifest without installing. |
35
+ | Command | What it does |
36
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `neurodock install-all` | One-command first-time install: pip-install the six servers, wire every detected client, copy the starter profile. |
38
+ | `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`). |
39
+ | `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability. |
40
+ | `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default). |
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. |
43
+ | `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`. |
44
+ | `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool. |
45
+ | `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly. |
46
+ | `neurodock host uninstall` | Remove all NeuroDock native-host manifests and registry pointers. |
47
+ | `neurodock profile show` | Print the resolved profile with loader defaults applied. |
48
+ | `neurodock profile validate` | Validate `~/.neurodock/profile.yaml` against the v0.1 schema. |
49
+ | `neurodock plugin add` | Install a plugin from a local directory into `~/.neurodock/plugins/`. |
50
+ | `neurodock plugin remove` | Uninstall a plugin (alias: `plugin uninstall`). |
51
+ | `neurodock plugin list` | List installed plugins and their enabled state. `--json` for scripting. |
52
+ | `neurodock plugin enable` | Activate an installed plugin (writes a `.enabled` marker file). |
53
+ | `neurodock plugin disable` | Deactivate an installed plugin without deleting its files. |
54
+ | `neurodock plugin validate` | Schema-validate a plugin manifest without installing. |
55
55
  | `neurodock install-hooks` | Wire the proactive-guardrail hook into Claude Code, optionally autostart the Phase 3 daemon. See `--self-test`, `--install-daemon`, `--uninstall`, `--dry-run`. |
56
56
 
57
57
  ### `neurodock install-all`
@@ -46,7 +46,7 @@ import platform
46
46
  import subprocess
47
47
  import sys
48
48
  import time
49
- from datetime import datetime, timedelta, timezone
49
+ from datetime import UTC, datetime, timedelta
50
50
  from pathlib import Path
51
51
  from typing import Any
52
52
 
@@ -67,8 +67,8 @@ DEDUP_SECONDS = 30 * 60
67
67
  # Heuristic thresholds — mirror the Phase 1 hook so the user gets a
68
68
  # consistent experience whether they're in Claude Code or not.
69
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
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
72
 
73
73
 
74
74
  def main() -> int:
@@ -117,7 +117,7 @@ def _run_forever() -> int:
117
117
  except KeyboardInterrupt:
118
118
  _log("daemon-stop", {"reason": "sigint"})
119
119
  return 0
120
- except Exception as exc: # noqa: BLE001 — must not crash autostart
120
+ except Exception as exc: # broad-except: must not crash autostart
121
121
  _log("daemon-error", {"error": str(exc)})
122
122
  return 1
123
123
 
@@ -133,16 +133,13 @@ def _run_one_tick() -> int:
133
133
  last = state.get("last_surfaced", {})
134
134
  last_kind = last.get("kind")
135
135
  last_at = last.get("at")
136
- if (
137
- last_kind == signal["kind"]
138
- and isinstance(last_at, str)
139
- ):
136
+ if last_kind == signal["kind"] and isinstance(last_at, str):
140
137
  try:
141
138
  elapsed = (now - datetime.fromisoformat(last_at)).total_seconds()
142
139
  if elapsed < DEDUP_SECONDS:
143
140
  return 0
144
- except ValueError:
145
- pass
141
+ except Exception as exc:
142
+ _log("daemon-dedup-parse-error", {"error": str(exc)})
146
143
  _surface(signal)
147
144
  state["last_surfaced"] = {
148
145
  "kind": signal["kind"],
@@ -217,7 +214,7 @@ def _surface(signal: dict[str, Any]) -> None:
217
214
  _notify_macos(title, message)
218
215
  else:
219
216
  _notify_linux(title, message)
220
- except Exception as exc: # noqa: BLE001 — never crash on a notify failure
217
+ except Exception as exc:
221
218
  _log("notify-error", {"error": str(exc)})
222
219
 
223
220
 
@@ -225,14 +222,18 @@ def _notify_windows(title: str, message: str) -> None:
225
222
  # Use PowerShell BurntToast-free approach: New-BurntToastNotification
226
223
  # isn't always present. Use the built-in toast API via XML through
227
224
  # the Windows Runtime instead. Falls back to msg.exe on failure.
225
+ #
226
+ # Security: title and message come from hardcoded daemon logic today,
227
+ # but _escape_ps() defensively strips shell-metacharacters so future
228
+ # callers cannot inject PowerShell via the notification body.
228
229
  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;'
230
+ "[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null;"
231
+ "$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
232
+ "[Windows.UI.Notifications.ToastTemplateType]::ToastText02);"
233
+ f'$template.GetElementsByTagName("text").Item(0).AppendChild($template.CreateTextNode("{_escape_ps(title)}")) | Out-Null;'
234
+ f'$template.GetElementsByTagName("text").Item(1).AppendChild($template.CreateTextNode("{_escape_ps(message)}")) | Out-Null;'
234
235
  '$notify = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("NeuroDock");'
235
- '$notify.Show([Windows.UI.Notifications.ToastNotification]::new($template));'
236
+ "$notify.Show([Windows.UI.Notifications.ToastNotification]::new($template));"
236
237
  )
237
238
  subprocess.run(
238
239
  ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
@@ -243,9 +244,10 @@ def _notify_windows(title: str, message: str) -> None:
243
244
 
244
245
 
245
246
  def _notify_macos(title: str, message: str) -> None:
246
- script = (
247
- f'display notification "{_escape(message)}" with title "{_escape(title)}"'
248
- )
247
+ # Security: osascript -e interpolates title/message into the AppleScript
248
+ # source. _escape_as() strips characters that would break out of the
249
+ # quoted string context inside the AppleScript literal.
250
+ script = f'display notification "{_escape_as(message)}" with title "{_escape_as(title)}"'
249
251
  subprocess.run(
250
252
  ["osascript", "-e", script],
251
253
  check=False,
@@ -258,6 +260,8 @@ def _notify_linux(title: str, message: str) -> None:
258
260
  # Most desktop environments have notify-send. We don't fail loud if
259
261
  # it's absent — Linux server users without a display server will
260
262
  # see the daemon log entries but no toast.
263
+ # Security: title and message are passed as separate argv elements —
264
+ # no shell interpolation, no escaping required.
261
265
  subprocess.run(
262
266
  ["notify-send", "-a", "NeuroDock", title, message],
263
267
  check=False,
@@ -266,8 +270,45 @@ def _notify_linux(title: str, message: str) -> None:
266
270
  )
267
271
 
268
272
 
269
- def _escape(s: str) -> str:
270
- return s.replace('"', '\\"').replace("\n", " ").replace("\r", " ")
273
+ # Characters to STRIP from PowerShell double-quoted string interpolation.
274
+ # Note: double-quote itself is NOT in this set — it is backslash-escaped
275
+ # by _escape_ps() instead of being stripped, so the text node value is
276
+ # preserved. Backtick is the PS escape character, so it must be stripped.
277
+ _PS_STRIP = str.maketrans(
278
+ "",
279
+ "",
280
+ "`$(){};&|<>\\\n\r",
281
+ )
282
+
283
+ # Characters to STRIP from AppleScript double-quoted string interpolation.
284
+ # AppleScript has no reliable backslash escape for double-quote inside a
285
+ # quoted string (the behaviour is interpreter-version-dependent), so both
286
+ # double-quote and backslash are stripped rather than escaped.
287
+ _AS_STRIP = str.maketrans(
288
+ "",
289
+ "",
290
+ '"\\&|;`\n\r',
291
+ )
292
+
293
+
294
+ def _escape_ps(s: str) -> str:
295
+ """Strip PS-dangerous chars; backslash-escape remaining double-quotes.
296
+
297
+ The double-quote is backslash-escaped (not stripped) so that the XML
298
+ text node still receives the literal quote character.
299
+ """
300
+ stripped = s.translate(_PS_STRIP)
301
+ return stripped.replace('"', '\\"')
302
+
303
+
304
+ def _escape_as(s: str) -> str:
305
+ """Strip AppleScript-dangerous chars including double-quote.
306
+
307
+ AppleScript double-quoted literals cannot reliably escape a quote via
308
+ backslash on all macOS versions, so the double-quote is stripped
309
+ entirely rather than kept.
310
+ """
311
+ return s.translate(_AS_STRIP)
271
312
 
272
313
 
273
314
  # ── Autostart wiring ─────────────────────────────────────────────────────
@@ -279,8 +320,7 @@ def _install_autostart() -> int:
279
320
  daemon_script = (HOOK_DIR / "neurodock_daemon.py").resolve()
280
321
  if not daemon_script.exists():
281
322
  sys.stderr.write(
282
- f"daemon script not found at {daemon_script} — run "
283
- "`neurodock install-hooks` first.\n"
323
+ f"daemon script not found at {daemon_script} — run `neurodock install-hooks` first.\n"
284
324
  )
285
325
  return 1
286
326
  if system == "Windows":
@@ -302,25 +342,33 @@ def _uninstall_autostart() -> int:
302
342
  def _install_windows_autostart(daemon_script: Path) -> int:
303
343
  # Register an HKCU Run entry so the daemon launches at user logon.
304
344
  # No admin rights required.
305
- cmd = (
306
- f'python "{str(daemon_script).replace(chr(92), "/")}" run'
307
- )
345
+ #
346
+ # Use `pythonw.exe` (windows-subsystem) not `python.exe` (console-
347
+ # subsystem) so the daemon runs invisibly. With `python.exe` the
348
+ # Run-key launch flashes a black console window on every login
349
+ # because the binary is linked against the console subsystem.
350
+ # `pythonw.exe` ships alongside `python.exe` in every Windows
351
+ # Python install — same interpreter, same site-packages, no console.
352
+ cmd = f'pythonw "{str(daemon_script).replace(chr(92), "/")}" run'
308
353
  try:
309
354
  subprocess.run(
310
355
  [
311
- "reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
312
- "/v", "NeuroDockGuardrail",
313
- "/t", "REG_SZ",
314
- "/d", cmd,
356
+ "reg",
357
+ "add",
358
+ r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
359
+ "/v",
360
+ "NeuroDockGuardrail",
361
+ "/t",
362
+ "REG_SZ",
363
+ "/d",
364
+ cmd,
315
365
  "/f",
316
366
  ],
317
367
  check=True,
318
368
  capture_output=True,
319
369
  timeout=10,
320
370
  )
321
- sys.stdout.write(
322
- "Registered HKCU Run entry. Daemon will launch at next logon.\n"
323
- )
371
+ sys.stdout.write("Registered HKCU Run entry. Daemon will launch at next logon.\n")
324
372
  return 0
325
373
  except subprocess.CalledProcessError as exc:
326
374
  sys.stderr.write(f"reg add failed: {exc.stderr.decode(errors='ignore')}\n")
@@ -331,8 +379,11 @@ def _uninstall_windows_autostart() -> int:
331
379
  try:
332
380
  subprocess.run(
333
381
  [
334
- "reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
335
- "/v", "NeuroDockGuardrail",
382
+ "reg",
383
+ "delete",
384
+ r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
385
+ "/v",
386
+ "NeuroDockGuardrail",
336
387
  "/f",
337
388
  ],
338
389
  check=True,
@@ -410,9 +461,7 @@ WantedBy=default.target
410
461
 
411
462
 
412
463
  def _uninstall_linux_systemd_user_unit() -> int:
413
- unit = (
414
- Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
415
- )
464
+ unit = Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
416
465
  subprocess.run(
417
466
  ["systemctl", "--user", "disable", "--now", "neurodock-guardrail.service"],
418
467
  check=False,
@@ -449,8 +498,8 @@ def _save_daemon_state(state: dict[str, Any]) -> None:
449
498
  try:
450
499
  with DAEMON_STATE_FILE.open("w", encoding="utf-8") as fh:
451
500
  json.dump(state, fh)
452
- except OSError:
453
- pass
501
+ except Exception as exc:
502
+ _log("daemon-state-save-error", {"error": str(exc)})
454
503
 
455
504
 
456
505
  def _log(event: str, data: dict[str, Any]) -> None:
@@ -463,12 +512,13 @@ def _log(event: str, data: dict[str, Any]) -> None:
463
512
  }
464
513
  with LOG_FILE.open("a", encoding="utf-8") as fh:
465
514
  fh.write(json.dumps(entry) + "\n")
466
- except OSError:
467
- pass
515
+ except Exception as exc:
516
+ # Cannot recurse into _log here; write minimally to stderr.
517
+ sys.stderr.write(f"[neurodock-daemon] log-write-error: {exc}\n")
468
518
 
469
519
 
470
520
  def _now() -> datetime:
471
- return datetime.now(timezone.utc).astimezone()
521
+ return datetime.now(UTC).astimezone()
472
522
 
473
523
 
474
524
  # ── Self-test ────────────────────────────────────────────────────────────
@@ -56,8 +56,7 @@ import json
56
56
  import os
57
57
  import re
58
58
  import sys
59
- from dataclasses import dataclass
60
- from datetime import datetime, time, timedelta, timezone
59
+ from datetime import UTC, datetime, timedelta
61
60
  from pathlib import Path
62
61
  from typing import Any
63
62
 
@@ -71,9 +70,9 @@ PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
71
70
 
72
71
  # Hyperfocus heuristic — mirrors packages/mcp-guardrail/heuristics/hyperfocus.py
73
72
  HYPERFOCUS_BREAK_MINUTES_DEFAULT = 90
74
- HYPERFOCUS_GENTLE_RATIO = 0.60 # 54 min
75
- HYPERFOCUS_NUDGE_RATIO = 1.00 # 90 min
76
- HYPERFOCUS_HARD_RATIO = 4.0 / 3.0 # 120 min
73
+ HYPERFOCUS_GENTLE_RATIO = 0.60 # 54 min
74
+ HYPERFOCUS_NUDGE_RATIO = 1.00 # 90 min
75
+ HYPERFOCUS_HARD_RATIO = 4.0 / 3.0 # 120 min
77
76
 
78
77
  # Rumination heuristic — Jaccard similarity over normalised word sets.
79
78
  RUMINATION_WINDOW_MINUTES_DEFAULT = 90
@@ -89,8 +88,8 @@ MAX_PROMPT_HISTORY = 200
89
88
 
90
89
  # Deep-night / late-night clock bands trigger an early-warning banner
91
90
  # at session-start. End-of-day defaults align with `profile.example.yaml`.
92
- DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
93
- LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
91
+ DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
92
+ LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
94
93
 
95
94
 
96
95
  # ── Main dispatch ────────────────────────────────────────────────────────
@@ -118,7 +117,7 @@ def main() -> int:
118
117
  _on_stop(payload)
119
118
  elif kind == "self-test":
120
119
  return _self_test()
121
- except Exception as exc: # noqa: BLE001 — must never block the user
120
+ except Exception as exc:
122
121
  _log("error", {"kind": kind, "error": str(exc)})
123
122
  return 0
124
123
 
@@ -144,7 +143,17 @@ def _on_session_start(_payload: dict[str, Any]) -> None:
144
143
 
145
144
  def _on_pre_tool(payload: dict[str, Any]) -> None:
146
145
  state = _load_session()
146
+ # Defensive bootstrap: if SessionStart never fired (e.g. hook installed
147
+ # mid-session, or the Claude Code event is suppressed in a given client),
148
+ # the elapsed-time heuristics need an anchor. Set started_at on the first
149
+ # PreToolUse rather than silently degrade. The user's 2026-05-26 silent-
150
+ # failure incident hit exactly this path — state had {tool_count: N} with
151
+ # no started_at, so _evaluate_hyperfocus returned None forever.
152
+ if not isinstance(state.get("started_at"), str):
153
+ state["started_at"] = _now().isoformat()
154
+ _log("session-bootstrap", {"reason": "missing-started_at"})
147
155
  state["tool_count"] = int(state.get("tool_count", 0)) + 1
156
+ state["last_active_at"] = _now().isoformat()
148
157
  _save_session(state)
149
158
 
150
159
  prompt = _extract_user_prompt(payload)
@@ -179,8 +188,8 @@ def _on_stop(_payload: dict[str, Any]) -> None:
179
188
  try:
180
189
  elapsed = _now() - datetime.fromisoformat(started)
181
190
  duration_min = int(elapsed.total_seconds() // 60)
182
- except ValueError:
183
- pass
191
+ except Exception as exc:
192
+ _log("session-end-parse-error", {"error": str(exc)})
184
193
  _save_session({}) # clear
185
194
  _log("session-end", {"duration_min": duration_min})
186
195
 
@@ -249,10 +258,7 @@ def _evaluate_rumination() -> str | None:
249
258
  if len(prompts) < RUMINATION_THRESHOLD_DEFAULT:
250
259
  return None
251
260
  window_start = _now() - timedelta(minutes=RUMINATION_WINDOW_MINUTES_DEFAULT)
252
- recent = [
253
- p for p in prompts
254
- if _parse_iso(p.get("at", "")) >= window_start
255
- ]
261
+ recent = [p for p in prompts if _parse_iso(p.get("at", "")) >= window_start]
256
262
  if len(recent) < RUMINATION_THRESHOLD_DEFAULT:
257
263
  return None
258
264
  # Compare the latest prompt to the others.
@@ -278,17 +284,30 @@ def _evaluate_sycophancy(response: str) -> str | None:
278
284
  return None
279
285
  opener = response.lstrip()[:200].lower()
280
286
  absolutes = [
281
- "absolutely", "exactly right", "you're right",
282
- "you are right", "great point", "excellent point",
283
- "perfect", "spot on", "100%", "100 percent",
287
+ "absolutely",
288
+ "exactly right",
289
+ "you're right",
290
+ "you are right",
291
+ "great point",
292
+ "excellent point",
293
+ "perfect",
294
+ "spot on",
295
+ "100%",
296
+ "100 percent",
284
297
  ]
285
298
  hits = [phrase for phrase in absolutes if phrase in opener]
286
299
  if not hits:
287
300
  return None
288
301
  # If the response contains a trade-off marker, treat it as balanced.
289
302
  tradeoff_markers = [
290
- "however", "trade-off", "tradeoff", "downside",
291
- "but ", "although", "the cost is", "the risk is",
303
+ "however",
304
+ "trade-off",
305
+ "tradeoff",
306
+ "downside",
307
+ "but ",
308
+ "although",
309
+ "the cost is",
310
+ "the risk is",
292
311
  ]
293
312
  if any(marker in response.lower() for marker in tradeoff_markers):
294
313
  return None
@@ -308,16 +327,80 @@ def _jaccard_similarity(a: str, b: str) -> float:
308
327
  return intersection / union if union > 0 else 0.0
309
328
 
310
329
 
311
- _STOP_WORDS = frozenset({
312
- "the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
313
- "be", "been", "being", "have", "has", "had", "do", "does", "did",
314
- "will", "would", "could", "should", "may", "might", "must",
315
- "shall", "can", "of", "in", "on", "at", "to", "for", "with",
316
- "by", "from", "as", "if", "then", "else", "when", "where", "why",
317
- "how", "what", "which", "who", "this", "that", "these", "those",
318
- "i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
319
- "us", "them", "my", "your", "his", "its", "our", "their",
320
- })
330
+ _STOP_WORDS = frozenset(
331
+ {
332
+ "the",
333
+ "a",
334
+ "an",
335
+ "and",
336
+ "or",
337
+ "but",
338
+ "is",
339
+ "are",
340
+ "was",
341
+ "were",
342
+ "be",
343
+ "been",
344
+ "being",
345
+ "have",
346
+ "has",
347
+ "had",
348
+ "do",
349
+ "does",
350
+ "did",
351
+ "will",
352
+ "would",
353
+ "could",
354
+ "should",
355
+ "may",
356
+ "might",
357
+ "must",
358
+ "shall",
359
+ "can",
360
+ "of",
361
+ "in",
362
+ "on",
363
+ "at",
364
+ "to",
365
+ "for",
366
+ "with",
367
+ "by",
368
+ "from",
369
+ "as",
370
+ "if",
371
+ "then",
372
+ "else",
373
+ "when",
374
+ "where",
375
+ "why",
376
+ "how",
377
+ "what",
378
+ "which",
379
+ "who",
380
+ "this",
381
+ "that",
382
+ "these",
383
+ "those",
384
+ "i",
385
+ "you",
386
+ "he",
387
+ "she",
388
+ "it",
389
+ "we",
390
+ "they",
391
+ "me",
392
+ "him",
393
+ "her",
394
+ "us",
395
+ "them",
396
+ "my",
397
+ "your",
398
+ "his",
399
+ "its",
400
+ "our",
401
+ "their",
402
+ }
403
+ )
321
404
 
322
405
 
323
406
  def _normalise_for_similarity(text: str) -> set[str]:
@@ -342,7 +425,7 @@ def _clock_band(now: datetime) -> str:
342
425
 
343
426
 
344
427
  def _now() -> datetime:
345
- return datetime.now(timezone.utc).astimezone()
428
+ return datetime.now(UTC).astimezone()
346
429
 
347
430
 
348
431
  # ── Payload extraction (best-effort against Claude Code shape) ───────────
@@ -394,8 +477,8 @@ def _save_session(state: dict[str, Any]) -> None:
394
477
  try:
395
478
  with SESSION_FILE.open("w", encoding="utf-8") as fh:
396
479
  json.dump(state, fh)
397
- except OSError:
398
- pass
480
+ except Exception as exc:
481
+ _log("session-save-error", {"error": str(exc)})
399
482
 
400
483
 
401
484
  def _load_prompts() -> list[dict[str, Any]]:
@@ -415,15 +498,15 @@ def _record_prompt(text: str) -> None:
415
498
  try:
416
499
  with PROMPTS_FILE.open("w", encoding="utf-8") as fh:
417
500
  json.dump(prompts, fh)
418
- except OSError:
419
- pass
501
+ except Exception as exc:
502
+ _log("prompt-save-error", {"error": str(exc)})
420
503
 
421
504
 
422
505
  def _parse_iso(value: str) -> datetime:
423
506
  try:
424
507
  return datetime.fromisoformat(value)
425
508
  except ValueError:
426
- return datetime.min.replace(tzinfo=timezone.utc)
509
+ return datetime.min.replace(tzinfo=UTC)
427
510
 
428
511
 
429
512
  # ── Output ───────────────────────────────────────────────────────────────
@@ -446,8 +529,9 @@ def _log(event: str, data: dict[str, Any]) -> None:
446
529
  }
447
530
  with LOG_FILE.open("a", encoding="utf-8") as fh:
448
531
  fh.write(json.dumps(entry) + "\n")
449
- except OSError:
450
- pass
532
+ except Exception as exc:
533
+ # Cannot recurse into _log here; write minimally to stderr.
534
+ sys.stderr.write(f"[neurodock-guardrail] log-write-error: {exc}\n")
451
535
 
452
536
 
453
537
  def _read_stdin_payload() -> dict[str, Any]: