Skip to content

Commit fad29a1

Browse files
authored
Merge pull request #12 from hanamorix/post-week-4-tech-debt
Post-Week-4 tech-debt cleanup — compact CLI + SHA verify + 2 docs
2 parents 0392b62 + 52071a9 commit fad29a1

5 files changed

Lines changed: 107 additions & 4 deletions

File tree

brain/cli.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,38 @@ def _heartbeat_handler(args: argparse.Namespace) -> int:
170170
f" dream: {'would fire' if result.dream_id else (result.dream_gated_reason or 'gated')}"
171171
)
172172
else:
173+
verbose = getattr(args, "verbose", False)
173174
print(f"Heartbeat tick complete ({args.trigger}).")
174175
print(f" elapsed: {result.elapsed_seconds / 3600:.2f}h")
175176
print(f" decayed: {result.memories_decayed} memories, pruned {result.edges_pruned} edges")
177+
178+
# Dream: show fires + interesting gates. Suppress "not_due" by default.
176179
if result.dream_id:
177180
print(f" dream fired: {result.dream_id}")
178-
else:
181+
elif verbose or (result.dream_gated_reason and result.dream_gated_reason != "not_due"):
179182
print(f" dream gated: {result.dream_gated_reason or 'gated'}")
183+
184+
# Reflex: show fires. Suppress "evaluated, nothing fired" unless --verbose.
180185
if result.reflex_fired:
181186
print(f" reflex fired: {', '.join(result.reflex_fired)}")
182-
elif result.reflex_skipped_count > 0:
187+
elif verbose and result.reflex_skipped_count > 0:
183188
print(f" reflex evaluated ({result.reflex_skipped_count} arc(s) skipped)")
189+
190+
# Research: show fires + interesting gates (no_eligible_interest,
191+
# no_interests_defined, research_raised). Suppress not_due + reflex_won_tie
192+
# by default.
184193
if result.research_fired:
185194
print(f" research fired: {result.research_fired}")
186-
elif result.research_gated_reason and result.research_gated_reason != "not_due":
195+
elif result.research_gated_reason and (
196+
verbose or result.research_gated_reason not in ("not_due", "reflex_won_tie")
197+
):
187198
print(f" research gated: {result.research_gated_reason}")
199+
200+
# Interest bumps: show only if > 0 (already compact). Verbose adds zero.
188201
if result.interests_bumped > 0:
189202
print(f" interests bumped: {result.interests_bumped}")
203+
elif verbose:
204+
print(" interests bumped: 0")
190205
return 0
191206

192207

@@ -454,6 +469,12 @@ def _build_parser() -> argparse.ArgumentParser:
454469
help="Web searcher for research engine: ddgs (default), noop, claude-tool.",
455470
)
456471
hb_sub.add_argument("--dry-run", action="store_true")
472+
hb_sub.add_argument(
473+
"--verbose",
474+
action="store_true",
475+
help="Show all engine outcomes including gated reasons + zero-count engines. "
476+
"Default output is compact — events shown, non-events hidden.",
477+
)
457478
hb_sub.set_defaults(func=_heartbeat_handler)
458479

