meanwhile-cli 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (40) hide show
  1. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.claude-plugin/marketplace.json +1 -1
  2. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.claude-plugin/plugin.json +1 -1
  3. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/PKG-INFO +9 -2
  4. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/README.md +8 -1
  5. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/plugin.json +1 -1
  6. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/pyproject.toml +1 -1
  7. meanwhile_cli-0.1.2/src/meanwhile/__init__.py +1 -0
  8. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/app.py +12 -3
  9. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/build_prompt.md +20 -2
  10. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/game.py +21 -4
  11. meanwhile_cli-0.1.2/src/meanwhile/scores.py +75 -0
  12. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_app.py +16 -0
  13. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_game.py +12 -0
  14. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_plugin_files.py +11 -0
  15. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_scores.py +32 -0
  16. meanwhile_cli-0.1.1/src/meanwhile/__init__.py +0 -1
  17. meanwhile_cli-0.1.1/src/meanwhile/scores.py +0 -44
  18. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.agents/plugins/marketplace.json +0 -0
  19. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.gitignore +0 -0
  20. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/LICENSE +0 -0
  21. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/commands/meanwhile-build.md +0 -0
  22. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/codex-hooks.json +0 -0
  23. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/hooks.json +0 -0
  24. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/meanwhile-state.sh +0 -0
  25. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/agents.py +0 -0
  26. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/app.tcss +0 -0
  27. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/cli.py +0 -0
  28. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/hooks_cli.py +0 -0
  29. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/meanwhile-state.sh +0 -0
  30. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/pool.py +0 -0
  31. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/repo.py +0 -0
  32. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/state.py +0 -0
  33. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/strings.py +0 -0
  34. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_agents.py +0 -0
  35. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_cli.py +0 -0
  36. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_hooks_cli.py +0 -0
  37. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_pool.py +0 -0
  38. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_repo.py +0 -0
  39. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_state.py +0 -0
  40. {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_strings.py +0 -0
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "meanwhile",
10
10
  "source": ".",
11
- "version": "0.1.1",
11
+ "version": "0.1.2",
12
12
  "description": "Play a quiz about your own repository while Claude Code is working, so your context stays loaded for the diff review that follows."
13
13
  }
