@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,520 @@
1
+ #!/usr/bin/env python3
2
+ """NeuroDock proactive guardrail — Claude Code hook (self-contained).
3
+
4
+ Bundled with `@neurodock/cli`; copied to `~/.neurodock/hooks/` by
5
+ `neurodock install-hooks`. Pure stdlib — no pip install, no MCP server
6
+ required. Heuristics vendored from packages/mcp-guardrail so the hook
7
+ keeps working when MCP servers aren't running yet.
8
+
9
+ Hook events handled (Claude Code subcommand args):
10
+
11
+ session-start Track session start time + clock band; emit a banner
12
+ if we're in the deep-night band.
13
+ pre-tool Record the current user prompt; every Nth tool use,
14
+ evaluate hyperfocus + rumination and emit banners.
15
+ post-tool Detect sycophancy patterns in assistant responses.
16
+ stop Mark session end; clear in-flight state.
17
+
18
+ Wire-up in `~/.claude/settings.json`:
19
+
20
+ {
21
+ "hooks": {
22
+ "SessionStart": [
23
+ {"hooks": [{"type": "command", "command": "python ~/.neurodock/hooks/proactive_guardrail.py session-start"}]}
24
+ ],
25
+ "PreToolUse": [
26
+ {"hooks": [{"type": "command", "command": "python ~/.neurodock/hooks/proactive_guardrail.py pre-tool"}]}
27
+ ],
28
+ "PostToolUse": [
29
+ {"hooks": [{"type": "command", "command": "python ~/.neurodock/hooks/proactive_guardrail.py post-tool"}]}
30
+ ],
31
+ "Stop": [
32
+ {"hooks": [{"type": "command", "command": "python ~/.neurodock/hooks/proactive_guardrail.py stop"}]}
33
+ ]
34
+ }
35
+ }
36
+
37
+ `neurodock install-hooks` writes this idempotently.
38
+
39
+ Opt-out: set `NEURODOCK_GUARDRAILS=off` in the environment, or delete
40
+ the `hooks` entries from `settings.json`. The hook is silent by
41
+ default unless a heuristic actually trips.
42
+
43
+ State files (all under `~/.neurodock/state/`):
44
+
45
+ guardrail-session.json - {started_at, intent, tool_count}
46
+ guardrail-prompts.json - rolling list of {at, text} for rumination
47
+ guardrail-log.jsonl - audit trail (every banner emitted)
48
+
49
+ The hook NEVER blocks the user's work. Any internal error is logged
50
+ and swallowed.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import json
56
+ import os
57
+ import re
58
+ import sys
59
+ from dataclasses import dataclass
60
+ from datetime import datetime, time, timedelta, timezone
61
+ from pathlib import Path
62
+ from typing import Any
63
+
64
+ # ── Configuration ────────────────────────────────────────────────────────
65
+
66
+ VERSION = "0.0.1"
67
+ STATE_DIR = Path.home() / ".neurodock" / "state"
68
+ LOG_FILE = STATE_DIR / "guardrail-log.jsonl"
69
+ SESSION_FILE = STATE_DIR / "guardrail-session.json"
70
+ PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
71
+
72
+ # Hyperfocus heuristic — mirrors packages/mcp-guardrail/heuristics/hyperfocus.py
73
+ 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
77
+
78
+ # Rumination heuristic — Jaccard similarity over normalised word sets.
79
+ RUMINATION_WINDOW_MINUTES_DEFAULT = 90
80
+ RUMINATION_THRESHOLD_DEFAULT = 3
81
+ RUMINATION_SIMILARITY_DEFAULT = 0.55 # tuned per mcp-guardrail tests
82
+
83
+ # Don't run heuristics on every single tool call — that 4x's the hook
84
+ # latency. Every Nth PreToolUse instead.
85
+ PRETOOL_CHECK_EVERY_N = 5
86
+
87
+ # Cap prompt-history file so it doesn't grow forever.
88
+ MAX_PROMPT_HISTORY = 200
89
+
90
+ # Deep-night / late-night clock bands trigger an early-warning banner
91
+ # 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
94
+
95
+
96
+ # ── Main dispatch ────────────────────────────────────────────────────────
97
+
98
+
99
+ def main() -> int:
100
+ if os.environ.get("NEURODOCK_GUARDRAILS", "").lower() == "off":
101
+ return 0
102
+ if len(sys.argv) < 2:
103
+ return 0
104
+ kind = sys.argv[1]
105
+ payload = _read_stdin_payload()
106
+ try:
107
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
108
+ except OSError:
109
+ return 0 # filesystem unavailable — fail silent
110
+ try:
111
+ if kind == "session-start":
112
+ _on_session_start(payload)
113
+ elif kind == "pre-tool":
114
+ _on_pre_tool(payload)
115
+ elif kind == "post-tool":
116
+ _on_post_tool(payload)
117
+ elif kind == "stop":
118
+ _on_stop(payload)
119
+ elif kind == "self-test":
120
+ return _self_test()
121
+ except Exception as exc: # noqa: BLE001 — must never block the user
122
+ _log("error", {"kind": kind, "error": str(exc)})
123
+ return 0
124
+
125
+
126
+ # ── Hook handlers ────────────────────────────────────────────────────────
127
+
128
+
129
+ def _on_session_start(_payload: dict[str, Any]) -> None:
130
+ now = _now()
131
+ state = _load_session()
132
+ state["started_at"] = now.isoformat()
133
+ state["tool_count"] = 0
134
+ _save_session(state)
135
+ band = _clock_band(now)
136
+ if band in ("deep_night", "late_night"):
137
+ _emit_banner(
138
+ f"NeuroDock: it's {band.replace('_', ' ')} local time. "
139
+ f"I'll nudge you toward stopping every "
140
+ f"{HYPERFOCUS_BREAK_MINUTES_DEFAULT} minutes."
141
+ )
142
+ _log("session-start", {"band": band})
143
+
144
+
145
+ def _on_pre_tool(payload: dict[str, Any]) -> None:
146
+ state = _load_session()
147
+ state["tool_count"] = int(state.get("tool_count", 0)) + 1
148
+ _save_session(state)
149
+
150
+ prompt = _extract_user_prompt(payload)
151
+ if prompt:
152
+ _record_prompt(prompt)
153
+
154
+ if state["tool_count"] % PRETOOL_CHECK_EVERY_N != 0:
155
+ return
156
+
157
+ hyperfocus_banner = _evaluate_hyperfocus(state)
158
+ if hyperfocus_banner:
159
+ _emit_banner(hyperfocus_banner)
160
+ rumination_banner = _evaluate_rumination()
161
+ if rumination_banner:
162
+ _emit_banner(rumination_banner)
163
+
164
+
165
+ def _on_post_tool(payload: dict[str, Any]) -> None:
166
+ response = _extract_assistant_response(payload)
167
+ if not response:
168
+ return
169
+ sycophancy_banner = _evaluate_sycophancy(response)
170
+ if sycophancy_banner:
171
+ _emit_banner(sycophancy_banner)
172
+
173
+
174
+ def _on_stop(_payload: dict[str, Any]) -> None:
175
+ state = _load_session()
176
+ started = state.get("started_at")
177
+ duration_min: int | None = None
178
+ if isinstance(started, str):
179
+ try:
180
+ elapsed = _now() - datetime.fromisoformat(started)
181
+ duration_min = int(elapsed.total_seconds() // 60)
182
+ except ValueError:
183
+ pass
184
+ _save_session({}) # clear
185
+ _log("session-end", {"duration_min": duration_min})
186
+
187
+
188
+ # ── Heuristics (vendored from packages/mcp-guardrail) ────────────────────
189
+
190
+
191
+ def _evaluate_hyperfocus(state: dict[str, Any]) -> str | None:
192
+ """Elapsed-threshold heuristic; mirrors mcp-guardrail's structure."""
193
+ started_iso = state.get("started_at")
194
+ if not isinstance(started_iso, str):
195
+ return None
196
+ try:
197
+ started = datetime.fromisoformat(started_iso)
198
+ except ValueError:
199
+ return None
200
+ now = _now()
201
+ elapsed = now - started
202
+ elapsed_min = elapsed.total_seconds() / 60.0
203
+
204
+ gentle = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_GENTLE_RATIO
205
+ nudge = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_NUDGE_RATIO
206
+ hard = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_HARD_RATIO
207
+
208
+ band = _clock_band(now)
209
+ past_eod = band in ("late_night", "deep_night")
210
+
211
+ level: str
212
+ if elapsed_min < gentle:
213
+ level = "none"
214
+ elif elapsed_min < nudge:
215
+ level = "gentle"
216
+ elif elapsed_min < hard:
217
+ level = "nudge"
218
+ else:
219
+ level = "hard"
220
+ # End-of-day escalates one rung.
221
+ if past_eod and level == "gentle":
222
+ level = "nudge"
223
+ elif past_eod and level == "nudge":
224
+ level = "hard"
225
+
226
+ if level == "none":
227
+ return None
228
+
229
+ elapsed_label = f"{int(elapsed_min)} min"
230
+ if level == "gentle":
231
+ return (
232
+ f"NeuroDock hyperfocus check ({elapsed_label}): consider standing up, "
233
+ "hydrating, looking 20ft away for 20 seconds."
234
+ )
235
+ if level == "nudge":
236
+ return (
237
+ f"NeuroDock hyperfocus check ({elapsed_label}): worth taking a real "
238
+ "break — walk outside, switch context for 10 minutes."
239
+ )
240
+ return (
241
+ f"NeuroDock hyperfocus check ({elapsed_label}): you've crossed the "
242
+ "hard threshold. Save your work and stop for the day."
243
+ )
244
+
245
+
246
+ def _evaluate_rumination() -> str | None:
247
+ """Jaccard-similarity rumination detector across recent prompts."""
248
+ prompts = _load_prompts()
249
+ if len(prompts) < RUMINATION_THRESHOLD_DEFAULT:
250
+ return None
251
+ 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
+ ]
256
+ if len(recent) < RUMINATION_THRESHOLD_DEFAULT:
257
+ return None
258
+ # Compare the latest prompt to the others.
259
+ latest = recent[-1]["text"]
260
+ matches = 0
261
+ for prior in recent[:-1]:
262
+ sim = _jaccard_similarity(latest, prior["text"])
263
+ if sim >= RUMINATION_SIMILARITY_DEFAULT:
264
+ matches += 1
265
+ if matches < RUMINATION_THRESHOLD_DEFAULT - 1:
266
+ return None
267
+ return (
268
+ f"NeuroDock rumination check: you've asked a variant of this question "
269
+ f"{matches + 1} times in the last {RUMINATION_WINDOW_MINUTES_DEFAULT} "
270
+ "minutes. Want to step back, or are you finding what you need?"
271
+ )
272
+
273
+
274
+ def _evaluate_sycophancy(response: str) -> str | None:
275
+ """Lightweight sycophancy detector — opens with absolute agreement +
276
+ no trade-off named. Conservative; designed to fire rarely."""
277
+ if len(response) < 60:
278
+ return None
279
+ opener = response.lstrip()[:200].lower()
280
+ absolutes = [
281
+ "absolutely", "exactly right", "you're right",
282
+ "you are right", "great point", "excellent point",
283
+ "perfect", "spot on", "100%", "100 percent",
284
+ ]
285
+ hits = [phrase for phrase in absolutes if phrase in opener]
286
+ if not hits:
287
+ return None
288
+ # If the response contains a trade-off marker, treat it as balanced.
289
+ tradeoff_markers = [
290
+ "however", "trade-off", "tradeoff", "downside",
291
+ "but ", "although", "the cost is", "the risk is",
292
+ ]
293
+ if any(marker in response.lower() for marker in tradeoff_markers):
294
+ return None
295
+ return (
296
+ "NeuroDock sycophancy check: Claude's response opens with "
297
+ f"'{hits[0]}' and names no trade-off. Push back if you disagree."
298
+ )
299
+
300
+
301
+ def _jaccard_similarity(a: str, b: str) -> float:
302
+ set_a = _normalise_for_similarity(a)
303
+ set_b = _normalise_for_similarity(b)
304
+ if not set_a or not set_b:
305
+ return 0.0
306
+ intersection = len(set_a & set_b)
307
+ union = len(set_a | set_b)
308
+ return intersection / union if union > 0 else 0.0
309
+
310
+
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
+ })
321
+
322
+
323
+ def _normalise_for_similarity(text: str) -> set[str]:
324
+ tokens = re.findall(r"[a-z0-9]+", text.lower())
325
+ return {t for t in tokens if len(t) > 2 and t not in _STOP_WORDS}
326
+
327
+
328
+ # ── Clock bands ──────────────────────────────────────────────────────────
329
+
330
+
331
+ def _clock_band(now: datetime) -> str:
332
+ hour = now.hour
333
+ if hour in DEEP_NIGHT_HOURS:
334
+ return "deep_night"
335
+ if hour in LATE_NIGHT_HOURS:
336
+ return "late_night"
337
+ if hour < 12:
338
+ return "morning"
339
+ if hour < 17:
340
+ return "afternoon"
341
+ return "evening"
342
+
343
+
344
+ def _now() -> datetime:
345
+ return datetime.now(timezone.utc).astimezone()
346
+
347
+
348
+ # ── Payload extraction (best-effort against Claude Code shape) ───────────
349
+
350
+
351
+ def _extract_user_prompt(payload: dict[str, Any]) -> str | None:
352
+ """Pull a user prompt from the hook payload. Claude Code's exact field
353
+ name varies by event — try a handful and stop on first match."""
354
+ for key in ("prompt", "user_prompt", "userPrompt", "input"):
355
+ value = payload.get(key)
356
+ if isinstance(value, str) and value.strip():
357
+ return value.strip()
358
+ # Tool input shape — used by some hook variants
359
+ tool_input = payload.get("tool_input")
360
+ if isinstance(tool_input, dict):
361
+ text = tool_input.get("prompt") or tool_input.get("input")
362
+ if isinstance(text, str) and text.strip():
363
+ return text.strip()
364
+ return None
365
+
366
+
367
+ def _extract_assistant_response(payload: dict[str, Any]) -> str | None:
368
+ """Pull an assistant response from PostToolUse payload."""
369
+ for key in ("response", "response_text", "output", "result"):
370
+ value = payload.get(key)
371
+ if isinstance(value, str) and value.strip():
372
+ return value.strip()
373
+ tool_response = payload.get("tool_response")
374
+ if isinstance(tool_response, dict):
375
+ text = tool_response.get("text") or tool_response.get("output")
376
+ if isinstance(text, str) and text.strip():
377
+ return text.strip()
378
+ return None
379
+
380
+
381
+ # ── State I/O ────────────────────────────────────────────────────────────
382
+
383
+
384
+ def _load_session() -> dict[str, Any]:
385
+ try:
386
+ with SESSION_FILE.open("r", encoding="utf-8") as fh:
387
+ data = json.load(fh)
388
+ return data if isinstance(data, dict) else {}
389
+ except (OSError, json.JSONDecodeError):
390
+ return {}
391
+
392
+
393
+ def _save_session(state: dict[str, Any]) -> None:
394
+ try:
395
+ with SESSION_FILE.open("w", encoding="utf-8") as fh:
396
+ json.dump(state, fh)
397
+ except OSError:
398
+ pass
399
+
400
+
401
+ def _load_prompts() -> list[dict[str, Any]]:
402
+ try:
403
+ with PROMPTS_FILE.open("r", encoding="utf-8") as fh:
404
+ data = json.load(fh)
405
+ return data if isinstance(data, list) else []
406
+ except (OSError, json.JSONDecodeError):
407
+ return []
408
+
409
+
410
+ def _record_prompt(text: str) -> None:
411
+ prompts = _load_prompts()
412
+ prompts.append({"at": _now().isoformat(), "text": text[:2000]})
413
+ if len(prompts) > MAX_PROMPT_HISTORY:
414
+ prompts = prompts[-MAX_PROMPT_HISTORY:]
415
+ try:
416
+ with PROMPTS_FILE.open("w", encoding="utf-8") as fh:
417
+ json.dump(prompts, fh)
418
+ except OSError:
419
+ pass
420
+
421
+
422
+ def _parse_iso(value: str) -> datetime:
423
+ try:
424
+ return datetime.fromisoformat(value)
425
+ except ValueError:
426
+ return datetime.min.replace(tzinfo=timezone.utc)
427
+
428
+
429
+ # ── Output ───────────────────────────────────────────────────────────────
430
+
431
+
432
+ def _emit_banner(message: str) -> None:
433
+ line = f"\n┌─ NeuroDock ──\n│ {message}\n└──\n"
434
+ sys.stderr.write(line)
435
+ sys.stderr.flush()
436
+ _log("banner", {"message": message[:200]})
437
+
438
+
439
+ def _log(event: str, data: dict[str, Any]) -> None:
440
+ try:
441
+ entry = {
442
+ "at": _now().isoformat(),
443
+ "event": event,
444
+ "version": VERSION,
445
+ **data,
446
+ }
447
+ with LOG_FILE.open("a", encoding="utf-8") as fh:
448
+ fh.write(json.dumps(entry) + "\n")
449
+ except OSError:
450
+ pass
451
+
452
+
453
+ def _read_stdin_payload() -> dict[str, Any]:
454
+ if sys.stdin.isatty():
455
+ return {}
456
+ try:
457
+ raw = sys.stdin.read()
458
+ if not raw.strip():
459
+ return {}
460
+ parsed = json.loads(raw)
461
+ return parsed if isinstance(parsed, dict) else {}
462
+ except (json.JSONDecodeError, OSError):
463
+ return {}
464
+
465
+
466
+ # ── Self-test (run with `python proactive_guardrail.py self-test`) ──────
467
+
468
+
469
+ def _self_test() -> int:
470
+ """Smoke-test each heuristic against a known-trip and known-skip input."""
471
+ ok = True
472
+
473
+ # Hyperfocus: 200 min elapsed → hard level
474
+ fake_state = {
475
+ "started_at": (_now() - timedelta(minutes=200)).isoformat(),
476
+ "tool_count": 5,
477
+ }
478
+ banner = _evaluate_hyperfocus(fake_state)
479
+ if banner is None or "hard" not in banner.lower():
480
+ sys.stderr.write(f"FAIL: hyperfocus hard-level: {banner!r}\n")
481
+ ok = False
482
+
483
+ # Hyperfocus: 10 min elapsed → no banner
484
+ fake_state["started_at"] = (_now() - timedelta(minutes=10)).isoformat()
485
+ if _evaluate_hyperfocus(fake_state) is not None:
486
+ sys.stderr.write("FAIL: hyperfocus should not fire at 10 min\n")
487
+ ok = False
488
+
489
+ # Sycophancy: positive case
490
+ sycophancy_text = "Absolutely! You're 100% right about this approach."
491
+ if _evaluate_sycophancy(sycophancy_text * 3) is None:
492
+ sys.stderr.write("FAIL: sycophancy should fire on pure agreement\n")
493
+ ok = False
494
+
495
+ # Sycophancy: balanced case
496
+ balanced_text = (
497
+ "Absolutely a valid approach, however the downside is increased "
498
+ "complexity and the trade-off is worth weighing carefully."
499
+ )
500
+ if _evaluate_sycophancy(balanced_text * 2) is not None:
501
+ sys.stderr.write("FAIL: sycophancy should NOT fire on balanced text\n")
502
+ ok = False
503
+
504
+ # Jaccard similarity sanity check
505
+ sim = _jaccard_similarity(
506
+ "how do I fix the linkedin image translation",
507
+ "the linkedin image translation is broken how do I fix it",
508
+ )
509
+ if sim < 0.5:
510
+ sys.stderr.write(f"FAIL: jaccard sim too low: {sim}\n")
511
+ ok = False
512
+
513
+ if ok:
514
+ sys.stdout.write(f"OK: proactive_guardrail v{VERSION} self-test passed.\n")
515
+ return 0
516
+ return 1
517
+
518
+
519
+ if __name__ == "__main__":
520
+ sys.exit(main())
@@ -0,0 +1,20 @@
1
+ export interface InstallHooksOptions {
2
+ readonly dryRun: boolean;
3
+ readonly selfTest: boolean;
4
+ readonly uninstall: boolean;
5
+ /**
6
+ * 0.0.23: also wire the long-running Phase 3 daemon at user-login
7
+ * autostart (HKCU Run on Windows, LaunchAgent on macOS, systemd
8
+ * --user unit on Linux). Off by default — the daemon is optional;
9
+ * the Phase 1 Claude Code hook + Phase 2 extension watchdog cover
10
+ * the common cases. The daemon adds host-agnostic coverage (catches
11
+ * you working in the terminal at 02:00 too).
12
+ */
13
+ readonly installDaemon?: boolean;
14
+ }
15
+ export interface InstallHooksResult {
16
+ readonly messages: string[];
17
+ readonly exitCode: number;
18
+ }
19
+ export declare function runInstallHooks(options: InstallHooksOptions): Promise<InstallHooksResult>;
20
+ //# sourceMappingURL=install-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install-hooks.d.ts","sourceRoot":"","sources":["../../src/commands/install-hooks.ts"],"names":[],"mappings":"AAwCA,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAoBD,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAwH7B"}