meanwhile-cli 0.1.0__py3-none-any.whl
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.
- meanwhile/__init__.py +1 -0
- meanwhile/agents.py +314 -0
- meanwhile/app.py +187 -0
- meanwhile/app.tcss +6 -0
- meanwhile/build_prompt.md +73 -0
- meanwhile/cli.py +223 -0
- meanwhile/game.py +70 -0
- meanwhile/hooks_cli.py +308 -0
- meanwhile/meanwhile-state.sh +103 -0
- meanwhile/pool.py +101 -0
- meanwhile/repo.py +30 -0
- meanwhile/scores.py +44 -0
- meanwhile/state.py +66 -0
- meanwhile/strings.py +42 -0
- meanwhile_cli-0.1.0.dist-info/METADATA +265 -0
- meanwhile_cli-0.1.0.dist-info/RECORD +19 -0
- meanwhile_cli-0.1.0.dist-info/WHEEL +4 -0
- meanwhile_cli-0.1.0.dist-info/entry_points.txt +3 -0
- meanwhile_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
meanwhile/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
meanwhile/agents.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Which agents meanwhile can hook into, and how each one registers hooks.
|
|
2
|
+
|
|
3
|
+
The busy/idle signal is not Claude-specific: Codex, Cursor and OpenCode expose
|
|
4
|
+
the same two moments under their own names, and the same bash script serves
|
|
5
|
+
all four. Only the registration file and its shape differ, so that is all this
|
|
6
|
+
module knows about.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
#: How our entries are recognised inside somebody else's config file. Matching
|
|
18
|
+
#: on the script name rather than on an exact command string is what lets
|
|
19
|
+
#: `merge` replace an entry that points at an older copy of the script.
|
|
20
|
+
MARKER = "meanwhile-state.sh"
|
|
21
|
+
|
|
22
|
+
SLUG_PATTERN = re.compile(r"^[a-z][a-z-]{0,15}\Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Agent:
|
|
27
|
+
slug: str
|
|
28
|
+
label: str
|
|
29
|
+
busy_event: str
|
|
30
|
+
idle_event: str
|
|
31
|
+
#: Which config shape this agent's file uses: "claude" (a "hooks" map of
|
|
32
|
+
#: event -> [{"hooks": [{"type": "command", ...}]}], shared with Codex) or
|
|
33
|
+
#: "cursor" (a versioned "hooks" map of event -> [{"command": ...}]), or
|
|
34
|
+
#: "opencode" (no config entry at all: a whole JavaScript file of ours,
|
|
35
|
+
#: see `plugin_source`).
|
|
36
|
+
shape: str
|
|
37
|
+
project_relpath: str
|
|
38
|
+
user_relpath: str
|
|
39
|
+
supports_user_scope: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
AGENTS: tuple[Agent, ...] = (
|
|
43
|
+
Agent(
|
|
44
|
+
slug="claude",
|
|
45
|
+
label="Claude Code",
|
|
46
|
+
busy_event="UserPromptSubmit",
|
|
47
|
+
idle_event="Stop",
|
|
48
|
+
shape="claude",
|
|
49
|
+
# Not hooks.json: a standalone (non-plugin) Claude Code registration
|
|
50
|
+
# lives in a settings file alongside unrelated settings, which is why
|
|
51
|
+
# the merge only ever touches document["hooks"].
|
|
52
|
+
#
|
|
53
|
+
# settings.local.json, not settings.json, for the project scope:
|
|
54
|
+
# settings.json is the committed file, and the command we register
|
|
55
|
+
# carries an absolute path to this machine's copy of the script. A
|
|
56
|
+
# committed registration would make every teammate's Claude Code run a
|
|
57
|
+
# script that does not exist for them, on every prompt submit and every
|
|
58
|
+
# Stop. settings.local.json is the gitignored per-user sibling.
|
|
59
|
+
# Under $HOME there is no such split, and settings.json is personal
|
|
60
|
+
# already.
|
|
61
|
+
project_relpath=".claude/settings.local.json",
|
|
62
|
+
user_relpath=".claude/settings.json",
|
|
63
|
+
supports_user_scope=True,
|
|
64
|
+
),
|
|
65
|
+
Agent(
|
|
66
|
+
slug="codex",
|
|
67
|
+
label="Codex",
|
|
68
|
+
busy_event="UserPromptSubmit",
|
|
69
|
+
idle_event="Stop",
|
|
70
|
+
shape="claude",
|
|
71
|
+
project_relpath=".codex/hooks.json",
|
|
72
|
+
user_relpath=".codex/hooks.json",
|
|
73
|
+
supports_user_scope=True,
|
|
74
|
+
),
|
|
75
|
+
Agent(
|
|
76
|
+
slug="cursor",
|
|
77
|
+
label="Cursor",
|
|
78
|
+
busy_event="beforeSubmitPrompt",
|
|
79
|
+
idle_event="stop",
|
|
80
|
+
shape="cursor",
|
|
81
|
+
project_relpath=".cursor/hooks.json",
|
|
82
|
+
user_relpath=".cursor/hooks.json",
|
|
83
|
+
# Cursor runs user-level hooks from ~/.cursor/ rather than from the
|
|
84
|
+
# project root, and the script keys the state file on its working
|
|
85
|
+
# directory — so a user-level registration would key the wrong
|
|
86
|
+
# directory and the pane would simply never wake.
|
|
87
|
+
supports_user_scope=False,
|
|
88
|
+
),
|
|
89
|
+
Agent(
|
|
90
|
+
slug="opencode",
|
|
91
|
+
label="OpenCode",
|
|
92
|
+
# One event for both moments: OpenCode has no shell hooks, only
|
|
93
|
+
# plugins, and a plugin hears `session.status` with the new status
|
|
94
|
+
# inside it.
|
|
95
|
+
busy_event="session.status",
|
|
96
|
+
idle_event="session.status",
|
|
97
|
+
shape="opencode",
|
|
98
|
+
project_relpath=".opencode/plugins/meanwhile.js",
|
|
99
|
+
user_relpath=".config/opencode/plugins/meanwhile.js",
|
|
100
|
+
# Unlike Cursor, a global plugin is still handed the project's own
|
|
101
|
+
# directory, so a user-level registration keys the right state file.
|
|
102
|
+
supports_user_scope=True,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def agent_by_slug(slug: str) -> Agent | None:
|
|
108
|
+
for agent in AGENTS:
|
|
109
|
+
if agent.slug == slug:
|
|
110
|
+
return agent
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def label_for(slug: str | None) -> str | None:
|
|
115
|
+
agent = agent_by_slug(slug) if slug else None
|
|
116
|
+
return agent.label if agent else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def command_for(script: str, agent: Agent, status: str) -> str:
|
|
120
|
+
"""The shell command a registration runs.
|
|
121
|
+
|
|
122
|
+
`shlex.quote`, not a bare pair of double quotes: the path carries the
|
|
123
|
+
user's home directory, and a `"`, `$` or backtick in it would close the
|
|
124
|
+
quoting or be expanded by the shell — a hook pointing at a path that does
|
|
125
|
+
not exist, which is exactly the silent failure this module exists to avoid.
|
|
126
|
+
"""
|
|
127
|
+
return f"{shlex.quote(script)} {status} {agent.slug}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_OPENCODE_PLUGIN = """\
|
|
131
|
+
// Written by `meanwhile hooks install --agent __AGENT__`, and removed again by
|
|
132
|
+
// `meanwhile hooks uninstall --agent __AGENT__`. Edits here are overwritten.
|
|
133
|
+
const SCRIPT = __SCRIPT__
|
|
134
|
+
|
|
135
|
+
export const Meanwhile = async ({ $, client, directory }) => {
|
|
136
|
+
// A subagent runs in a session of its own, which goes busy and idle while
|
|
137
|
+
// the session that spawned it is still working. Only a session without a
|
|
138
|
+
// parent is the one the user is waiting on.
|
|
139
|
+
const children = new Map()
|
|
140
|
+
const isChild = async (id) => {
|
|
141
|
+
if (!children.has(id)) {
|
|
142
|
+
let child = false
|
|
143
|
+
try {
|
|
144
|
+
const found = await client.session.get({ path: { id } })
|
|
145
|
+
child = Boolean(found?.data?.parentID)
|
|
146
|
+
} catch {}
|
|
147
|
+
children.set(id, child)
|
|
148
|
+
}
|
|
149
|
+
return children.get(id)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
event: async ({ event }) => {
|
|
154
|
+
if (event.type !== "session.status") return
|
|
155
|
+
const status = event.properties.status.type
|
|
156
|
+
// "retry" is the third one: still working, so nothing to write.
|
|
157
|
+
if (status !== "busy" && status !== "idle") return
|
|
158
|
+
if (await isChild(event.properties.sessionID)) return
|
|
159
|
+
// The script keys the state file on CLAUDE_PROJECT_DIR, then on its
|
|
160
|
+
// working directory. Set both to the directory OpenCode was started in.
|
|
161
|
+
await $`${SCRIPT} ${status} __AGENT__`
|
|
162
|
+
.cwd(directory)
|
|
163
|
+
.env({ ...process.env, CLAUDE_PROJECT_DIR: directory })
|
|
164
|
+
.quiet()
|
|
165
|
+
.nothrow()
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def plugin_source(script: str, agent: Agent) -> str:
|
|
173
|
+
"""The whole plugin file an "opencode"-shaped agent loads.
|
|
174
|
+
|
|
175
|
+
`json.dumps` for the path: a JSON string is a JavaScript string literal,
|
|
176
|
+
so a quote or backslash in the user's home directory cannot end it early.
|
|
177
|
+
The shell side needs no quoting of its own — Bun's `$` passes every
|
|
178
|
+
interpolated value as one argument.
|
|
179
|
+
"""
|
|
180
|
+
return _OPENCODE_PLUGIN.replace("__SCRIPT__", json.dumps(script)).replace(
|
|
181
|
+
"__AGENT__", agent.slug
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _entry(command: str, shape: str) -> dict:
|
|
186
|
+
if shape == "cursor":
|
|
187
|
+
return {"command": command}
|
|
188
|
+
return {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _commands_in(entry: dict) -> list[str]:
|
|
192
|
+
if "command" in entry:
|
|
193
|
+
return [entry["command"]]
|
|
194
|
+
return [h.get("command", "") for h in entry.get("hooks", []) if isinstance(h, dict)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_ours(entry: dict) -> bool:
|
|
198
|
+
return any(MARKER in command for command in _commands_in(entry))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _filtered_entry(entry: dict, shape: str) -> dict | None:
|
|
202
|
+
"""Filter out our hooks from an entry, returning the filtered entry or None if empty.
|
|
203
|
+
|
|
204
|
+
For cursor shape (one command per entry), return the entry or None.
|
|
205
|
+
For claude/codex shape (array of hooks), filter the inner hooks array and return
|
|
206
|
+
the entry only if it still has hooks.
|
|
207
|
+
"""
|
|
208
|
+
if shape == "cursor":
|
|
209
|
+
# For cursor: entry has {"command": "..."}, drop it if it's ours
|
|
210
|
+
if _is_ours(entry):
|
|
211
|
+
return None
|
|
212
|
+
return entry
|
|
213
|
+
|
|
214
|
+
# For claude/codex: entry has {"hooks": [...], possibly "matcher": ...}
|
|
215
|
+
# Filter the inner hooks array, keeping only those without our marker
|
|
216
|
+
if "hooks" not in entry or not isinstance(entry["hooks"], list):
|
|
217
|
+
return entry
|
|
218
|
+
|
|
219
|
+
kept_hooks = [
|
|
220
|
+
h for h in entry["hooks"]
|
|
221
|
+
if isinstance(h, dict) and MARKER not in h.get("command", "")
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
if not kept_hooks:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
# Reconstruct entry with filtered hooks, preserving other keys like "matcher"
|
|
228
|
+
filtered = {k: v for k, v in entry.items() if k != "hooks"}
|
|
229
|
+
filtered["hooks"] = kept_hooks
|
|
230
|
+
return filtered
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _kept_entries(entries, shape: str) -> list:
|
|
234
|
+
"""Everything in `entries` except our own hooks, in order.
|
|
235
|
+
|
|
236
|
+
A neighbour this module does not understand — a bare string in the event's
|
|
237
|
+
array, a number, anything that is not a hook entry — is carried through
|
|
238
|
+
untouched rather than dropped. The guarantee is "never destroys a foreign
|
|
239
|
+
hook", and it must not quietly mean "never destroys a well-formed one".
|
|
240
|
+
"""
|
|
241
|
+
if not isinstance(entries, list):
|
|
242
|
+
# An event whose value is not an array is malformed for every agent we
|
|
243
|
+
# know, but it is still somebody's content: keep it as the one entry it
|
|
244
|
+
# is, so `merge` can append ours beside it instead of over it.
|
|
245
|
+
entries = [entries]
|
|
246
|
+
kept = []
|
|
247
|
+
for entry in entries:
|
|
248
|
+
if not isinstance(entry, dict):
|
|
249
|
+
kept.append(entry)
|
|
250
|
+
continue
|
|
251
|
+
filtered = _filtered_entry(entry, shape)
|
|
252
|
+
if filtered is not None:
|
|
253
|
+
kept.append(filtered)
|
|
254
|
+
return kept
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def merge(document: dict, agent: Agent, script: str) -> dict:
|
|
258
|
+
"""Our two entries, folded into a copy of `document`.
|
|
259
|
+
|
|
260
|
+
Idempotent, and safe on a file that already holds other tools' hooks:
|
|
261
|
+
every hook that is not ours is carried through untouched (even if in an
|
|
262
|
+
entry with ours), and ours are rewritten rather than appended so a changed
|
|
263
|
+
script path replaces the old registration instead of doubling it.
|
|
264
|
+
"""
|
|
265
|
+
out = copy.deepcopy(document)
|
|
266
|
+
if agent.shape == "cursor":
|
|
267
|
+
out.setdefault("version", 1)
|
|
268
|
+
hooks = out.setdefault("hooks", {})
|
|
269
|
+
for status, event in (("busy", agent.busy_event), ("idle", agent.idle_event)):
|
|
270
|
+
# Filter each entry: for claude/codex, this removes our inner hooks but
|
|
271
|
+
# keeps the entry if foreign hooks remain; for cursor, drops the entry if it's ours
|
|
272
|
+
kept = _kept_entries(hooks.get(event, []), agent.shape)
|
|
273
|
+
hooks[event] = kept + [_entry(command_for(script, agent, status), agent.shape)]
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def unmerge(document: dict, agent: Agent) -> dict:
|
|
278
|
+
"""A copy of `document` with our hooks — and only ours — taken out.
|
|
279
|
+
|
|
280
|
+
For claude/codex shape, removes our inner hooks but keeps entries that still
|
|
281
|
+
have foreign hooks. For cursor shape, removes entire entries that are ours.
|
|
282
|
+
"""
|
|
283
|
+
out = copy.deepcopy(document)
|
|
284
|
+
hooks = out.get("hooks")
|
|
285
|
+
if not isinstance(hooks, dict):
|
|
286
|
+
return out
|
|
287
|
+
for event in (agent.busy_event, agent.idle_event):
|
|
288
|
+
entries = hooks.get(event)
|
|
289
|
+
if entries is not None and not isinstance(entries, list):
|
|
290
|
+
# Not a list, so nothing of ours can be in it (merge only ever
|
|
291
|
+
# writes a list). Leave the stranger exactly as we found it.
|
|
292
|
+
continue
|
|
293
|
+
# Filter each entry to remove our hooks, keeping entries if they still have foreign hooks
|
|
294
|
+
kept = _kept_entries(entries or [], agent.shape)
|
|
295
|
+
if kept:
|
|
296
|
+
hooks[event] = kept
|
|
297
|
+
else:
|
|
298
|
+
hooks.pop(event, None)
|
|
299
|
+
if not hooks:
|
|
300
|
+
out.pop("hooks", None)
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def registered_commands(document: dict, agent: Agent) -> list[str]:
|
|
305
|
+
"""Every command string of ours in `document`, in busy-then-idle order."""
|
|
306
|
+
found = []
|
|
307
|
+
hooks = document.get("hooks")
|
|
308
|
+
if not isinstance(hooks, dict):
|
|
309
|
+
return found
|
|
310
|
+
for event in (agent.busy_event, agent.idle_event):
|
|
311
|
+
for entry in hooks.get(event, []):
|
|
312
|
+
if isinstance(entry, dict) and _is_ours(entry):
|
|
313
|
+
found.extend(c for c in _commands_in(entry) if MARKER in c)
|
|
314
|
+
return found
|
meanwhile/app.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The terminal UI. Renders cards; all rules live in meanwhile.game."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.widgets import Footer, Static
|
|
11
|
+
|
|
12
|
+
from . import agents, game, scores, strings
|
|
13
|
+
from .pool import Pool, QuizItem
|
|
14
|
+
|
|
15
|
+
# Lifecycle phases. The pane is opened once and left open: it waits for the
|
|
16
|
+
# agent to start, plays for exactly as long as it is busy, then waits again.
|
|
17
|
+
WAITING = "waiting"
|
|
18
|
+
PLAYING = "playing"
|
|
19
|
+
DONE = "done"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MeanwhileApp(App):
|
|
23
|
+
CSS_PATH = "app.tcss"
|
|
24
|
+
TITLE = "meanwhile"
|
|
25
|
+
|
|
26
|
+
# Every key the game can ever offer is declared here, statically. Which of
|
|
27
|
+
# them the footer actually shows is decided per card by `check_action`.
|
|
28
|
+
BINDINGS = [
|
|
29
|
+
("q", "quit", "Quit"),
|
|
30
|
+
("space", "next", "Next card"),
|
|
31
|
+
*[(str(n), f"choose({n})", f"Choice {n}") for n in range(1, 10)],
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# The actions `check_action` gates; everything else is always available.
|
|
35
|
+
CARD_ACTIONS = ("choose",)
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
pool: Pool,
|
|
40
|
+
repo_dir: Path,
|
|
41
|
+
status_source: Callable[[], str],
|
|
42
|
+
banner: str = "",
|
|
43
|
+
poll_seconds: float = 0.5,
|
|
44
|
+
language: str = "en",
|
|
45
|
+
agent_source: Callable[[], str | None] = lambda: None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.pool = pool
|
|
49
|
+
self.repo_dir = repo_dir
|
|
50
|
+
self.status_source = status_source
|
|
51
|
+
self.banner = banner
|
|
52
|
+
self.poll_seconds = poll_seconds
|
|
53
|
+
self.language = language
|
|
54
|
+
self.agent_source = agent_source
|
|
55
|
+
self.session = game.start_session(pool)
|
|
56
|
+
self.phase = WAITING
|
|
57
|
+
self._last_status = "idle"
|
|
58
|
+
self._answered = False
|
|
59
|
+
|
|
60
|
+
def _agent_name(self) -> str:
|
|
61
|
+
return agents.label_for(self.agent_source()) or strings.text(self.language, "agent")
|
|
62
|
+
|
|
63
|
+
# --- dynamic bindings -------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
66
|
+
"""Offer only the keys the card on screen can actually take.
|
|
67
|
+
|
|
68
|
+
A three-choice card should not fill a narrow pane's footer with
|
|
69
|
+
"Choice 4" .. "Choice 9". Returning False is what *removes* a binding:
|
|
70
|
+
verified on Textual 8.2.8, True shows it, None shows it greyed out,
|
|
71
|
+
and only False keeps it out of `Screen.active_bindings` — and so out
|
|
72
|
+
of the Footer — entirely.
|
|
73
|
+
|
|
74
|
+
This decides what the footer advertises and what a keypress may reach;
|
|
75
|
+
the guards in the action handlers stay the last line of defence.
|
|
76
|
+
"""
|
|
77
|
+
if action not in self.CARD_ACTIONS:
|
|
78
|
+
return True
|
|
79
|
+
if self.phase != PLAYING:
|
|
80
|
+
return False
|
|
81
|
+
item = game.current(self.session)
|
|
82
|
+
return isinstance(item, QuizItem) and 1 <= int(parameters[0]) <= len(item.choices)
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
# markup=False: card/feedback text comes from an untrusted pool.json
|
|
86
|
+
# (see pool.py) and from literal "[space] next" hints — neither should
|
|
87
|
+
# be parsed as Textual console markup.
|
|
88
|
+
with Vertical():
|
|
89
|
+
yield Static(self.banner, id="banner", markup=False)
|
|
90
|
+
yield Static("", id="status", markup=False)
|
|
91
|
+
yield Static("", id="card", markup=False)
|
|
92
|
+
yield Static("", id="feedback", markup=False)
|
|
93
|
+
yield Static("", id="score", markup=False)
|
|
94
|
+
yield Footer()
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
self.query_one("#banner").display = bool(self.banner)
|
|
98
|
+
self._render()
|
|
99
|
+
# Poll once up front so a pane opened while the agent is already busy
|
|
100
|
+
# joins that wait instead of sitting out until the first tick.
|
|
101
|
+
self._poll_status()
|
|
102
|
+
self.set_interval(self.poll_seconds, self._poll_status)
|
|
103
|
+
|
|
104
|
+
# --- status ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _poll_status(self) -> None:
|
|
107
|
+
"""Drive the lifecycle off the edges of the busy/idle state file."""
|
|
108
|
+
status = self.status_source()
|
|
109
|
+
if status == self._last_status:
|
|
110
|
+
return
|
|
111
|
+
self._last_status = status
|
|
112
|
+
if status == "busy":
|
|
113
|
+
self._start_playing()
|
|
114
|
+
else:
|
|
115
|
+
self._finish_playing()
|
|
116
|
+
|
|
117
|
+
def _start_playing(self) -> None:
|
|
118
|
+
self.phase = PLAYING
|
|
119
|
+
self.session = game.start_session(self.pool) # re-shuffled: the cards come back
|
|
120
|
+
self._answered = False
|
|
121
|
+
self.query_one("#status").update("")
|
|
122
|
+
self.query_one("#feedback").update("")
|
|
123
|
+
self._render()
|
|
124
|
+
|
|
125
|
+
def _finish_playing(self) -> None:
|
|
126
|
+
if self.phase != PLAYING:
|
|
127
|
+
return
|
|
128
|
+
self.phase = DONE
|
|
129
|
+
self.refresh_bindings() # DONE takes no input: the card keys leave the footer
|
|
130
|
+
self.query_one("#status").update(strings.text(self.language, "done", agent=self._agent_name()))
|
|
131
|
+
# Exactly once per session: the `phase != PLAYING` guard above is what
|
|
132
|
+
# guarantees it — the only way back into PLAYING is _start_playing,
|
|
133
|
+
# which deals a new session.
|
|
134
|
+
if self.repo_dir is not None and self.session.answered:
|
|
135
|
+
scores.record_session(self.repo_dir, self.session)
|
|
136
|
+
|
|
137
|
+
# --- actions --------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def action_choose(self, number: int) -> None:
|
|
140
|
+
item = game.current(self.session)
|
|
141
|
+
if self.phase != PLAYING or self._answered or not isinstance(item, QuizItem):
|
|
142
|
+
return
|
|
143
|
+
index = number - 1
|
|
144
|
+
if not 0 <= index < len(item.choices):
|
|
145
|
+
return
|
|
146
|
+
self._resolve(game.answer(self.session, index))
|
|
147
|
+
|
|
148
|
+
def action_next(self) -> None:
|
|
149
|
+
if self.phase != PLAYING or not self._answered:
|
|
150
|
+
return
|
|
151
|
+
game.advance(self.session)
|
|
152
|
+
self._answered = False
|
|
153
|
+
self.query_one("#feedback").update("")
|
|
154
|
+
self._render()
|
|
155
|
+
|
|
156
|
+
# --- rendering ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _resolve(self, verdict: game.Verdict) -> None:
|
|
159
|
+
self._answered = True
|
|
160
|
+
mark = strings.text(self.language, "correct" if verdict.correct else "wrong")
|
|
161
|
+
self.query_one("#feedback").update(f"{mark} — {verdict.explanation} [space] next")
|
|
162
|
+
self._render_score()
|
|
163
|
+
|
|
164
|
+
def _render(self) -> None:
|
|
165
|
+
# The card (or the phase) has just changed, so the footer's answer for
|
|
166
|
+
# `check_action` has too.
|
|
167
|
+
self.refresh_bindings()
|
|
168
|
+
card = self.query_one("#card")
|
|
169
|
+
if self.phase == WAITING:
|
|
170
|
+
self.query_one("#status").update(strings.text(self.language, "waiting", agent=self._agent_name()))
|
|
171
|
+
card.update("")
|
|
172
|
+
self.query_one("#score").update("")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
item = game.current(self.session)
|
|
176
|
+
if item is None:
|
|
177
|
+
card.update(strings.text(self.language, "exhausted"))
|
|
178
|
+
elif isinstance(item, QuizItem):
|
|
179
|
+
choices = "\n".join(f" {n}. {text}" for n, text in enumerate(item.choices, start=1))
|
|
180
|
+
card.update(f"{item.q}\n\n{choices}\n\n({item.source})")
|
|
181
|
+
self._render_score()
|
|
182
|
+
|
|
183
|
+
def _render_score(self) -> None:
|
|
184
|
+
self.query_one("#score").update(
|
|
185
|
+
f"{self.session.correct}/{self.session.answered} correct "
|
|
186
|
+
f"streak {self.session.streak} (best {self.session.best_streak})"
|
|
187
|
+
)
|
meanwhile/app.tcss
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Build `.meanwhile/pool.json` for the current repository.
|
|
2
|
+
|
|
3
|
+
## What to read
|
|
4
|
+
|
|
5
|
+
Survey the repo to understand what it does and what is distinctive about it:
|
|
6
|
+
|
|
7
|
+
- data models, schemas, and migrations
|
|
8
|
+
- API routes, serializers, and their validation rules
|
|
9
|
+
- the README, and any docs describing domain concepts
|
|
10
|
+
- `CHANGELOG.md` if present
|
|
11
|
+
- `git log --oneline -50` for recent direction
|
|
12
|
+
|
|
13
|
+
Prefer the project's own domain terms over generic programming vocabulary. A
|
|
14
|
+
good question could only be answered by someone who has read this repository.
|
|
15
|
+
Write the questions in {language}. Record that language in the pool too, so
|
|
16
|
+
the playing interface follows it.
|
|
17
|
+
|
|
18
|
+
The language is given as a short language code, not a language name, and the
|
|
19
|
+
pool's `language` field records exactly that code. `tr` and `en` are the two
|
|
20
|
+
the playing interface itself speaks; any other code is still fine — the cards
|
|
21
|
+
get written in that language and the interface stays English.
|
|
22
|
+
|
|
23
|
+
## What to write
|
|
24
|
+
|
|
25
|
+
Write `.meanwhile/pool.json` with 30-50 `quiz` items, using exactly this shape:
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
"builtAt": "<today, YYYY-MM-DD>",
|
|
29
|
+
"headSha": "<output of: git rev-parse --short HEAD>",
|
|
30
|
+
"repo": "{repo}",
|
|
31
|
+
"language": "{language}",
|
|
32
|
+
"items": [
|
|
33
|
+
{
|
|
34
|
+
"type": "quiz",
|
|
35
|
+
"q": "<question about this project's domain or behavior>",
|
|
36
|
+
"choices": ["<option>", "<option>", "<option>"],
|
|
37
|
+
"answer": <0-based index of the correct choice>,
|
|
38
|
+
"why": "<one sentence explaining the answer>",
|
|
39
|
+
"source": "<repo-relative path the question came from>"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
|
|
46
|
+
- Never invent code. Every question must be grounded in a file that exists.
|
|
47
|
+
- **Never take a question or an answer from a secret file.**
|
|
48
|
+
`.meanwhile/pool.json` is committed and shared with the whole team, so
|
|
49
|
+
anything copied into it is published. Skip `.env` and any `.env.*`,
|
|
50
|
+
credential/key/token/certificate files (`*.pem`, `*.key`, `id_rsa`,
|
|
51
|
+
`credentials*`, `secrets*`), fixtures holding real keys, and **any file
|
|
52
|
+
excluded by `.gitignore`** — check with `git check-ignore -q <path>`
|
|
53
|
+
before using a file. Draw only on files that are tracked in git.
|
|
54
|
+
- Wrong quiz choices should be plausible to someone who half-remembers the
|
|
55
|
+
code, not obviously absurd.
|
|
56
|
+
|
|
57
|
+
## Finish
|
|
58
|
+
|
|
59
|
+
1. Ensure `.meanwhile/scores.json` is in `.gitignore`; add it if missing.
|
|
60
|
+
2. Run `uvx meanwhile-cli validate --repo .` (during development, from a
|
|
61
|
+
checkout of this plugin: `uv run meanwhile validate --repo .`) and fix
|
|
62
|
+
anything it reports.
|
|
63
|
+
3. Tell the user the item count and that `.meanwhile/pool.json` is ready to
|
|
64
|
+
commit so their team shares the pool.
|
|
65
|
+
4. Tell the user how to play, in {language} — building the pool starts
|
|
66
|
+
nothing, and no other step says how. Give them the command itself, in a
|
|
67
|
+
code block they can copy:
|
|
68
|
+
|
|
69
|
+
uvx meanwhile-cli
|
|
70
|
+
|
|
71
|
+
and say where it goes: a second terminal pane beside this one, opened in
|
|
72
|
+
this same directory. The pane waits there, deals cards from their next
|
|
73
|
+
prompt on, and `q` quits it.
|