@neurodock/cli 0.8.0 → 0.8.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,31 @@
1
1
  # @neurodock/cli changelog
2
2
 
3
+ ## 0.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - proactive-guardrail hook: surface banners inline and honour profile.yaml
8
+
9
+ The bundled Claude Code hook (`proactive_guardrail.py`) now surfaces its
10
+ hyperfocus / rumination / late-night / sycophancy banners through Claude
11
+ Code's structured `systemMessage` output (a single JSON object on stdout,
12
+ exit 0) instead of writing to stderr. The pre-0.0.2 hook exited 0 with the
13
+ banner on stderr, which only appears in transcript/verbose mode — so every
14
+ fired banner was effectively invisible during normal use. The hook still
15
+ never exits 2, so it never blocks a tool call.
16
+
17
+ The hook is also now profile-driven: it reads
18
+ `chronometric.hyperfocus_break_minutes`, `chronometric.end_of_day_local`,
19
+ `guardrails.rumination_threshold`, `guardrails.rumination_window_minutes`,
20
+ and `guardrails.sycophancy_check` from `~/.neurodock/profile.yaml`
21
+ (stdlib-only scalar extraction, honouring `$NEURODOCK_PROFILE_PATH` and
22
+ `$XDG_CONFIG_HOME`). Missing or out-of-range values fall back to the schema
23
+ defaults. Previously every threshold was hardcoded, contradicting the
24
+ documented opt-out matrix.
25
+
26
+ After upgrading, re-run `neurodock install-hooks` to copy the updated
27
+ script into `~/.neurodock/hooks/`.
28
+
3
29
  ## 0.8.0 - 2026-06-11
4
30
 
5
31
  ### Added — `neurodock setup`: install-all + install-hooks in one command
@@ -17,6 +17,28 @@ Hook events handled (Claude Code subcommand args):
17
17
  post-tool Detect sycophancy patterns in assistant responses.
18
18
  stop Mark session end; clear in-flight state.
19
19
 
20
+ How banners reach the user (0.0.2):
21
+
22
+ Banners are surfaced through Claude Code's structured hook output —
23
+ a single JSON object printed to STDOUT carrying a `systemMessage`
24
+ field, with the process exiting 0. That is the documented
25
+ non-blocking, user-visible channel. The pre-0.0.2 hook wrote the
26
+ banner to stderr and exited 0, which only shows in transcript/verbose
27
+ mode — so every fired banner was effectively invisible during normal
28
+ use. We never use exit 2 (that BLOCKS the tool call) — the guardrail
29
+ must never block the user's work.
30
+
31
+ Profile-driven thresholds (0.0.2):
32
+
33
+ The hook reads `~/.neurodock/profile.yaml` (honouring
34
+ $NEURODOCK_PROFILE_PATH and $XDG_CONFIG_HOME like the CLI) and uses
35
+ the user's own `chronometric.hyperfocus_break_minutes`,
36
+ `chronometric.end_of_day_local`, `guardrails.rumination_threshold`,
37
+ `guardrails.rumination_window_minutes`, and
38
+ `guardrails.sycophancy_check`. Pure-stdlib scalar extraction — no
39
+ YAML dependency. Missing/invalid values fall back to the defaults
40
+ below.
41
+
20
42
  Wire-up in `~/.claude/settings.json`:
21
43
 