459480
rf_sub = subparsers.add_parser(

brain/emotion/influence.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ def to_dict(self) -> dict[str, Any]:
5353
# Emotion → tone bias mapping. Only triggers when the emotion is dominant
5454
# and above a minimum intensity. Immutable tuple so callers can't accidentally
5555
# corrupt rule matching via `influence._TONE_RULES.append(...)`.
56+
#
57+
# Soft-coupling note (post vocabulary-split):
58+
# `creative_hunger` is no longer in the framework baseline — it lives in
59+
# per-persona emotion_vocabulary.json since the 2026-04-25 split. This rule
60+
# only fires when the persona's *currently dominant* emotion is named
61+
# `creative_hunger`, so personas that don't register that name simply never
62+
# hit the rule (graceful no-op). Other personas can opt in to the same
63+
# tone bias by registering an emotion with that exact name in their
64+
# vocabulary file. Future work (Phase 2 emergence + Tauri GUI) may move
65+
# tone bias to a per-emotion field in the persona's vocabulary so this
66+
# table doesn't have to know persona-specific names at all — for now,
67+
# the soft coupling is documented + intentional.
5668
_TONE_RULES: tuple[tuple[str, float, str], ...] = (
5769
("grief", 6.0, "tender"),
5870
("tenderness", 7.0, "tender"),

brain/emotion/vocabulary.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
from dataclasses import dataclass
2020
from typing import Literal
2121

22+
# `nell_specific` retained for backwards-compat — pre-vocabulary-split persona
23+
# files in the wild may still contain entries with this category. New baseline
24+
# entries use only "core" or "complex"; new persona-loaded entries use only
25+
# "persona_extension". The Literal accepts `nell_specific` so loaders don't
26+
# reject older files outright.
2227
EmotionCategory = Literal["core", "complex", "nell_specific", "persona_extension"]
2328

2429

brain/migrator/cli.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,16 @@ def _ensure_clobber_safe(path: Path, force: bool, kind: str) -> None:
249249

250250

251251
def _verify_sources_unchanged(og_dir: Path, manifest: list[FileManifest]) -> None:
252-
"""Re-stat each source file; abort if any size differs from manifest."""
252+
"""Re-stat + re-hash each source file; abort if size or SHA-256 differs.
253+
254+
Size mismatch catches the obvious case (file truncated/grown). SHA-256
255+
catches the harder case where same-byte-length content was mutated
256+
during the migration window (e.g., a stale OG bridge writing fresh
257+
JSON of identical length). The hash was computed by OGReader during
258+
the initial manifest pass; we just compare it.
259+
"""
260+
import hashlib
261+
253262
for m in manifest:
254263
path = og_dir / m.relative_path
255264
st = path.stat()
@@ -258,6 +267,13 @@ def _verify_sources_unchanged(og_dir: Path, manifest: list[FileManifest]) -> Non
258267
f"Source file {path} changed size during migration "
259268
f"(was {m.size_bytes}, now {st.st_size}). Aborting."
260269
)
270+
current_sha = hashlib.sha256(path.read_bytes()).hexdigest()
271+
if current_sha != m.sha256:
272+
raise RuntimeError(
273+
f"Source file {path} changed content during migration "
274+
f"(SHA-256 mismatch: was {m.sha256[:12]}..., now "
275+
f"{current_sha[:12]}...). Aborting."
276+
)
261277

262278

263279
def _inspect_cmds(work_dir: Path) -> list[str]:

tests/unit/brain/engines/test_cli_heartbeat.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,52 @@ def test_nell_heartbeat_unknown_trigger_rejected(nell_persona: Path) -> None:
104104

105105
with pytest.raises(SystemExit):
106106
main(["heartbeat", "--persona", "nell", "--trigger", "frobnicate", "--provider", "fake"])
107+
108+
109+
def test_nell_heartbeat_compact_output_suppresses_not_due(
110+
nell_persona: Path, capsys: pytest.CaptureFixture[str]
111+
) -> None:
112+
"""Default output suppresses 'dream gated: not_due' and similar non-events."""
113+
from brain.cli import main
114+
115+
main(["heartbeat", "--persona", "nell", "--trigger", "open", "--provider", "fake"]) # init
116+
main(["heartbeat", "--persona", "nell", "--trigger", "close", "--provider", "fake"])
117+
out = capsys.readouterr().out
118+
119+
# The active tick should NOT print "dream gated: not_due" by default
120+
assert "dream gated: not_due" not in out
121+
# And should NOT print "research gated: reflex_won_tie" or "research gated: not_due"
122+
assert "research gated: not_due" not in out
123+
assert "research gated: reflex_won_tie" not in out
124+
# And should NOT print "interests bumped: 0"
125+
assert "interests bumped: 0" not in out
126+
# But the basic heartbeat lines should still be there
127+
assert "Heartbeat tick complete" in out
128+
assert "decayed:" in out
129+
130+
131+
def test_nell_heartbeat_verbose_shows_all_gated_reasons(
132+
nell_persona: Path, capsys: pytest.CaptureFixture[str]
133+
) -> None:
134+
"""--verbose flag re-enables non-event lines (dream gated: not_due, etc)."""
135+
from brain.cli import main
136+
137+
main(["heartbeat", "--persona", "nell", "--trigger", "open", "--provider", "fake"]) # init
138+
main(
139+
[
140+
"heartbeat",
141+
"--persona",
142+
"nell",
143+
"--trigger",
144+
"close",
145+
"--provider",
146+
"fake",
147+
"--verbose",
148+
]
149+
)
150+
out = capsys.readouterr().out
151+
152+
# Verbose mode shows dream gated even for not_due
153+
assert "dream gated:" in out
154+
# Verbose mode shows interests bumped: 0
155+
assert "interests bumped: 0" in out

0 commit comments

Comments
 (0)