14
14
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "meanwhile",
3
3
  "description": "Play a quiz about your own repository while Claude Code is working, so your context stays loaded for the diff review that follows.",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "author": {
6
6
  "name": "Ismail Bayram"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meanwhile-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Play a quiz about your own repository while your coding agent works
5
5
  Project-URL: Homepage, https://github.com/ismailbayram/meanwhile
6
6
  Project-URL: Repository, https://github.com/ismailbayram/meanwhile
@@ -136,10 +136,17 @@ looks like this:
136
136
  - **Language:** `--lang` takes a language code, not a name. `tr` and `en` are
137
137
  the two the interface speaks; any other code (`de`, `fr`, …) writes the
138
138
  questions in that language and keeps the interface in English.
139
+ - **Adding questions:** run the build step again. When a pool already
140
+ exists it keeps every question and adds 30 new ones. Ask your agent for
141
+ "a fresh pool" if you want to replace it instead.
139
142
  - **Refreshing:** when the repository is about 50 commits past the pool, the
140
143
  pane shows a banner. Run the build step again and commit the new file.
141
144
  Nothing is regenerated automatically.
142
- - Scores are kept in `.meanwhile/scores.json`, which is gitignored.
145
+ - **Repeats:** within a round no question repeats. Across rounds, questions
146
+ you have not answered yet come first; once you have answered them all, it
147
+ starts over.
148
+ - Scores and the answered-question list are kept in
149
+ `.meanwhile/scores.json`, which is gitignored.
143
150
 
144
151
  ## Commands
145
152
 
@@ -121,10 +121,17 @@ looks like this:
121
121
  - **Language:** `--lang` takes a language code, not a name. `tr` and `en` are
122
122
  the two the interface speaks; any other code (`de`, `fr`, …) writes the
123
123
  questions in that language and keeps the interface in English.
124
+ - **Adding questions:** run the build step again. When a pool already
125
+ exists it keeps every question and adds 30 new ones. Ask your agent for
126
+ "a fresh pool" if you want to replace it instead.
124
127
  - **Refreshing:** when the repository is about 50 commits past the pool, the
125
128
  pane shows a banner. Run the build step again and commit the new file.
126
129
  Nothing is regenerated automatically.
127
- - Scores are kept in `.meanwhile/scores.json`, which is gitignored.
130
+ - **Repeats:** within a round no question repeats. Across rounds, questions
131
+ you have not answered yet come first; once you have answered them all, it
132
+ starts over.
133
+ - Scores and the answered-question list are kept in
134
+ `.meanwhile/scores.json`, which is gitignored.
128
135
 
129
136
  ## Commands
130
137
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "meanwhile",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "description": "Play a quiz about your own repository while Claude Code is working, so your context stays loaded for the diff review that follows.",
6
6
  "author": {
7
7
  "name": "Ismail Bayram"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meanwhile-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Play a quiz about your own repository while your coding agent works"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -52,11 +52,16 @@ class MeanwhileApp(App):
52
52
  self.poll_seconds = poll_seconds
53
53
  self.language = language
54
54
  self.agent_source = agent_source
55
- self.session = game.start_session(pool)
55
+ self.session = self._deal()
56
56
  self.phase = PLAYING
57
57
  self._last_status = "idle"
58
58
  self._answered = False
59
59
 
60
+ def _deal(self) -> game.Session:
61
+ """A fresh round, questions this player has not answered yet first."""
62
+ seen = scores.load_seen(self.repo_dir) if self.repo_dir is not None else set()
63
+ return game.start_session(self.pool, seen=seen)
64
+
60
65
  def _agent_name(self) -> str:
61
66
  return agents.label_for(self.agent_source()) or strings.text(self.language, "agent")
62
67
 
@@ -120,7 +125,7 @@ class MeanwhileApp(App):
120
125
  # card on screen rather than reshuffling under the player.
121
126
  return
122
127
  self.phase = PLAYING
123
- self.session = game.start_session(self.pool) # re-shuffled: the cards come back
128
+ self.session = self._deal() # re-shuffled: the cards come back
124
129
  self._answered = False
125
130
  self.query_one("#status").update("")
126
131
  self.query_one("#feedback").update("")
@@ -158,6 +163,10 @@ class MeanwhileApp(App):
158
163
  if not 0 <= index < len(item.choices):
159
164
  return
160
165
  self._resolve(game.answer(self.session, index))
166
+ if self.repo_dir is not None:
167
+ scores.mark_seen(
168
+ self.repo_dir, game.item_key(item), {game.item_key(i) for i in self.pool.items}
169
+ )
161
170
 
162
171
  def action_next(self) -> None:
163
172
  if self.phase != PLAYING:
@@ -166,7 +175,7 @@ class MeanwhileApp(App):
166
175
  # Out of cards. Without hooks no busy edge will ever come to deal
167
176
  # another round, so `space` does it: bank this one, start afresh.
168
177
  self._record()
169
- self.session = game.start_session(self.pool)
178
+ self.session = self._deal()
170
179
  self._answered = False
171
180
  self.query_one("#feedback").update("")
172
181
  self._render()
@@ -20,9 +20,26 @@ pool's `language` field records exactly that code. `tr` and `en` are the two
20
20
  the playing interface itself speaks; any other code is still fine — the cards
21
21
  get written in that language and the interface stays English.
22
22
 
23
+ ## Extending an existing pool
24
+
25
+ If `.meanwhile/pool.json` already exists, extend it rather than replacing it,
26
+ unless the user asked for a fresh pool:
27
+
28
+ - Keep every existing item exactly as it is — players' progress is keyed on
29
+ the question text, so a reworded question counts as a new one. Only drop
30
+ an item whose `source` file no longer exists.
31
+ - Add 30 new items. None may ask what an existing item already asks; read
32
+ the existing questions first, and look for parts of the repository they
33
+ do not cover yet.
34
+ - Keep the pool's own `language` — it wins over the language named above,
35
+ which is only a default — unless the user asked for another one.
36
+ - Set `builtAt` and `headSha` to today and the current commit.
37
+
23
38
  ## What to write
24
39
 
25
- Write `.meanwhile/pool.json` with 30-50 `quiz` items, using exactly this shape:
40
+ For a new pool, write `.meanwhile/pool.json` with 30-50 `quiz` items; when
41
+ extending, keep the same envelope and append to `items`. Use exactly this
42
+ shape:
26
43
 
27
44
  {
28
45
  "builtAt": "<today, YYYY-MM-DD>",
@@ -60,7 +77,8 @@ Rules:
60
77
  2. Run `uvx meanwhile-cli validate --repo .` (during development, from a
61
78
  checkout of this plugin: `uv run meanwhile validate --repo .`) and fix
62
79
  anything it reports.
63
- 3. Tell the user the item count and that `.meanwhile/pool.json` is ready to
80
+ 3. Tell the user the item count — when extending, how many were added and
81
+ how many there are now — and that `.meanwhile/pool.json` is ready to
64
82
  commit so their team shares the pool.
65
83
  4. Tell the user how to play, in {language} — building the pool starts
66
84
  nothing, and no other step says how. Give them the command itself, in a
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import random
6
7
  from dataclasses import dataclass, field
7
8
 
@@ -31,10 +32,26 @@ class Session:
31
32
  _answered_index: int = field(default=-1, init=False, repr=False, compare=False)
32
33
 
33
34
 
34
- def start_session(pool: Pool, rng: random.Random | None = None) -> Session:
35
- items = list(pool.items)
36
- (rng or random.Random()).shuffle(items)
37
- return Session(items=items)
35
+ def item_key(item) -> str:
36
+ """A stable name for a question, for remembering it was answered.
37
+
38
+ The question text, not the item's position: a rebuilt or extended pool
39
+ reorders items, and a question that survives a rebuild word for word is
40
+ the same question to the player.
41
+ """
42
+ return hashlib.sha256(item.q.encode("utf-8")).hexdigest()[:16]
43
+
44
+
45
+ def start_session(
46
+ pool: Pool, rng: random.Random | None = None, seen: frozenset[str] | set[str] = frozenset()
47
+ ) -> Session:
48
+ """A shuffled round over the whole pool, questions not in `seen` first."""
49
+ rng = rng or random.Random()
50
+ fresh = [item for item in pool.items if item_key(item) not in seen]
51
+ repeats = [item for item in pool.items if item_key(item) in seen]
52
+ rng.shuffle(fresh)
53
+ rng.shuffle(repeats)
54
+ return Session(items=fresh + repeats)
38
55
 
39
56
 
40
57
  def finished(session: Session) -> bool:
@@ -0,0 +1,75 @@
1
+ """Per-developer running totals, and which questions this developer has
2
+ answered. Gitignored: both are local, never shared."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from .game import Session
11
+
12
+ SCORES_RELPATH = ".meanwhile/scores.json"
13
+ _ZERO = {"answered": 0, "correct": 0, "best_streak": 0}
14
+
15
+
16
+ def _path(repo_dir: str | Path) -> Path:
17
+ return Path(repo_dir) / SCORES_RELPATH
18
+
19
+
20
+ def _read(repo_dir: str | Path) -> dict:
21
+ try:
22
+ data = json.loads(_path(repo_dir).read_text(encoding="utf-8"))
23
+ except (OSError, json.JSONDecodeError):
24
+ return {}
25
+ return data if isinstance(data, dict) else {}
26
+
27
+
28
+ def _write(repo_dir: str | Path, totals: dict, seen: set[str]) -> None:
29
+ path = _path(repo_dir)
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
32
+ tmp.write_text(json.dumps({**totals, "seen": sorted(seen)}, indent=2), encoding="utf-8")
33
+ tmp.replace(path)
34
+
35
+
36
+ def load_scores(repo_dir: str | Path) -> dict:
37
+ data = _read(repo_dir)
38
+ out = dict(_ZERO)
39
+ for key in out:
40
+ value = data.get(key, 0)
41
+ out[key] = value if isinstance(value, int) and value >= 0 else 0
42
+ return out
43
+
44
+
45
+ def record_session(repo_dir: str | Path, session: Session) -> dict:
46
+ totals = load_scores(repo_dir)
47
+ totals["answered"] += session.answered
48
+ totals["correct"] += session.correct
49
+ totals["best_streak"] = max(totals["best_streak"], session.best_streak)
50
+ _write(repo_dir, totals, load_seen(repo_dir))
51
+ return totals
52
+
53
+
54
+ def load_seen(repo_dir: str | Path) -> set[str]:
55
+ """The keys (see `game.item_key`) of the questions answered so far."""
56
+ seen = _read(repo_dir).get("seen", [])
57
+ if not isinstance(seen, list):
58
+ return set()
59
+ return {key for key in seen if isinstance(key, str)}
60
+
61
+
62
+ def mark_seen(repo_dir: str | Path, key: str, pool_keys: set[str]) -> set[str]:
63
+ """Remember one answered question, and return what is remembered now.
64
+
65
+ Written on every answer rather than at the end of a round: a pane closed
66
+ with its terminal never reaches the end of a round. Once every question in
67
+ the pool has been answered the list starts over, so the next rounds go
68
+ back to a plain shuffle instead of ordering by a list that covers it all.
69
+ Keys of questions no longer in the pool are dropped on the way.
70
+ """
71
+ seen = (load_seen(repo_dir) & pool_keys) | {key}
72
+ if pool_keys <= seen:
73
+ seen = set()
74
+ _write(repo_dir, load_scores(repo_dir), seen)
75
+ return seen
@@ -325,6 +325,22 @@ async def test_space_on_an_exhausted_pool_deals_a_fresh_round(tmp_path):
325
325
  assert saved["answered"] == 1 # the finished round was banked, and only once
326
326
 
327
327
 
328
+ async def test_an_answered_question_is_dealt_last_next_time(tmp_path):
329
+ """Remembered across panes, not just within one: a second pane opened on
330
+ the same repository leads with what the first one never got to."""
331
+ status = {"value": "idle"}
332
+ first = lifecycle_app([QUIZ, NARROW], status, tmp_path)
333
+ async with first.run_test() as pilot:
334
+ await pilot.pause()
335
+ answered = game.current(first.session)
336
+ first.action_choose(1)
337
+ await pilot.pause()
338
+
339
+ for _ in range(10): # the shuffle must never put it first again
340
+ second = lifecycle_app([QUIZ, NARROW], status, tmp_path)
341
+ assert second.session.items[-1] is answered
342
+
343
+
328
344
  async def test_quitting_mid_session_banks_it(tmp_path):
329
345
  """Without hooks there is never an idle edge, so `q` is where a session ends."""
330
346
  status = {"value": "idle"}
@@ -84,3 +84,15 @@ def test_answering_a_finished_session_is_rejected():
84
84
  assert game.finished(session) is True
85
85
  with pytest.raises(game.GameError):
86
86
  game.answer(session, 1)
87
+
88
+
89
+ def test_unseen_questions_come_before_seen_ones():
90
+ seen = {game.item_key(QUIZ)}
91
+ for seed in range(20): # whatever the shuffle, the seen one is last
92
+ session = game.start_session(make_pool([QUIZ, QUIZ_2]), rng=random.Random(seed), seen=seen)
93
+ assert session.items == [QUIZ_2, QUIZ]
94
+
95
+
96
+ def test_the_item_key_follows_the_question_text_not_the_position():
97
+ assert game.item_key(QUIZ) == game.item_key(QuizItem(**{**QUIZ.__dict__, "source": "moved.py"}))
98
+ assert game.item_key(QUIZ) != game.item_key(QUIZ_2)
@@ -102,6 +102,17 @@ def test_the_build_prompt_ends_by_telling_the_user_how_to_play():
102
102
  assert "same directory" in finish
103
103
 
104
104
 
105
+ def test_the_build_prompt_extends_an_existing_pool_instead_of_replacing_it():
106
+ """Running the build again is how a player gets more questions. Replacing
107
+ the pool would throw away the questions the team already has, and with
108
+ them every player's record of which ones they answered."""
109
+ text = PROMPT_SOURCE.read_text(encoding="utf-8")
110
+ extend = text.split("## Extending an existing pool")[1].split("##")[0]
111
+ assert "Keep every existing item exactly as it is" in extend
112
+ assert "fresh pool" in extend
113
+ assert "`language`" in extend
114
+
115
+
105
116
  def test_the_slash_command_delegates_rather_than_duplicating():
106
117
  body = BUILD_COMMAND.read_text(encoding="utf-8")
107
118
  assert "build_prompt.md" in body
@@ -30,3 +30,35 @@ def test_an_unplayed_session_writes_nothing_new(tmp_path):
30
30
  scores.record_session(tmp_path, Session(items=[], answered=2, correct=1, best_streak=1))
31
31
  unchanged = scores.record_session(tmp_path, Session(items=[]))
32
32
  assert unchanged == {"answered": 2, "correct": 1, "best_streak": 1}
33
+
34
+
35
+ def test_seen_questions_are_remembered_across_loads(tmp_path):
36
+ scores.mark_seen(tmp_path, "a", {"a", "b", "c"})
37
+ scores.mark_seen(tmp_path, "b", {"a", "b", "c"})
38
+ assert scores.load_seen(tmp_path) == {"a", "b"}
39
+
40
+
41
+ def test_seen_starts_over_once_the_whole_pool_is_answered(tmp_path):
42
+ scores.mark_seen(tmp_path, "a", {"a", "b"})
43
+ assert scores.mark_seen(tmp_path, "b", {"a", "b"}) == set()
44
+ assert scores.load_seen(tmp_path) == set()
45
+
46
+
47
+ def test_seen_drops_questions_that_left_the_pool(tmp_path):
48
+ scores.mark_seen(tmp_path, "gone", {"gone", "x", "y"})
49
+ assert scores.mark_seen(tmp_path, "x", {"x", "y"}) == {"x"}
50
+
51
+
52
+ def test_totals_and_seen_do_not_overwrite_each_other(tmp_path):
53
+ scores.mark_seen(tmp_path, "a", {"a", "b"})
54
+ scores.record_session(tmp_path, Session(items=[], answered=1, correct=1, best_streak=1))
55
+ assert scores.load_seen(tmp_path) == {"a"}
56
+ scores.mark_seen(tmp_path, "b", {"a", "b", "c"})
57
+ assert scores.load_scores(tmp_path) == {"answered": 1, "correct": 1, "best_streak": 1}
58
+
59
+
60
+ def test_a_malformed_seen_list_reads_as_empty(tmp_path):
61
+ (tmp_path / ".meanwhile").mkdir()
62
+ (tmp_path / scores.SCORES_RELPATH).write_text('{"answered": 1, "seen": "nope"}')
63
+ assert scores.load_seen(tmp_path) == set()
64
+ assert scores.load_scores(tmp_path)["answered"] == 1
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
@@ -1,44 +0,0 @@
1
- """Per-developer running totals. Gitignored: scores are local, never shared."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- from pathlib import Path
8
-
9
- from .game import Session
10
-
11
- SCORES_RELPATH = ".meanwhile/scores.json"
12
- _ZERO = {"answered": 0, "correct": 0, "best_streak": 0}
13
-
14
-
15
- def _path(repo_dir: str | Path) -> Path:
16
- return Path(repo_dir) / SCORES_RELPATH
17
-
18
-
19
- def load_scores(repo_dir: str | Path) -> dict:
20
- try:
21
- data = json.loads(_path(repo_dir).read_text(encoding="utf-8"))
22
- except (OSError, json.JSONDecodeError):
23
- return dict(_ZERO)
24
- if not isinstance(data, dict):
25
- return dict(_ZERO)
26
- out = dict(_ZERO)
27
- for key in out:
28
- value = data.get(key, 0)
29
- out[key] = value if isinstance(value, int) and value >= 0 else 0
30
- return out
31
-
32
-
33
- def record_session(repo_dir: str | Path, session: Session) -> dict:
34
- totals = load_scores(repo_dir)
35
- totals["answered"] += session.answered
36
- totals["correct"] += session.correct
37
- totals["best_streak"] = max(totals["best_streak"], session.best_streak)
38
-
39
- path = _path(repo_dir)
40
- path.parent.mkdir(parents=True, exist_ok=True)
41
- tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
42
- tmp.write_text(json.dumps(totals, indent=2), encoding="utf-8")
43
- tmp.replace(path)
44
- return totals
File without changes
File without changes