22
44
  {
@@ -64,12 +86,25 @@ from typing import Any
64
86
 
65
87
  # ── Configuration ────────────────────────────────────────────────────────
66
88
 
67
- VERSION = "0.0.1"
89
+ VERSION = "0.0.2"
68
90
  STATE_DIR = Path.home() / ".neurodock" / "state"
69
91
  LOG_FILE = STATE_DIR / "guardrail-log.jsonl"
70
92
  SESSION_FILE = STATE_DIR / "guardrail-session.json"
71
93
  PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
72
94
 
95
+ # Banners accumulated during a single hook invocation. A hook fires once
96
+ # per event and may produce more than one banner (e.g. pre-tool can trip
97
+ # both hyperfocus and rumination), but Claude Code accepts exactly one
98
+ # JSON object on stdout — so we collect here and flush once in main().
99
+ _PENDING_BANNERS: list[str] = []
100
+
101
+ # Valid profile ranges (mirrors packages/core/schemas/profile.example.yaml).
102
+ # Out-of-range values are clamped, not rejected — the hook stays charitable
103
+ # and predictable; `neurodock profile validate` is where ranges are enforced.
104
+ HYPERFOCUS_BREAK_MIN_RANGE = (15, 240)
105
+ RUMINATION_THRESHOLD_RANGE = (1, 20)
106
+ RUMINATION_WINDOW_RANGE = (5, 1440)
107
+
73
108
  # Hyperfocus heuristic — mirrors packages/mcp-guardrail/heuristics/hyperfocus.py
74
109
  HYPERFOCUS_BREAK_MINUTES_DEFAULT = 90
75
110
  HYPERFOCUS_GENTLE_RATIO = 0.60 # 54 min
@@ -103,47 +138,55 @@ def main() -> int:
103
138
  if len(sys.argv) < 2:
104
139
  return 0
105
140
  kind = sys.argv[1]
141
+ # Self-test is a standalone diagnostic — no stdin payload, no state
142
+ # directory, no banner flush. Handle it before anything else.
143
+ if kind == "self-test":
144
+ return _self_test()
106
145
  payload = _read_stdin_payload()
107
146
  try:
108
147
  STATE_DIR.mkdir(parents=True, exist_ok=True)
109
148
  except OSError:
110
149
  return 0 # filesystem unavailable — fail silent
150
+ settings = _load_profile_settings()
111
151
  try:
112
152
  if kind == "session-start":
113
- _on_session_start(payload)
153
+ _on_session_start(payload, settings)
114
154
  elif kind == "pre-tool":
115
- _on_pre_tool(payload)
155
+ _on_pre_tool(payload, settings)
116
156
  elif kind == "post-tool":
117
- _on_post_tool(payload)
157
+ _on_post_tool(payload, settings)
118
158
  elif kind == "stop":
119
159
  _on_stop(payload)
120
- elif kind == "self-test":
121
- return _self_test()
122
160
  except Exception as exc:
123
161
  _log("error", {"kind": kind, "error": str(exc)})
162
+ # Flush any banners the handlers queued, as one JSON object on stdout.
163
+ _flush_banners()
124
164
  return 0
125
165
 
126
166
 
127
167
  # ── Hook handlers ────────────────────────────────────────────────────────
128
168
 
129
169
 
130
- def _on_session_start(_payload: dict[str, Any]) -> None:
170
+ def _on_session_start(_payload: dict[str, Any], settings: dict[str, Any]) -> None:
131
171
  now = _now()
132
172
  state = _load_session()
133
173
  state["started_at"] = now.isoformat()
134
174
  state["tool_count"] = 0
135
175
  _save_session(state)
136
176
  band = _clock_band(now)
177
+ break_minutes = settings.get(
178
+ "hyperfocus_break_minutes", HYPERFOCUS_BREAK_MINUTES_DEFAULT
179
+ )
137
180
  if band in ("deep_night", "late_night"):
138
181
  _emit_banner(
139
182
  f"NeuroDock: it's {band.replace('_', ' ')} local time. "
140
183
  f"I'll nudge you toward stopping every "
141
- f"{HYPERFOCUS_BREAK_MINUTES_DEFAULT} minutes."
184
+ f"{break_minutes} minutes."
142
185
  )
143
186
  _log("session-start", {"band": band})
144
187
 
145
188
 
146
- def _on_pre_tool(payload: dict[str, Any]) -> None:
189
+ def _on_pre_tool(payload: dict[str, Any], settings: dict[str, Any]) -> None:
147
190
  state = _load_session()
148
191
  # Defensive bootstrap: if SessionStart never fired (e.g. hook installed
149
192
  # mid-session, or the Claude Code event is suppressed in a given client),
@@ -165,15 +208,28 @@ def _on_pre_tool(payload: dict[str, Any]) -> None:
165
208
  if state["tool_count"] % PRETOOL_CHECK_EVERY_N != 0:
166
209
  return
167
210
 
168
- hyperfocus_banner = _evaluate_hyperfocus(state)
211
+ break_minutes = settings.get(
212
+ "hyperfocus_break_minutes", HYPERFOCUS_BREAK_MINUTES_DEFAULT
213
+ )
214
+ end_of_day = settings.get("end_of_day_local")
215
+ hyperfocus_banner = _evaluate_hyperfocus(state, break_minutes, end_of_day)
169
216
  if hyperfocus_banner:
170
217
  _emit_banner(hyperfocus_banner)
171
- rumination_banner = _evaluate_rumination()
218
+ threshold = settings.get("rumination_threshold", RUMINATION_THRESHOLD_DEFAULT)
219
+ window = settings.get(
220
+ "rumination_window_minutes", RUMINATION_WINDOW_MINUTES_DEFAULT
221
+ )
222
+ rumination_banner = _evaluate_rumination(threshold, window)
172
223
  if rumination_banner:
173
224
  _emit_banner(rumination_banner)
174
225
 
175
226
 
176
- def _on_post_tool(payload: dict[str, Any]) -> None:
227
+ def _on_post_tool(payload: dict[str, Any], settings: dict[str, Any]) -> None:
228
+ # Honour the user's sycophancy preference: "off" means never flag.
229
+ # "warn"/"refuse" both surface the advisory banner (the hook never
230
+ # refuses a send — that's a client-side decision).
231
+ if settings.get("sycophancy_check") == "off":
232
+ return
177
233
  response = _extract_assistant_response(payload)
178
234
  if not response:
179
235
  return
@@ -199,8 +255,18 @@ def _on_stop(_payload: dict[str, Any]) -> None:
199
255
  # ── Heuristics (vendored from packages/mcp-guardrail) ────────────────────
200
256
 
201
257
 
202
- def _evaluate_hyperfocus(state: dict[str, Any]) -> str | None:
203
- """Elapsed-threshold heuristic; mirrors mcp-guardrail's structure."""
258
+ def _evaluate_hyperfocus(
259
+ state: dict[str, Any],
260
+ break_minutes: int = HYPERFOCUS_BREAK_MINUTES_DEFAULT,
261
+ end_of_day_local: str | None = None,
262
+ ) -> str | None:
263
+ """Elapsed-threshold heuristic; mirrors mcp-guardrail's structure.
264
+
265
+ `break_minutes` re-anchors the escalation ladder (from
266
+ `chronometric.hyperfocus_break_minutes`). `end_of_day_local`
267
+ ("HH:MM") makes the nudge stricter after the user's clock-out time;
268
+ when absent we fall back to the deep/late-night clock band.
269
+ """
204
270
  started_iso = state.get("started_at")
205
271
  if not isinstance(started_iso, str):
206
272
  return None
@@ -212,12 +278,11 @@ def _evaluate_hyperfocus(state: dict[str, Any]) -> str | None:
212
278
  elapsed = now - started
213
279
  elapsed_min = elapsed.total_seconds() / 60.0
214
280
 
215
- gentle = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_GENTLE_RATIO
216
- nudge = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_NUDGE_RATIO
217
- hard = HYPERFOCUS_BREAK_MINUTES_DEFAULT * HYPERFOCUS_HARD_RATIO
281
+ gentle = break_minutes * HYPERFOCUS_GENTLE_RATIO
282
+ nudge = break_minutes * HYPERFOCUS_NUDGE_RATIO
283
+ hard = break_minutes * HYPERFOCUS_HARD_RATIO
218
284
 
219
- band = _clock_band(now)
220
- past_eod = band in ("late_night", "deep_night")
285
+ past_eod = _is_past_end_of_day(now, end_of_day_local)
221
286
 
222
287
  level: str
223
288
  if elapsed_min < gentle:
@@ -254,14 +319,21 @@ def _evaluate_hyperfocus(state: dict[str, Any]) -> str | None:
254
319
  )
