@rm0nroe/coach-claw 1.0.6
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/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- package/skills/config/SKILL.md +149 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import analyze
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _session(
|
|
9
|
+
*,
|
|
10
|
+
session_hash: str = "abcd1234",
|
|
11
|
+
edits: int = 0,
|
|
12
|
+
writes: int = 0,
|
|
13
|
+
tests: int = 0,
|
|
14
|
+
commits: int = 0,
|
|
15
|
+
reads: int = 0,
|
|
16
|
+
grep: int = 0,
|
|
17
|
+
glob: int = 0,
|
|
18
|
+
skills: int = 0,
|
|
19
|
+
rm_rf: int = 0,
|
|
20
|
+
plan_before_edit: bool = False,
|
|
21
|
+
first_plan_idx=None,
|
|
22
|
+
first_edit_idx=None,
|
|
23
|
+
last_edit_idx=None,
|
|
24
|
+
first_test_idx=None,
|
|
25
|
+
last_test_idx=None,
|
|
26
|
+
first_commit_idx=None,
|
|
27
|
+
last_commit_idx=None,
|
|
28
|
+
first_read_idx=None,
|
|
29
|
+
first_search_idx=None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"project": "-Users-r-Desktop-dev-coach",
|
|
33
|
+
"session_hash": session_hash,
|
|
34
|
+
"tool_counts": {},
|
|
35
|
+
"user_turns": 1,
|
|
36
|
+
"assistant_turns": 5,
|
|
37
|
+
"first_ts": None,
|
|
38
|
+
"last_ts": None,
|
|
39
|
+
"first_user_ts": None,
|
|
40
|
+
"first_edit_ts": None,
|
|
41
|
+
"first_plan_ts": None,
|
|
42
|
+
"task_create_count": 0,
|
|
43
|
+
"exit_plan_count": 0,
|
|
44
|
+
"edit_count": edits,
|
|
45
|
+
"write_count": writes,
|
|
46
|
+
"bash_count": tests + commits,
|
|
47
|
+
"commit_count": commits,
|
|
48
|
+
"test_run_count": tests,
|
|
49
|
+
"has_any_commit": commits > 0,
|
|
50
|
+
"has_any_test_run": tests > 0,
|
|
51
|
+
"bash_rm_rf_count": rm_rf,
|
|
52
|
+
"read_count": reads,
|
|
53
|
+
"grep_count": grep,
|
|
54
|
+
"glob_count": glob,
|
|
55
|
+
"agent_count": 0,
|
|
56
|
+
"skill_count": skills,
|
|
57
|
+
"skills_invoked": {},
|
|
58
|
+
"sec_first_user_to_first_edit": None,
|
|
59
|
+
"plan_before_edit": plan_before_edit,
|
|
60
|
+
"first_plan_idx": first_plan_idx,
|
|
61
|
+
"first_edit_idx": first_edit_idx,
|
|
62
|
+
"last_edit_idx": last_edit_idx,
|
|
63
|
+
"first_test_idx": first_test_idx,
|
|
64
|
+
"last_test_idx": last_test_idx,
|
|
65
|
+
"first_commit_idx": first_commit_idx,
|
|
66
|
+
"last_commit_idx": last_commit_idx,
|
|
67
|
+
"first_read_idx": first_read_idx,
|
|
68
|
+
"first_search_idx": first_search_idx,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strong_session(i: int) -> dict:
|
|
73
|
+
return _session(
|
|
74
|
+
session_hash=f"good{i}",
|
|
75
|
+
edits=3,
|
|
76
|
+
tests=1,
|
|
77
|
+
commits=1,
|
|
78
|
+
reads=2,
|
|
79
|
+
grep=1,
|
|
80
|
+
skills=1,
|
|
81
|
+
plan_before_edit=True,
|
|
82
|
+
first_plan_idx=0,
|
|
83
|
+
first_search_idx=1,
|
|
84
|
+
first_read_idx=2,
|
|
85
|
+
first_edit_idx=3,
|
|
86
|
+
last_edit_idx=3,
|
|
87
|
+
first_test_idx=4,
|
|
88
|
+
last_test_idx=4,
|
|
89
|
+
first_commit_idx=5,
|
|
90
|
+
last_commit_idx=5,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_aggregate_emits_positive_strength_detections():
|
|
95
|
+
detections, _summary = analyze.aggregate([_strong_session(i) for i in range(3)])
|
|
96
|
+
|
|
97
|
+
by_id = {d["id"]: d for d in detections}
|
|
98
|
+
expected = {
|
|
99
|
+
"tests-after-edits",
|
|
100
|
+
"plans-before-edits",
|
|
101
|
+
"commits-gated-by-tests",
|
|
102
|
+
"search-before-reading",
|
|
103
|
+
"small-batch-verify",
|
|
104
|
+
"safe-git-hygiene",
|
|
105
|
+
"effective-skill-use",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
assert expected <= set(by_id)
|
|
109
|
+
assert all(by_id[eid]["direction"] == "positive" for eid in expected)
|
|
110
|
+
assert by_id["tests-after-edits"]["reward_hint"]["action"] == "test_run"
|
|
111
|
+
assert by_id["effective-skill-use"]["reward_hint"]["action"] == "skill_invoke"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_positive_strengths_require_repeated_majority_evidence():
|
|
115
|
+
sessions = [
|
|
116
|
+
_session(
|
|
117
|
+
session_hash=f"good{i}",
|
|
118
|
+
edits=2,
|
|
119
|
+
tests=1,
|
|
120
|
+
first_edit_idx=1,
|
|
121
|
+
last_edit_idx=1,
|
|
122
|
+
first_test_idx=2,
|
|
123
|
+
last_test_idx=2,
|
|
124
|
+
)
|
|
125
|
+
for i in range(2)
|
|
126
|
+
]
|
|
127
|
+
sessions += [
|
|
128
|
+
_session(session_hash=f"bad{i}", edits=2, first_edit_idx=1, last_edit_idx=1)
|
|
129
|
+
for i in range(3)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
detections, _summary = analyze.aggregate(sessions)
|
|
133
|
+
|
|
134
|
+
assert "tests-after-edits" not in {d["id"] for d in detections}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_analyze_session_records_tool_order_for_strength_detectors(tmp_path):
|
|
138
|
+
transcript = tmp_path / "session.jsonl"
|
|
139
|
+
record = {
|
|
140
|
+
"type": "assistant",
|
|
141
|
+
"message": {
|
|
142
|
+
"role": "assistant",
|
|
143
|
+
"content": [
|
|
144
|
+
{"type": "tool_use", "name": "Plan", "input": {}},
|
|
145
|
+
{"type": "tool_use", "name": "Grep", "input": {"pattern": "x"}},
|
|
146
|
+
{"type": "tool_use", "name": "Read", "input": {"file_path": "a.py"}},
|
|
147
|
+
{"type": "tool_use", "name": "Edit", "input": {"file_path": "a.py"}},
|
|
148
|
+
{"type": "tool_use", "name": "Bash", "input": {"command": "pytest"}},
|
|
149
|
+
{"type": "tool_use", "name": "Bash", "input": {"command": "git commit -m ok"}},
|
|
150
|
+
{"type": "tool_use", "name": "Skill", "input": {"skill": "design"}},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
transcript.write_text(json.dumps(record) + "\n")
|
|
155
|
+
|
|
156
|
+
sig = analyze.analyze_session(transcript)
|
|
157
|
+
|
|
158
|
+
assert sig is not None
|
|
159
|
+
assert sig["first_plan_idx"] == 0
|
|
160
|
+
assert sig["first_search_idx"] == 1
|
|
161
|
+
assert sig["first_read_idx"] == 2
|
|
162
|
+
assert sig["first_edit_idx"] == 3
|
|
163
|
+
assert sig["last_test_idx"] == 4
|
|
164
|
+
assert sig["last_commit_idx"] == 5
|
|
165
|
+
assert sig["plan_before_edit"] is True
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""bank.py — atomic write unlinks tmp on os.replace failure.
|
|
2
|
+
|
|
3
|
+
Pinned regression for the unlink-on-failure path in
|
|
4
|
+
`bank._atomic_write_yaml`. Before the fix, a rare cross-device or
|
|
5
|
+
filesystem-quirk failure of `os.replace` would leak a stale
|
|
6
|
+
`.profile.<rand>.tmp` into `~/.claude/coach/`, eventually accumulating
|
|
7
|
+
visible junk. This test forces the failure path and asserts the temp
|
|
8
|
+
is cleaned up. Mirrors the cleanup pattern in
|
|
9
|
+
`merge.atomic_write_yaml` and `marker_io._atomic_write_under_lock`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
import bank
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_atomic_write_unlinks_temp_on_replace_failure(tmp_path, monkeypatch):
|
|
22
|
+
"""If os.replace raises, _atomic_write_yaml must unlink the .tmp and re-raise."""
|
|
23
|
+
target = tmp_path / "profile.yaml"
|
|
24
|
+
target.write_text("schema_version: 1\nentries: []\n")
|
|
25
|
+
|
|
26
|
+
captured: list[str] = []
|
|
27
|
+
|
|
28
|
+
def failing_replace(src, _dst):
|
|
29
|
+
captured.append(src)
|
|
30
|
+
raise OSError("simulated cross-device rename failure")
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(bank.os, "replace", failing_replace)
|
|
33
|
+
|
|
34
|
+
with pytest.raises(OSError, match="simulated cross-device rename failure"):
|
|
35
|
+
bank._atomic_write_yaml(target, {"schema_version": 1, "entries": []})
|
|
36
|
+
|
|
37
|
+
# The exception must propagate (caller decides what to do).
|
|
38
|
+
assert captured, "os.replace was never called — test setup wrong"
|
|
39
|
+
leftover = captured[0]
|
|
40
|
+
assert not Path(leftover).exists(), (
|
|
41
|
+
f"tmp file {leftover} was not unlinked after os.replace failure — "
|
|
42
|
+
"matches the bug class merge.atomic_write_yaml fixed; ensure "
|
|
43
|
+
"bank._atomic_write_yaml mirrors that pattern."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# No stray .profile.*.tmp in the directory either.
|
|
47
|
+
stragglers = sorted(p.name for p in tmp_path.glob(".profile.*.tmp"))
|
|
48
|
+
assert stragglers == [], f"leftover tmp files: {stragglers}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_atomic_write_happy_path_still_works(tmp_path):
|
|
52
|
+
"""Round-trip on the happy path — confirm the unlink-on-failure
|
|
53
|
+
addition didn't break the normal write."""
|
|
54
|
+
target = tmp_path / "profile.yaml"
|
|
55
|
+
bank._atomic_write_yaml(target, {"schema_version": 1, "entries": []})
|
|
56
|
+
assert target.exists()
|
|
57
|
+
content = target.read_text()
|
|
58
|
+
assert "schema_version: 1" in content
|
|
59
|
+
assert "entries: []" in content
|
|
60
|
+
# No stray tmp files.
|
|
61
|
+
assert sorted(p.name for p in tmp_path.glob(".profile.*.tmp")) == []
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""bank.py concurrency — bounded-wait lock acquire.
|
|
2
|
+
|
|
3
|
+
Regression guard for the race where /coach-insights holds the profile lock at
|
|
4
|
+
the moment SessionStart spawns bank.py. Before this fix, bank.py would
|
|
5
|
+
non-blocking-fail immediately and silently drop the session's XP banking
|
|
6
|
+
until the NEXT SessionStart. Now bank.py waits up to LOCK_WAIT_SECONDS
|
|
7
|
+
for the lock to free up.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
import bank
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hold_lock_for(path: Path, seconds: float, released: threading.Event) -> None:
|
|
25
|
+
"""Acquire the lock, hold it for `seconds`, release, signal."""
|
|
26
|
+
with path.open("a+") as fh:
|
|
27
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
28
|
+
time.sleep(seconds)
|
|
29
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
30
|
+
released.set()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_lock_acquired_immediately_when_free(tmp_path, monkeypatch):
|
|
34
|
+
"""When nothing holds the lock, bank.py acquires essentially instantly."""
|
|
35
|
+
monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 5)
|
|
36
|
+
monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
|
|
37
|
+
lockfile_path = tmp_path / ".lock"
|
|
38
|
+
|
|
39
|
+
start = time.monotonic()
|
|
40
|
+
with lockfile_path.open("a+") as fh:
|
|
41
|
+
assert bank._acquire_lock_bounded(fh) is True
|
|
42
|
+
elapsed = time.monotonic() - start
|
|
43
|
+
assert elapsed < 0.2 # essentially immediate
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_lock_waits_then_succeeds_when_released(tmp_path, monkeypatch):
|
|
47
|
+
"""If the lock frees up before the bounded-wait timeout, bank.py
|
|
48
|
+
acquires it and proceeds — the case that fixes the race."""
|
|
49
|
+
monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 3)
|
|
50
|
+
monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
|
|
51
|
+
lockfile_path = tmp_path / ".lock"
|
|
52
|
+
released = threading.Event()
|
|
53
|
+
|
|
54
|
+
# Hold the lock for 0.4s from a background thread.
|
|
55
|
+
holder = threading.Thread(
|
|
56
|
+
target=_hold_lock_for, args=(lockfile_path, 0.4, released), daemon=True,
|
|
57
|
+
)
|
|
58
|
+
holder.start()
|
|
59
|
+
time.sleep(0.05) # ensure holder grabbed the lock first
|
|
60
|
+
|
|
61
|
+
start = time.monotonic()
|
|
62
|
+
with lockfile_path.open("a+") as fh:
|
|
63
|
+
acquired = bank._acquire_lock_bounded(fh)
|
|
64
|
+
elapsed = time.monotonic() - start
|
|
65
|
+
|
|
66
|
+
holder.join(timeout=2)
|
|
67
|
+
assert acquired is True
|
|
68
|
+
assert elapsed >= 0.3 # did wait for the holder
|
|
69
|
+
assert elapsed < 2.0 # but came back well before the timeout
|
|
70
|
+
assert released.is_set()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_lock_gives_up_after_timeout(tmp_path, monkeypatch):
|
|
74
|
+
"""If the holder never releases, bank.py returns False after the
|
|
75
|
+
bounded-wait deadline passes — safe fallback so a stuck lock never
|
|
76
|
+
hangs the process indefinitely."""
|
|
77
|
+
monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 0.3)
|
|
78
|
+
monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
|
|
79
|
+
lockfile_path = tmp_path / ".lock"
|
|
80
|
+
released = threading.Event()
|
|
81
|
+
|
|
82
|
+
# Hold the lock longer than LOCK_WAIT_SECONDS.
|
|
83
|
+
holder = threading.Thread(
|
|
84
|
+
target=_hold_lock_for, args=(lockfile_path, 1.0, released), daemon=True,
|
|
85
|
+
)
|
|
86
|
+
holder.start()
|
|
87
|
+
time.sleep(0.05)
|
|
88
|
+
|
|
89
|
+
start = time.monotonic()
|
|
90
|
+
with lockfile_path.open("a+") as fh:
|
|
91
|
+
acquired = bank._acquire_lock_bounded(fh)
|
|
92
|
+
elapsed = time.monotonic() - start
|
|
93
|
+
|
|
94
|
+
holder.join(timeout=3)
|
|
95
|
+
assert acquired is False
|
|
96
|
+
assert 0.2 <= elapsed < 0.8 # gave up around the timeout, not much later
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_bank_writes_session_banked_xp_not_milestones(tmp_path, monkeypatch):
|
|
100
|
+
profile_path = tmp_path / "profile.yaml"
|
|
101
|
+
ledger_path = tmp_path / "banked_sessions.json"
|
|
102
|
+
projects = tmp_path / "projects"
|
|
103
|
+
projects.mkdir()
|
|
104
|
+
transcript = projects / "session-1.jsonl"
|
|
105
|
+
transcript.write_text("{}\n")
|
|
106
|
+
old = time.time() - 3600
|
|
107
|
+
os.utime(transcript, (old, old))
|
|
108
|
+
profile_path.write_text(yaml.safe_dump({
|
|
109
|
+
"entries": [],
|
|
110
|
+
"graduated": [],
|
|
111
|
+
"milestone_xp": 3,
|
|
112
|
+
}))
|
|
113
|
+
monkeypatch.setattr(bank, "PROFILE", profile_path)
|
|
114
|
+
monkeypatch.setattr(bank, "LEDGER", ledger_path)
|
|
115
|
+
monkeypatch.setattr(bank, "PROJECTS", projects)
|
|
116
|
+
monkeypatch.setattr(bank, "_score_transcript", lambda _path, _profile: 12)
|
|
117
|
+
|
|
118
|
+
summary = bank._bank()
|
|
119
|
+
|
|
120
|
+
written = yaml.safe_load(profile_path.read_text())
|
|
121
|
+
ledger = json.loads(ledger_path.read_text())
|
|
122
|
+
assert summary["xp_added"] == 1
|
|
123
|
+
assert written["session_banked_xp"] == 1
|
|
124
|
+
assert written["banked_session_xp"] == 1
|
|
125
|
+
assert written["milestone_xp"] == 3
|
|
126
|
+
assert ledger["session-1"]["banked"] == 1
|