255
320
 
256
321
 
257
- def _evaluate_rumination() -> str | None:
258
- """Jaccard-similarity rumination detector across recent prompts."""
322
+ def _evaluate_rumination(
323
+ threshold: int = RUMINATION_THRESHOLD_DEFAULT,
324
+ window_minutes: int = RUMINATION_WINDOW_MINUTES_DEFAULT,
325
+ ) -> str | None:
326
+ """Jaccard-similarity rumination detector across recent prompts.
327
+
328
+ `threshold` and `window_minutes` come from `guardrails.*` in the
329
+ profile; both fall back to the module defaults.
330
+ """
259
331
  prompts = _load_prompts()
260
- if len(prompts) < RUMINATION_THRESHOLD_DEFAULT:
332
+ if len(prompts) < threshold:
261
333
  return None
262
- window_start = _now() - timedelta(minutes=RUMINATION_WINDOW_MINUTES_DEFAULT)
334
+ window_start = _now() - timedelta(minutes=window_minutes)
263
335
  recent = [p for p in prompts if _parse_iso(p.get("at", "")) >= window_start]
264
- if len(recent) < RUMINATION_THRESHOLD_DEFAULT:
336
+ if len(recent) < threshold:
265
337
  return None
266
338
  # Compare the latest prompt to the others.
267
339
  latest = recent[-1]["text"]
@@ -270,11 +342,11 @@ def _evaluate_rumination() -> str | None:
270
342
  sim = _jaccard_similarity(latest, prior["text"])
271
343
  if sim >= RUMINATION_SIMILARITY_DEFAULT:
272
344
  matches += 1
273
- if matches < RUMINATION_THRESHOLD_DEFAULT - 1:
345
+ if matches < threshold - 1:
274
346
  return None
275
347
  return (
276
348
  f"NeuroDock rumination check: you've asked a variant of this question "
277
- f"{matches + 1} times in the last {RUMINATION_WINDOW_MINUTES_DEFAULT} "
349
+ f"{matches + 1} times in the last {window_minutes} "
278
350
  "minutes. Want to step back, or are you finding what you need?"
279
351
  )
280
352
 
@@ -430,6 +502,129 @@ def _now() -> datetime:
430
502
  return datetime.now(UTC).astimezone()
431
503
 
432
504
 
505
+ def _is_past_end_of_day(now: datetime, end_of_day_local: str | None) -> bool:
506
+ """True if `now` is at/after the user's clock-out time, or in deep night.
507
+
508
+ When `end_of_day_local` ("HH:MM") is set, the hyperfocus nudge gets
509
+ stricter after that time (documented behaviour). When it is absent or
510
+ malformed, fall back to the deep/late-night clock band so behaviour is
511
+ unchanged for users who never set it.
512
+ """
513
+ if not isinstance(end_of_day_local, str):
514
+ return _clock_band(now) in ("late_night", "deep_night")
515
+ match = re.match(r"^\s*(\d{1,2}):(\d{2})\s*$", end_of_day_local)
516
+ if match is None:
517
+ return _clock_band(now) in ("late_night", "deep_night")
518
+ hour, minute = int(match.group(1)), int(match.group(2))
519
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
520
+ return _clock_band(now) in ("late_night", "deep_night")
521
+ # Deep night (00:00–05:59) is always "past end of day" regardless of the
522
+ # configured clock-out — nobody sets end_of_day to 03:00 and means it.
523
+ if now.hour < 6:
524
+ return True
525
+ eod_minutes = hour * 60 + minute
526
+ now_minutes = now.hour * 60 + now.minute
527
+ return now_minutes >= eod_minutes
528
+
529
+
530
+ # ── Profile (stdlib-only scalar extraction from profile.yaml) ────────────
531
+
532
+
533
+ def _profile_path() -> Path:
534
+ """Resolve profile.yaml with the same precedence as the CLI
535
+ (packages/cli/src/lib/paths.ts):
536
+ 1. $NEURODOCK_PROFILE_PATH
537
+ 2. $XDG_CONFIG_HOME/neurodock/profile.yaml
538
+ 3. ~/.neurodock/profile.yaml
539
+ """
540
+ override = os.environ.get("NEURODOCK_PROFILE_PATH", "").strip()
541
+ if override:
542
+ return Path(override)
543
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
544
+ if xdg:
545
+ return Path(xdg) / "neurodock" / "profile.yaml"
546
+ return Path.home() / ".neurodock" / "profile.yaml"
547
+
548
+
549
+ def _load_profile_settings() -> dict[str, Any]:
550
+ """Read the user's profile and return the guardrail-relevant scalars.
551
+
552
+ Never raises: a missing/unreadable/invalid profile yields {} and the
553
+ callers fall back to the module defaults.
554
+ """
555
+ try:
556
+ path = _profile_path()
557
+ text = path.read_text(encoding="utf-8")
558
+ except (OSError, ValueError):
559
+ return {}
560
+ try:
561
+ return _parse_profile_text(text)
562
+ except Exception as exc: # never let a parse bug break a tool call
563
+ _log("profile-parse-error", {"error": str(exc)})
564
+ return {}
565
+
566
+
567
+ def _parse_profile_text(text: str) -> dict[str, Any]:
568
+ """Pure scalar extraction from profile YAML — no YAML dependency.
569
+
570
+ The hook is stdlib-only (no pip install), and we only need a handful
571
+ of leaf scalars whose keys are unique across the schema, so a targeted
572
+ line-anchored regex is sufficient and robust against comment lines
573
+ (which begin with `#` and never match `^\\s*<key>:`).
574
+ """
575
+ settings: dict[str, Any] = {}
576
+
577
+ hyperfocus = _extract_int(text, "hyperfocus_break_minutes")
578
+ if hyperfocus is not None:
579
+ settings["hyperfocus_break_minutes"] = _clamp(
580
+ hyperfocus, *HYPERFOCUS_BREAK_MIN_RANGE
581
+ )
582
+
583
+ threshold = _extract_int(text, "rumination_threshold")
584
+ if threshold is not None:
585
+ settings["rumination_threshold"] = _clamp(
586
+ threshold, *RUMINATION_THRESHOLD_RANGE
587
+ )
588
+
589
+ window = _extract_int(text, "rumination_window_minutes")
590
+ if window is not None:
591
+ settings["rumination_window_minutes"] = _clamp(
592
+ window, *RUMINATION_WINDOW_RANGE
593
+ )
594
+
595
+ eod = _extract_str(text, "end_of_day_local")
596
+ if eod is not None and re.match(r"^\d{1,2}:\d{2}$", eod):
597
+ settings["end_of_day_local"] = eod
598
+
599
+ syco = _extract_str(text, "sycophancy_check")
600
+ if syco in ("off", "warn", "refuse"):
601
+ settings["sycophancy_check"] = syco
602
+
603
+ return settings
604
+
605
+
606
+ def _extract_int(text: str, key: str) -> int | None:
607
+ match = re.search(
608
+ rf"^[ \t]*{re.escape(key)}[ \t]*:[ \t]*(\d+)\b",
609
+ text,
610
+ re.MULTILINE,
611
+ )
612
+ return int(match.group(1)) if match else None
613
+
614
+
615
+ def _extract_str(text: str, key: str) -> str | None:
616
+ match = re.search(
617
+ rf"^[ \t]*{re.escape(key)}[ \t]*:[ \t]*[\"']?([^\"'#\r\n]+?)[\"']?[ \t]*(?:#.*)?$",
618
+ text,
619
+ re.MULTILINE,
620
+ )
621
+ return match.group(1).strip() if match else None
622
+
623
+
624
+ def _clamp(value: int, low: int, high: int) -> int:
625
+ return max(low, min(high, value))
626
+
627
+
433
628
  # ── Payload extraction (best-effort against Claude Code shape) ───────────
434
629
 
435
630
 
@@ -515,12 +710,36 @@ def _parse_iso(value: str) -> datetime:
515
710
 
516
711
 
517
712
  def _emit_banner(message: str) -> None:
518
- line = f"\n┌─ NeuroDock ──\n│ {message}\n└──\n"
519
- sys.stderr.write(line)
520
- sys.stderr.flush()
713
+ """Queue a banner for the end-of-invocation flush and record it in the
714
+ audit log. Surfacing happens in `_flush_banners` via Claude Code's
715
+ structured `systemMessage` output — NOT stderr, which only shows in
716
+ transcript/verbose mode (the pre-0.0.2 invisibility bug)."""
717
+ _PENDING_BANNERS.append(message)
521
718
  _log("banner", {"message": message[:200]})
522
719
 
523
720
 
721
+ def _flush_banners() -> None:
722
+ """Emit all queued banners as a single JSON object on stdout, exit 0.
723
+
724
+ `systemMessage` is Claude Code's documented non-blocking, user-visible
725
+ hook channel. We deliberately do NOT set any permission decision, so
726
+ the tool call proceeds untouched — the guardrail never blocks work.
727
+ Failures here are swallowed: a guardrail must never break a tool call.
728
+ """
729
+ if not _PENDING_BANNERS:
730
+ return
731
+ try:
732
+ sys.stdout.write(_render_banner_payload(_PENDING_BANNERS))
733
+ sys.stdout.flush()
734
+ except Exception as exc:
735
+ _log("banner-flush-error", {"error": str(exc)})
736
+
737
+
738
+ def _render_banner_payload(banners: list[str]) -> str:
739
+ """Build the JSON string Claude Code reads from stdout. Pure + testable."""
740
+ return json.dumps({"systemMessage": "\n".join(banners)})
741
+
742
+
524
743
  def _log(event: str, data: dict[str, Any]) -> None:
525
744
  try:
526
745
  entry = {
@@ -556,7 +775,7 @@ def _self_test() -> int:
556
775
  """Smoke-test each heuristic against a known-trip and known-skip input."""
557
776
  ok = True
558
777
 
559
- # Hyperfocus: 200 min elapsed → hard level
778
+ # Hyperfocus: 200 min elapsed → hard level (default 90-min ladder)
560
779
  fake_state = {
561
780
  "started_at": (_now() - timedelta(minutes=200)).isoformat(),
562
781
  "tool_count": 5,
@@ -572,6 +791,70 @@ def _self_test() -> int:
572
791
  sys.stderr.write("FAIL: hyperfocus should not fire at 10 min\n")
573
792
  ok = False
574
793
 
794
+ # Profile-driven threshold: a 60-min break setting must fire at 70 min
795
+ # elapsed (which is below the default 90-min gentle threshold of 54…
796
+ # wait: 54<70 so it would fire by default too — use 45 min vs a 30-min
797
+ # setting to prove the profile value, not the default, drives it).
798
+ short_state = {"started_at": (_now() - timedelta(minutes=45)).isoformat()}
799
+ if _evaluate_hyperfocus(short_state, break_minutes=90) is not None:
800
+ sys.stderr.write("FAIL: 45 min should NOT fire on a 90-min break\n")
801
+ ok = False
802
+ if _evaluate_hyperfocus(short_state, break_minutes=30) is None:
803
+ sys.stderr.write("FAIL: 45 min SHOULD fire on a 30-min break\n")
804
+ ok = False
805
+
806
+ # Profile parsing: extract scalars from a representative profile snippet.
807
+ sample_profile = (
808
+ "schema_version: \"0.1.0\"\n"
809
+ "chronometric:\n"
810
+ " # how long before a nudge\n"
811
+ " hyperfocus_break_minutes: 60\n"
812
+ " end_of_day_local: \"18:30\"\n"
813
+ "guardrails:\n"
814
+ " rumination_threshold: 4\n"
815
+ " rumination_window_minutes: 120\n"
816
+ " sycophancy_check: \"off\"\n"
817
+ )
818
+ parsed = _parse_profile_text(sample_profile)
819
+ expected = {
820
+ "hyperfocus_break_minutes": 60,
821
+ "end_of_day_local": "18:30",
822
+ "rumination_threshold": 4,
823
+ "rumination_window_minutes": 120,
824
+ "sycophancy_check": "off",
825
+ }
826
+ if parsed != expected:
827
+ sys.stderr.write(f"FAIL: profile parse: got {parsed!r}\n")
828
+ ok = False
829
+
830
+ # Profile parsing: out-of-range values are clamped to the valid range.
831
+ clamped = _parse_profile_text("hyperfocus_break_minutes: 9999\n")
832
+ if clamped.get("hyperfocus_break_minutes") != HYPERFOCUS_BREAK_MIN_RANGE[1]:
833
+ sys.stderr.write(f"FAIL: clamp out-of-range: {clamped!r}\n")
834
+ ok = False
835
+
836
+ # Profile parsing: an empty / commented profile yields no overrides.
837
+ if _parse_profile_text("# just a comment\nschema_version: \"0.1.0\"\n") != {}:
838
+ sys.stderr.write("FAIL: bare profile should yield no overrides\n")
839
+ ok = False
840
+
841
+ # End-of-day: 19:00 with an 18:30 clock-out is "past end of day".
842
+ evening = _now().replace(hour=19, minute=0, second=0, microsecond=0)
843
+ if not _is_past_end_of_day(evening, "18:30"):
844
+ sys.stderr.write("FAIL: 19:00 should be past an 18:30 end-of-day\n")
845
+ ok = False
846
+ midday = _now().replace(hour=12, minute=0, second=0, microsecond=0)
847
+ if _is_past_end_of_day(midday, "18:30"):
848
+ sys.stderr.write("FAIL: 12:00 should NOT be past an 18:30 end-of-day\n")
849
+ ok = False
850
+
851
+ # Banner payload: combined banners render as valid JSON with systemMessage.
852
+ payload = _render_banner_payload(["first banner", "second banner"])
853
+ decoded = json.loads(payload)
854
+ if decoded.get("systemMessage") != "first banner\nsecond banner":
855
+ sys.stderr.write(f"FAIL: banner payload shape: {payload!r}\n")
856
+ ok = False
857
+
575
858
  # Sycophancy: positive case
576
859
  sycophancy_text = "Absolutely! You're 100% right about this approach."
577
860
  if _evaluate_sycophancy(sycophancy_text * 3) is None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neurodock/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "NeuroDock installer and diagnostic CLI — wires the MCP servers, manages plugins, and validates your profile.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",