rumo-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.
rumo/__init__.py ADDED
File without changes
rumo/app.py ADDED
@@ -0,0 +1,496 @@
1
+ """The `rumo` TUI: a Timeline (Past / Now / Later) on the left, the selected item on the right."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Callable
10
+ from datetime import date, datetime
11
+ from pathlib import Path
12
+
13
+ from rich.text import Text
14
+ from textual.app import App, ComposeResult
15
+ from textual.binding import Binding
16
+ from textual.containers import Horizontal, Vertical, VerticalScroll
17
+ from textual.screen import ModalScreen
18
+ from textual.widgets import Input, Label, ListItem, ListView, Markdown, OptionList, SelectionList, Static
19
+ from textual.widgets.option_list import Option
20
+
21
+ from rumo import config, items
22
+ from rumo.items import Item, WriteConflict
23
+
24
+ STATUS_STYLE = {"idea": "cyan", "doing": "green", "waiting": "yellow", "paused": "grey50", "done": "blue"}
25
+ NEW_GROUP = "+new"
26
+ NO_GROUP = "-none"
27
+
28
+ KEYS = Text.assemble(
29
+ *(
30
+ part
31
+ for key, label in [
32
+ ("s", "status"), ("g", "group"), ("t", "tags"), ("n", "new idea"), ("e", "edit"),
33
+ ("space", "step"), ("c", "copy resume"), ("/", "filter"), ("q", "quit"),
34
+ ]
35
+ for part in ((key, "bold"), (f" {label} ", "dim"))
36
+ )
37
+ )
38
+
39
+
40
+ MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()
41
+
42
+
43
+ def short_date(stamp: str) -> str:
44
+ """`2026-10-06 17:00` -> `Oct 06`: unambiguous in any country, unlike 10/06."""
45
+ try:
46
+ return f"{MONTHS[int(stamp[5:7]) - 1]} {stamp[8:10]}"
47
+ except (ValueError, IndexError):
48
+ return stamp[:10]
49
+
50
+
51
+ # ---------- pickers ----------
52
+
53
+
54
+ class ChoiceScreen(ModalScreen[str | None]):
55
+ """Pick one value; digits 1-9 pick directly, Esc cancels."""
56
+
57
+ BINDINGS = [Binding("escape", "dismiss(None)", "cancel")]
58
+
59
+ def __init__(self, title: str, choices: list[tuple[str, Text]], current: str | None = None) -> None:
60
+ super().__init__()
61
+ self.title_text = title
62
+ self.choices = choices
63
+ self.current = current
64
+
65
+ def compose(self) -> ComposeResult:
66
+ with Vertical(classes="dialog"):
67
+ yield Label(self.title_text, classes="dialog-title")
68
+ options = []
69
+ for n, (value, label) in enumerate(self.choices, start=1):
70
+ prefix = f"{n} " if n <= 9 else " "
71
+ options.append(Option(Text.assemble((prefix, "dim"), label), id=value))
72
+ yield OptionList(*options)
73
+
74
+ def on_mount(self) -> None:
75
+ option_list = self.query_one(OptionList)
76
+ values = [v for v, _ in self.choices]
77
+ if self.current in values:
78
+ option_list.highlighted = values.index(self.current)
79
+ option_list.focus()
80
+
81
+ def on_key(self, event) -> None:
82
+ if event.character and event.character.isdigit() and event.character != "0":
83
+ n = int(event.character)
84
+ if n <= len(self.choices):
85
+ event.stop()
86
+ self.dismiss(self.choices[n - 1][0])
87
+
88
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
89
+ self.dismiss(event.option.id)
90
+
91
+
92
+ class TextScreen(ModalScreen[str | None]):
93
+ BINDINGS = [Binding("escape", "dismiss(None)", "cancel")]
94
+
95
+ def __init__(self, title: str, placeholder: str = "") -> None:
96
+ super().__init__()
97
+ self.title_text = title
98
+ self.placeholder = placeholder
99
+
100
+ def compose(self) -> ComposeResult:
101
+ with Vertical(classes="dialog"):
102
+ yield Label(self.title_text, classes="dialog-title")
103
+ yield Input(placeholder=self.placeholder)
104
+
105
+ def on_input_submitted(self, event: Input.Submitted) -> None:
106
+ self.dismiss(event.value.strip() or None)
107
+
108
+
109
+ class TagScreen(ModalScreen[list[str] | None]):
110
+ """Space toggles a tag, Enter applies, Esc cancels."""
111
+
112
+ BINDINGS = [
113
+ Binding("escape", "dismiss(None)", "cancel"),
114
+ Binding("enter", "apply", "apply", priority=True),
115
+ ]
116
+
117
+ def __init__(self, tags: list[str], selected: list[str]) -> None:
118
+ super().__init__()
119
+ self.tags = tags
120
+ self.selected = selected
121
+
122
+ def compose(self) -> ComposeResult:
123
+ with Vertical(classes="dialog"):
124
+ yield Label("Tags (space toggles, enter applies)", classes="dialog-title")
125
+ yield SelectionList[str](*((t, t, t in self.selected) for t in self.tags))
126
+
127
+ def action_apply(self) -> None:
128
+ chosen = set(self.query_one(SelectionList).selected)
129
+ self.dismiss([t for t in self.tags if t in chosen])
130
+
131
+
132
+ class StepList(ListView):
133
+ BINDINGS = [Binding("space", "app.toggle_step", "toggle step")]
134
+
135
+
136
+ # ---------- app ----------
137
+
138
+
139
+ class RumoApp(App):
140
+ TITLE = "rumo"
141
+ CSS = """
142
+ Screen { layout: vertical; }
143
+ #filter { display: none; }
144
+ #filter.on { display: block; }
145
+ #main { height: 1fr; }
146
+ #timeline { width: 45%; height: 1fr; border: round $primary-darken-1; }
147
+ #timeline:focus { border: round $primary; }
148
+ #detail { border: round $secondary-darken-1; padding: 0 1; }
149
+ #header { margin-bottom: 1; }
150
+ .section { text-style: bold; color: $accent; margin-top: 1; }
151
+ #steps, #sessions { height: auto; max-height: 12; }
152
+ Markdown { margin: 0; padding: 0; }
153
+ #legend, #keys { height: auto; padding: 0 1; }
154
+ ModalScreen { align: center middle; }
155
+ .dialog { width: 60; height: auto; max-height: 80%; border: round $primary; background: $surface; padding: 0 1; }
156
+ .dialog-title { text-style: bold; margin-bottom: 1; }
157
+ .dialog OptionList, .dialog SelectionList { height: auto; max-height: 20; }
158
+ """
159
+ BINDINGS = [
160
+ Binding("q", "quit", "quit"),
161
+ Binding("s", "status", "status"),
162
+ Binding("g", "group", "group"),
163
+ Binding("t", "tags", "tags"),
164
+ Binding("n", "new_idea", "new idea"),
165
+ Binding("e", "edit", "edit"),
166
+ Binding("c", "copy_resume", "copy resume"),
167
+ Binding("slash", "filter", "filter"),
168
+ Binding("escape", "clear_filter", "clear filter", show=False),
169
+ ]
170
+
171
+ def __init__(self, config_path: Path | None = None, today: date | None = None) -> None:
172
+ super().__init__()
173
+ self.config_path = config_path or config.default_path()
174
+ self.cfg = config.load(self.config_path)
175
+ self.today = today
176
+ self.items: dict[str, Item] = {}
177
+ self.current: str | None = None
178
+ self.filter_text = ""
179
+ self.show_older = False
180
+ self.mtimes: dict[str, float] = {}
181
+
182
+ def compose(self) -> ComposeResult:
183
+ yield Input(placeholder="filter by title, tag, group or text (Esc clears)", id="filter")
184
+ with Horizontal(id="main"):
185
+ yield OptionList(id="timeline")
186
+ with VerticalScroll(id="detail"):
187
+ yield Static(id="header")
188
+ yield Markdown(id="body")
189
+ yield Label("Steps", classes="section")
190
+ yield StepList(id="steps")
191
+ yield Label("Sessions (c copies the resume command)", classes="section")
192
+ yield ListView(id="sessions")
193
+ yield Static(id="legend")
194
+ yield Static(KEYS, id="keys")
195
+
196
+ def on_mount(self) -> None:
197
+ self.cfg.folder.mkdir(parents=True, exist_ok=True)
198
+ if not self.cfg.error:
199
+ config.ensure_file(self.config_path, self.cfg.folder, self.cfg.language)
200
+ self.reload()
201
+ self.query_one("#timeline", OptionList).focus()
202
+ self.set_interval(2.0, self.check_changes)
203
+
204
+ # ---------- data ----------
205
+
206
+ def read_mtimes(self) -> dict[str, float]:
207
+ files = list(self.cfg.folder.glob("*.md"))
208
+ if self.config_path.exists():
209
+ files.append(self.config_path)
210
+ mtimes = {}
211
+ for path in files:
212
+ try:
213
+ mtimes[str(path)] = path.stat().st_mtime
214
+ except OSError:
215
+ continue
216
+ return mtimes
217
+
218
+ def check_changes(self) -> None:
219
+ if self.read_mtimes() != self.mtimes:
220
+ self.reload()
221
+
222
+ def reload(self) -> None:
223
+ self.cfg = config.load(self.config_path)
224
+ self.mtimes = self.read_mtimes()
225
+ self.items = {i.slug: i for i in items.list_items(self.cfg.folder)}
226
+ self.build_timeline()
227
+ self.show_detail()
228
+ self.query_one("#legend", Static).update(self.legend())
229
+
230
+ def group_style(self, item: Item) -> str:
231
+ group = self.cfg.groups.get(item.group)
232
+ return config.PALETTE.get(group.color, "") if group else ""
233
+
234
+ def matches(self, item: Item) -> bool:
235
+ term = self.filter_text.lower().strip()
236
+ if not term:
237
+ return True
238
+ group = self.cfg.groups.get(item.group)
239
+ haystack = [item.title, item.slug, item.status, item.group, group.name if group else "", *item.tags]
240
+ haystack += item.sections.values()
241
+ return term in " ".join(haystack).lower()
242
+
243
+ def write(self, operation: Callable[[], object]) -> None:
244
+ try:
245
+ operation()
246
+ except WriteConflict:
247
+ self.notify("The item just changed on disk. Reloaded; try again.", severity="warning")
248
+ self.reload()
249
+
250
+ # ---------- timeline ----------
251
+
252
+ def build_timeline(self) -> None:
253
+ timeline = self.query_one("#timeline", OptionList)
254
+ visible = [i for i in self.items.values() if self.matches(i)]
255
+ tl = items.timeline(visible, today=self.today, show_older=self.show_older)
256
+
257
+ options: list[Option] = []
258
+ if not self.items:
259
+ options.append(Option(Text(
260
+ "No items yet. Press n to capture an idea, or run /rumo new in an agent session.", "dim"
261
+ ), id="empty", disabled=True))
262
+ if tl.past or tl.older:
263
+ options.append(self.header("Past", f"last {items.PAST_WINDOW_DAYS} days" if not self.show_older else "all", "past"))
264
+ if tl.older:
265
+ options.append(Option(Text(f" … {tl.older} older (enter to show)", "dim italic"), id="older"))
266
+ options += [Option(self.row(i, short_date(i.done_at)), id=f"item:{i.slug}") for i in tl.past]
267
+ if tl.now:
268
+ options.append(self.header("Now", str(len(tl.now)), "now"))
269
+ options += [Option(self.row(i, i.status, next_step=True), id=f"item:{i.slug}") for i in tl.now]
270
+ if tl.later:
271
+ options.append(self.header("Later", str(len(tl.later)), "later"))
272
+ options += [Option(self.row(i, i.status), id=f"item:{i.slug}") for i in tl.later]
273
+
274
+ timeline.clear_options()
275
+ timeline.add_options(options)
276
+
277
+ ids = [o.id for o in options]
278
+ target = f"item:{self.current}" if self.current else None
279
+ if target not in ids:
280
+ first_now = f"item:{tl.now[0].slug}" if tl.now else None
281
+ first_any = next((i for i in ids if i and i.startswith("item:")), None)
282
+ target = first_now or first_any
283
+ self.current = target.removeprefix("item:") if target else None
284
+ if target:
285
+ timeline.highlighted = ids.index(target)
286
+
287
+ @staticmethod
288
+ def header(name: str, note: str, key: str) -> Option:
289
+ return Option(Text.assemble((name, "bold"), (f" {note}", "dim")), id=f"header:{key}", disabled=True)
290
+
291
+ def row(self, item: Item, prefix: str, next_step: bool = False) -> Text:
292
+ text = Text.assemble((f"{prefix:<8}", STATUS_STYLE.get(prefix, "dim")), (item.title, self.group_style(item)))
293
+ if item.steps:
294
+ done = sum(s.done for s in item.steps)
295
+ text.append(f" {done}/{len(item.steps)}", "dim")
296
+ if next_step and (step := item.next_step):
297
+ text.append(f"\n ↳ {step.text}", "dim")
298
+ return text
299
+
300
+ def legend(self) -> Text:
301
+ text = Text()
302
+ if self.cfg.error:
303
+ text.append(f"Config error, using defaults: {self.cfg.error}\n", "bold red")
304
+ if not self.cfg.groups:
305
+ return text.append("No groups yet. Press g on an item to create one.", "dim")
306
+ for group in self.cfg.groups.values():
307
+ text.append("● ", config.PALETTE.get(group.color, ""))
308
+ text.append(group.name + " ")
309
+ return text
310
+
311
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
312
+ if event.option_list.id != "timeline":
313
+ return
314
+ option_id = event.option.id or ""
315
+ if option_id.startswith("item:") and option_id.removeprefix("item:") != self.current:
316
+ self.current = option_id.removeprefix("item:")
317
+ self.show_detail()
318
+
319
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
320
+ if event.option_list.id == "timeline" and event.option.id == "older":
321
+ self.show_older = True
322
+ self.build_timeline()
323
+
324
+ # ---------- detail ----------
325
+
326
+ def current_item(self) -> Item | None:
327
+ return self.items.get(self.current) if self.current else None
328
+
329
+ def show_detail(self) -> None:
330
+ item = self.current_item()
331
+ header = self.query_one("#header", Static)
332
+ body = self.query_one("#body", Markdown)
333
+ steps = self.query_one("#steps", ListView)
334
+ sessions = self.query_one("#sessions", ListView)
335
+ step_index, session_index = steps.index, sessions.index
336
+ steps.clear()
337
+ sessions.clear()
338
+ if item is None:
339
+ header.update(Text("Nothing selected.", "dim"))
340
+ body.update("")
341
+ return
342
+
343
+ line = Text.assemble((item.title, f"bold {self.group_style(item)}"), "\n", (item.status, STATUS_STYLE[item.status]))
344
+ if group := self.cfg.groups.get(item.group):
345
+ line.append(" group ", "dim")
346
+ line.append(group.name, config.PALETTE.get(group.color, ""))
347
+ if item.tags:
348
+ line.append(" tags ", "dim")
349
+ for tag in item.tags:
350
+ line.append(tag + " ", "" if tag in self.cfg.tags else "dim")
351
+ if updated := item.meta.get("updated"):
352
+ line.append(f" updated {updated}", "dim")
353
+ header.update(line)
354
+
355
+ parts = []
356
+ for name in ("left off", "context"):
357
+ if content := item.sections.get(name):
358
+ parts.append(f"**{name.capitalize()}**\n\n{content}")
359
+ body.update("\n\n".join(parts))
360
+
361
+ steps.extend(ListItem(Label(self.step_label(s))) for s in item.steps)
362
+ sessions.extend(
363
+ ListItem(Label(Text.assemble((s.when, "dim"), " ", s.repo, " ", (s.id[:8], "cyan"), (" closed" if s.closed else "", "dim"))))
364
+ for s in reversed(item.sessions)
365
+ )
366
+ if item.steps:
367
+ steps.index = min(step_index or 0, len(item.steps) - 1)
368
+ if item.sessions:
369
+ sessions.index = min(session_index or 0, len(item.sessions) - 1)
370
+
371
+ @staticmethod
372
+ def step_label(step: items.Step) -> Text:
373
+ kind = f"[{step.kind}] " if step.kind else ""
374
+ if step.done:
375
+ return Text(f"[x] {kind}{step.text}", "dim strike")
376
+ return Text.assemble("[ ] ", (kind, "yellow" if step.kind == "manual" else "dim"), step.text)
377
+
378
+ # ---------- actions ----------
379
+
380
+ def action_toggle_step(self) -> None:
381
+ item = self.current_item()
382
+ index = self.query_one("#steps", ListView).index
383
+ if item is None or index is None or index >= len(item.steps):
384
+ return
385
+ step = item.steps[index]
386
+ self.write(lambda: items.toggle_step(item, step))
387
+
388
+ def action_status(self) -> None:
389
+ item = self.current_item()
390
+ if item is None:
391
+ return
392
+ choices = [(s, Text(s, STATUS_STYLE[s])) for s in items.STATUSES]
393
+
394
+ def chosen(status: str | None) -> None:
395
+ if status and status != item.status:
396
+ self.write(lambda: items.set_status(item, status))
397
+
398
+ self.push_screen(ChoiceScreen(f"Status of “{item.title}”", choices, item.status), chosen)
399
+
400
+ def action_group(self) -> None:
401
+ item = self.current_item()
402
+ if item is None:
403
+ return
404
+ choices = [
405
+ (g.key, Text(g.name, config.PALETTE.get(g.color, ""))) for g in self.cfg.groups.values()
406
+ ]
407
+ choices += [(NO_GROUP, Text("no group", "dim")), (NEW_GROUP, Text("+ new group…", "italic"))]
408
+
409
+ def chosen(key: str | None) -> None:
410
+ if key == NEW_GROUP:
411
+ self.new_group(item)
412
+ elif key is not None:
413
+ self.write(lambda: items.set_group(item, None if key == NO_GROUP else key))
414
+
415
+ self.push_screen(ChoiceScreen(f"Group of “{item.title}”", choices, item.group or NO_GROUP), chosen)
416
+
417
+ def new_group(self, item: Item) -> None:
418
+ def named(name: str | None) -> None:
419
+ if not name:
420
+ return
421
+ colors = [(c, Text(f"● {c}", hex_)) for c, hex_ in config.PALETTE.items()]
422
+
423
+ def colored(color: str | None) -> None:
424
+ if color:
425
+ key = config.add_group(self.config_path, name, color)
426
+ self.write(lambda: items.set_group(item, key))
427
+
428
+ self.push_screen(ChoiceScreen(f"Color for “{name}”", colors), colored)
429
+
430
+ self.push_screen(TextScreen("New group name", "e.g. Auth migration"), named)
431
+
432
+ def action_tags(self) -> None:
433
+ item = self.current_item()
434
+ if item is None:
435
+ return
436
+ tags = list(self.cfg.tags) + [t for t in item.tags if t not in self.cfg.tags]
437
+
438
+ def chosen(selected: list[str] | None) -> None:
439
+ if selected is not None and selected != item.tags:
440
+ self.write(lambda: items.set_tags(item, selected))
441
+
442
+ self.push_screen(TagScreen(tags, item.tags), chosen)
443
+
444
+ def action_new_idea(self) -> None:
445
+ def created(title: str | None) -> None:
446
+ if title:
447
+ path = items.create_idea(self.cfg.folder, title, now=datetime.now())
448
+ self.current = path.stem
449
+ self.reload()
450
+
451
+ self.push_screen(TextScreen("New idea", "a short title"), created)
452
+
453
+ def action_edit(self) -> None:
454
+ item = self.current_item()
455
+ if item is None:
456
+ return
457
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
458
+ with self.suspend():
459
+ subprocess.run([*shlex.split(editor, posix=os.name != "nt"), str(item.path)], check=False)
460
+ self.reload()
461
+
462
+ def action_copy_resume(self) -> None:
463
+ item = self.current_item()
464
+ if item is None or not item.sessions:
465
+ self.notify("This item has no sessions.", severity="warning")
466
+ return
467
+ sessions = self.query_one("#sessions", ListView)
468
+ recent = list(reversed(item.sessions))
469
+ index = sessions.index if sessions.has_focus and sessions.index is not None else 0
470
+ command = recent[index].resume_command
471
+ if shutil.which("pbcopy"):
472
+ subprocess.run(["pbcopy"], input=command.encode(), check=False)
473
+ else:
474
+ self.copy_to_clipboard(command) # OSC 52: works in most terminals outside macOS
475
+ self.notify(f"Copied: {command}")
476
+
477
+ def action_filter(self) -> None:
478
+ field = self.query_one("#filter", Input)
479
+ field.add_class("on")
480
+ field.focus()
481
+
482
+ def action_clear_filter(self) -> None:
483
+ field = self.query_one("#filter", Input)
484
+ field.value = ""
485
+ field.remove_class("on")
486
+ self.query_one("#timeline", OptionList).focus()
487
+
488
+ def on_input_changed(self, event: Input.Changed) -> None:
489
+ if event.input.id == "filter":
490
+ self.filter_text = event.value
491
+ self.build_timeline()
492
+ self.show_detail()
493
+
494
+ def on_input_submitted(self, event: Input.Submitted) -> None:
495
+ if event.input.id == "filter":
496
+ self.query_one("#timeline", OptionList).focus()
rumo/cli.py ADDED
@@ -0,0 +1,77 @@
1
+ """The `rumo` command: opens the Timeline.
2
+
3
+ Other subcommands exist for the agent and the hook, and are kept out of
4
+ the help on purpose: `hook`, `index`, `path`, `group-add`, `--version`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ USAGE = """rumo: your work, past, now and later.
12
+
13
+ rumo open the Timeline
14
+
15
+ Inside an AI agent session, type /rumo for the agent commands.
16
+ """
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> None:
20
+ args = sys.argv[1:] if argv is None else argv
21
+ command = args[0] if args else ""
22
+
23
+ # Late imports: `rumo hook` runs on every prompt and must not pay for Textual.
24
+ if command == "":
25
+ from rumo.app import RumoApp
26
+
27
+ RumoApp().run()
28
+ elif command == "hook":
29
+ from rumo.hook import main as hook
30
+
31
+ hook()
32
+ elif command == "path":
33
+ from rumo import config
34
+
35
+ print(config.load().folder)
36
+ elif command == "index":
37
+ _index()
38
+ elif command == "group-add":
39
+ _group_add(args[1:])
40
+ elif command in ("-V", "--version"):
41
+ from importlib.metadata import version
42
+
43
+ print(f"rumo {version('rumo-cli')}")
44
+ elif command in ("-h", "--help", "help"):
45
+ print(USAGE, end="")
46
+ else:
47
+ print(USAGE, end="", file=sys.stderr)
48
+ raise SystemExit(2)
49
+
50
+
51
+ def _index() -> None:
52
+ """Everything the agent needs in one call: where items live, the content language, tags, groups and items."""
53
+ from rumo import config, items
54
+
55
+ cfg = config.load()
56
+ print(f"# folder: {cfg.folder}")
57
+ print(f"# config: {cfg.path}")
58
+ print(f"# language: {cfg.language}")
59
+ print(f"# tags: {', '.join(cfg.tags)}")
60
+ print(f"# groups: {', '.join(f'{g.key} ({g.name}, {g.color})' for g in cfg.groups.values()) or '-'}")
61
+ print(f"# colors: {', '.join(config.PALETTE)}")
62
+ if listing := items.index(items.list_items(cfg.folder)):
63
+ print(listing)
64
+
65
+
66
+ def _group_add(rest: list[str]) -> None:
67
+ """`rumo group-add "<name>" <color>`: append a group to the config and print its key."""
68
+ from rumo import config
69
+
70
+ if len(rest) != 2 or not rest[0].strip() or rest[1] not in config.PALETTE:
71
+ print(f'usage: rumo group-add "<name>" <{"|".join(config.PALETTE)}>', file=sys.stderr)
72
+ raise SystemExit(2)
73
+ print(config.add_group(config.load().path, rest[0].strip(), rest[1]))
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
rumo/config.py ADDED
@@ -0,0 +1,147 @@
1
+ """User config: config.toml in the place each system expects (see `default_path`), or $RUMO_CONFIG.
2
+
3
+ folder = "~/rumo"
4
+ language = "pt-BR" # language of the content the agent writes
5
+ tags = ["bug", "feature"] # fixed list the agent picks from
6
+
7
+ [groups.auth]
8
+ name = "Migração auth" # content: any language
9
+ color = "blue" # one of PALETTE
10
+
11
+ A missing file means defaults; a malformed one means defaults plus `error`, never a crash.
12
+ Stdlib only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import tomllib
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from rumo.items import slugify
26
+
27
+ # Named colors that read well on dark and light terminals.
28
+ PALETTE = {
29
+ "blue": "#5b8cff",
30
+ "green": "#3fbf7f",
31
+ "yellow": "#d9a520",
32
+ "orange": "#ec8a3c",
33
+ "red": "#e05d5d",
34
+ "magenta": "#d561b5",
35
+ "purple": "#a77ee8",
36
+ "cyan": "#2fb8c8",
37
+ }
38
+
39
+ DEFAULT_TAGS = [
40
+ "bug", "feature", "refactor", "research", "spike", "review",
41
+ "infra", "deploy", "meeting", "docs", "support", "tech-debt",
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class Group:
47
+ key: str
48
+ name: str
49
+ color: str # a PALETTE name; anything else renders in the default color
50
+
51
+
52
+ @dataclass
53
+ class Config:
54
+ path: Path
55
+ folder: Path
56
+ language: str = "en"
57
+ tags: list[str] = field(default_factory=lambda: list(DEFAULT_TAGS))
58
+ groups: dict[str, Group] = field(default_factory=dict)
59
+ error: str | None = None
60
+
61
+
62
+ def default_path(env: Mapping[str, str] | None = None, windows: bool | None = None) -> Path:
63
+ """$RUMO_CONFIG, else %APPDATA%\\rumo on Windows, else $XDG_CONFIG_HOME/rumo (default ~/.config/rumo)."""
64
+ env = os.environ if env is None else env
65
+ windows = os.name == "nt" if windows is None else windows
66
+ if override := env.get("RUMO_CONFIG"):
67
+ return Path(override).expanduser()
68
+ if windows:
69
+ base = Path(env["APPDATA"]) if env.get("APPDATA") else Path.home() / "AppData" / "Roaming"
70
+ else:
71
+ base = Path(env["XDG_CONFIG_HOME"]) if env.get("XDG_CONFIG_HOME") else Path.home() / ".config"
72
+ return base / "rumo" / "config.toml"
73
+
74
+
75
+ def load(path: Path | None = None) -> Config:
76
+ path = path or default_path()
77
+ data: dict = {}
78
+ error = None
79
+ try:
80
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
81
+ except FileNotFoundError:
82
+ pass
83
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
84
+ error = f"{path}: {e}"
85
+
86
+ folder = os.environ.get("RUMO_DIR") or data.get("folder") or "~/rumo"
87
+ cfg = Config(path=path, folder=Path(str(folder)).expanduser(), language=language_from_env(), error=error)
88
+ if isinstance(language := data.get("language"), str) and language.strip():
89
+ cfg.language = language.strip()
90
+ if isinstance(tags := data.get("tags"), list):
91
+ cfg.tags = [str(t) for t in tags]
92
+ groups = data.get("groups")
93
+ for key, value in (groups.items() if isinstance(groups, dict) else []):
94
+ if isinstance(value, dict):
95
+ cfg.groups[key] = Group(key, str(value.get("name") or key), str(value.get("color", "")))
96
+ return cfg
97
+
98
+
99
+ def language_from_env(env: Mapping[str, str] | None = None) -> str:
100
+ """The system language as a tag: `pt_BR.UTF-8` -> `pt-BR`; anything unusable -> `en`."""
101
+ env = os.environ if env is None else env
102
+ value = env.get("LC_ALL") or env.get("LANG") or ""
103
+ m = re.match(r"^([a-z]{2})(?:_([A-Z]{2}))?", value)
104
+ if not m:
105
+ return "en"
106
+ return f"{m.group(1)}-{m.group(2)}" if m.group(2) else m.group(1)
107
+
108
+
109
+ def ensure_file(path: Path, folder: Path, language: str) -> None:
110
+ """Write a default config the first time, so there is a file to edit. Never overwrites."""
111
+ if path.exists():
112
+ return
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ path.write_text(default_text(folder, language), encoding="utf-8")
115
+
116
+
117
+ def add_group(path: Path, name: str, color: str) -> str:
118
+ """Append a [groups.<key>] block as text (no TOML writer needed) and return the new key."""
119
+ existing = load(path).groups
120
+ base = slugify(name)
121
+ key, n = base, 2
122
+ while key in existing:
123
+ key, n = f"{base}-{n}", n + 1
124
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
125
+ block = f'\n[groups.{key}]\nname = {toml_str(name)}\ncolor = {toml_str(color)}\n'
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ path.write_text(text.rstrip("\n") + "\n" + block if text.strip() else block.lstrip("\n"), encoding="utf-8")
128
+ return key
129
+
130
+
131
+ def default_text(folder: Path, language: str) -> str:
132
+ tags = ", ".join(toml_str(t) for t in DEFAULT_TAGS)
133
+ return (
134
+ "# rumo config. Colors: " + ", ".join(PALETTE) + "\n"
135
+ f"folder = {toml_str(str(folder))}\n"
136
+ f"language = {toml_str(language)}\n"
137
+ f"tags = [{tags}]\n"
138
+ "\n"
139
+ "# Groups connect related items; the title takes the group color.\n"
140
+ "# [groups.auth]\n"
141
+ '# name = "Auth migration"\n'
142
+ '# color = "blue"\n'
143
+ )
144
+
145
+
146
+ def toml_str(value: str) -> str:
147
+ return json.dumps(value, ensure_ascii=False) # a JSON string is a valid TOML basic string
rumo/hook.py ADDED
@@ -0,0 +1,47 @@
1
+ """Claude Code `UserPromptSubmit` hook: remind a linked session to keep its item up to date.
2
+
3
+ The link is the session id written in an item's Sessions section (by `/rumo new` or
4
+ `/rumo link`). A line ending in `| closed` (from park/unlink) no longer links, and after
5
+ `/clear` the id changes, so the reminder stops on its own.
6
+
7
+ The reminder carries the update rules, so they reach the agent exactly when they matter and
8
+ nothing has to be added to the user's global instructions.
9
+ Runs on every prompt: stdlib only, UTF-8 regardless of the console, silent on anything unexpected.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+
17
+ from rumo import config, items
18
+
19
+ RULES = (
20
+ "When you finish this response, if anything changed (step done, new step, change of direction, "
21
+ "blocker), update the item with the Edit tool: rewrite Left off (the next concrete action), check "
22
+ "or add Steps (`- [ ] [agent] ...` or `- [ ] [manual] ...` for work done outside the agent; work "
23
+ "with more than one stage and anything not done now becomes a step) and set `updated`. "
24
+ "Conversation-only replies need no update. Write content in the configured language (`rumo index`); "
25
+ "keep keys, sections, statuses and tags in English. Never set `done` without every step checked and "
26
+ "the user's confirmation. To stop updating from this session, append `| closed` to its line in Sessions."
27
+ )
28
+
29
+
30
+ def main() -> None:
31
+ try:
32
+ payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
33
+ session_id = payload.get("session_id", "")
34
+ except (ValueError, OSError, AttributeError):
35
+ return
36
+ if not session_id:
37
+ return
38
+
39
+ linked = []
40
+ for item in items.list_items(config.load().folder):
41
+ if any(s.id == session_id and not s.closed for s in item.sessions):
42
+ linked.append(f"{item.path} (status: {item.status})")
43
+
44
+ if linked:
45
+ message = "This session is linked to rumo item(s): " + "; ".join(linked) + ". " + RULES + "\n"
46
+ sys.stdout.buffer.write(message.encode("utf-8"))
47
+ sys.stdout.flush()
rumo/items.py ADDED
@@ -0,0 +1,305 @@
1
+ """Items: one Markdown file per piece of work, in a single local folder.
2
+
3
+ There is no index or database. Every reader (the TUI, the hook, the agent) reads the
4
+ folder, and every write checks that the file did not change since it was read, so a
5
+ human in the TUI and an agent session never overwrite each other.
6
+
7
+ Stdlib only: the hook imports this module on every prompt.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shlex
15
+ import unicodedata
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from datetime import date, datetime, timedelta
19
+ from pathlib import Path
20
+
21
+ STATUSES = ("idea", "doing", "waiting", "paused", "done")
22
+
23
+ TIME_FORMAT = "%Y-%m-%d %H:%M"
24
+ PAST_WINDOW_DAYS = 14
25
+
26
+ _STEP_RE = re.compile(r"^- \[( |x|X)\] (?:\[(agent|manual)\] )?(.*)$")
27
+ _SECTION_RE = re.compile(r"^## (.+?)\s*$")
28
+
29
+
30
+ class WriteConflict(Exception):
31
+ """The file changed on disk after it was read."""
32
+
33
+
34
+ @dataclass
35
+ class Step:
36
+ done: bool
37
+ kind: str # "agent", "manual" or "" when the line has no marker
38
+ text: str
39
+ line: int # index of the line in the file
40
+
41
+
42
+ @dataclass
43
+ class Session:
44
+ when: str
45
+ repo: str
46
+ id: str
47
+ folder: str
48
+ closed: bool
49
+
50
+ @property
51
+ def resume_command(self) -> str:
52
+ return self.resume_command_for(windows=os.name == "nt")
53
+
54
+ def resume_command_for(self, windows: bool) -> str:
55
+ """A command that reopens the agent session from its original folder, in the user's shell."""
56
+ if windows: # PowerShell, where `&&` is missing before 7 and single quotes differ
57
+ return f'cd "{self.folder}"; claude --resume {self.id}'
58
+ return f"cd {shlex.quote(self.folder)} && claude --resume {self.id}"
59
+
60
+
61
+ @dataclass
62
+ class Item:
63
+ path: Path
64
+ mtime: float
65
+ meta: dict[str, str]
66
+ sections: dict[str, str]
67
+ steps: list[Step] = field(default_factory=list)
68
+ sessions: list[Session] = field(default_factory=list)
69
+
70
+ @property
71
+ def slug(self) -> str:
72
+ return self.path.stem
73
+
74
+ @property
75
+ def title(self) -> str:
76
+ return self.meta.get("title") or self.slug
77
+
78
+ @property
79
+ def status(self) -> str:
80
+ status = self.meta.get("status", "")
81
+ return status if status in STATUSES else "doing"
82
+
83
+ @property
84
+ def group(self) -> str:
85
+ return self.meta.get("group", "")
86
+
87
+ @property
88
+ def tags(self) -> list[str]:
89
+ return _parse_list(self.meta.get("tags", ""))
90
+
91
+ @property
92
+ def done_at(self) -> str:
93
+ """When the item was finished; hand-edited done items without `done:` fall back to `updated`."""
94
+ return self.meta.get("done") or self.meta.get("updated", "")
95
+
96
+ @property
97
+ def next_step(self) -> Step | None:
98
+ return next((s for s in self.steps if not s.done), None)
99
+
100
+
101
+ def load(path: Path) -> Item:
102
+ mtime = path.stat().st_mtime
103
+ lines = path.read_text(encoding="utf-8").splitlines()
104
+ meta, body_start = _read_frontmatter(lines)
105
+
106
+ sections: dict[str, list[str]] = {}
107
+ steps: list[Step] = []
108
+ sessions: list[Session] = []
109
+ current = ""
110
+ for i in range(body_start, len(lines)):
111
+ line = lines[i]
112
+ if m := _SECTION_RE.match(line):
113
+ current = m.group(1).strip().lower()
114
+ sections[current] = []
115
+ continue
116
+ if not current:
117
+ continue
118
+ sections[current].append(line)
119
+ if current == "steps" and (m := _STEP_RE.match(line)):
120
+ steps.append(Step(m.group(1) != " ", m.group(2) or "", m.group(3), i))
121
+ elif current == "sessions" and line.startswith("- "):
122
+ parts = [p.strip() for p in line[2:].split("|")]
123
+ if len(parts) >= 4:
124
+ closed = len(parts) >= 5 and parts[4].lower() == "closed"
125
+ sessions.append(Session(parts[0], parts[1], parts[2], parts[3], closed))
126
+
127
+ return Item(
128
+ path=path,
129
+ mtime=mtime,
130
+ meta=meta,
131
+ sections={k: "\n".join(v).strip() for k, v in sections.items()},
132
+ steps=steps,
133
+ sessions=sessions,
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class Timeline:
139
+ past: list[Item] # done, oldest first so the list flows into Now
140
+ older: int # done items hidden by the Past window
141
+ now: list[Item] # doing, then waiting
142
+ later: list[Item] # paused, then ideas
143
+
144
+
145
+ def timeline(all_items: list[Item], today: date | None = None, show_older: bool = False) -> Timeline:
146
+ cutoff = ((today or date.today()) - timedelta(days=PAST_WINDOW_DAYS)).isoformat()
147
+ done = sorted((i for i in all_items if i.status == "done"), key=lambda i: i.done_at)
148
+ past = done if show_older else [i for i in done if i.done_at[:10] >= cutoff]
149
+
150
+ def by_status(order: tuple[str, ...]) -> list[Item]:
151
+ chosen = sorted((i for i in all_items if i.status in order), key=lambda i: i.meta.get("updated", ""), reverse=True)
152
+ return sorted(chosen, key=lambda i: order.index(i.status)) # stable: keeps recency inside each status
153
+
154
+ return Timeline(past, len(done) - len(past), by_status(("doing", "waiting")), by_status(("paused", "idea")))
155
+
156
+
157
+ def index(all_items: list[Item]) -> str:
158
+ """One line per item, for an agent to judge similarity without opening every file."""
159
+ lines = []
160
+ for item in all_items:
161
+ parts = [item.slug, item.status, item.title]
162
+ if item.group:
163
+ parts.append(f"group: {item.group}")
164
+ if item.tags:
165
+ parts.append(f"tags: {', '.join(item.tags)}")
166
+ if left_off := item.sections.get("left off", "").split("\n")[0].strip():
167
+ parts.append(f"left off: {left_off[:120]}")
168
+ lines.append(" | ".join(parts))
169
+ return "\n".join(lines)
170
+
171
+
172
+ def list_items(folder: Path) -> list[Item]:
173
+ found = []
174
+ for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
175
+ try:
176
+ found.append(load(path))
177
+ except (OSError, UnicodeDecodeError):
178
+ continue # being written or unreadable: it shows up on the next read
179
+ return found
180
+
181
+
182
+ def slugify(title: str) -> str:
183
+ ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
184
+ return re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-") or "item"
185
+
186
+
187
+ def create_idea(folder: Path, title: str, now: datetime | None = None) -> Path:
188
+ """Create a small item with status `idea`; it grows into a full item when work starts."""
189
+ stamp = _stamp(now)
190
+ base = f"{stamp[:10]}-{slugify(title)}"
191
+ folder.mkdir(parents=True, exist_ok=True)
192
+ path, n = folder / f"{base}.md", 2
193
+ while path.exists():
194
+ path, n = folder / f"{base}-{n}.md", n + 1
195
+ text = f"---\ntitle: {title.strip()}\nstatus: idea\ntags: []\ncreated: {stamp}\nupdated: {stamp}\n---\n"
196
+ with path.open("x", encoding="utf-8") as f: # "x": never overwrite a file created meanwhile
197
+ f.write(text)
198
+ return path
199
+
200
+
201
+ def set_status(item: Item, status: str, now: datetime | None = None) -> None:
202
+ if status not in STATUSES:
203
+ raise ValueError(f"unknown status: {status}")
204
+ stamp = _stamp(now)
205
+
206
+ def edit(lines: list[str], _: int) -> None:
207
+ _set_meta(lines, "status", status)
208
+ _set_meta(lines, "done", stamp if status == "done" else None)
209
+
210
+ _save(item, edit, stamp)
211
+
212
+
213
+ def set_group(item: Item, group: str | None, now: datetime | None = None) -> None:
214
+ _save(item, lambda lines, _: _set_meta(lines, "group", group or None), _stamp(now))
215
+
216
+
217
+ def set_tags(item: Item, tags: list[str], now: datetime | None = None) -> None:
218
+ _save(item, lambda lines, _: _set_meta(lines, "tags", f"[{', '.join(tags)}]"), _stamp(now))
219
+
220
+
221
+ def toggle_step(item: Item, step: Step, now: datetime | None = None) -> None:
222
+ def edit(lines: list[str], offset: int) -> None:
223
+ i = step.line + offset
224
+ m = _STEP_RE.match(lines[i]) if i < len(lines) else None
225
+ if not m or m.group(3) != step.text:
226
+ raise WriteConflict(item.path.name)
227
+ mark = " " if m.group(1) != " " else "x"
228
+ lines[i] = f"- [{mark}] " + lines[i][6:]
229
+
230
+ _save(item, edit, _stamp(now))
231
+
232
+
233
+ def _stamp(now: datetime | None) -> str:
234
+ return (now or datetime.now()).strftime(TIME_FORMAT)
235
+
236
+
237
+ def _save(item: Item, edit: Callable[[list[str], int], None], stamp: str) -> None:
238
+ """Apply `edit` to the file's lines and bump `updated`, unless the file changed since it was read.
239
+
240
+ `edit` gets the offset to add to body line numbers recorded at load time: a file without
241
+ frontmatter gets one prepended, and an unclosed one gets its closing `---` inserted.
242
+ """
243
+ if item.path.stat().st_mtime != item.mtime:
244
+ raise WriteConflict(item.path.name)
245
+ lines = item.path.read_text(encoding="utf-8").splitlines()
246
+ offset = 0
247
+ if not lines or lines[0].strip() != "---":
248
+ lines[:0] = ["---", "---"]
249
+ offset = 2
250
+ else:
251
+ end, closed = _frontmatter_end(lines)
252
+ if not closed:
253
+ lines.insert(end, "---")
254
+ offset = 1
255
+ edit(lines, offset)
256
+ _set_meta(lines, "updated", stamp)
257
+ tmp = item.path.with_name(item.path.name + ".tmp")
258
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
259
+ os.replace(tmp, item.path)
260
+
261
+
262
+ def _frontmatter_end(lines: list[str]) -> tuple[int, bool]:
263
+ """Index of the line that ends the frontmatter (which starts at line 0) and whether it is a real `---`.
264
+
265
+ A hand edit may drop the closing `---`; then the frontmatter ends at the first line that
266
+ cannot be a `key: value` (a blank line or a section heading).
267
+ """
268
+ for i in range(1, len(lines)):
269
+ line = lines[i].strip()
270
+ if line == "---":
271
+ return i, True
272
+ if not line or line.startswith("#") or ":" not in line:
273
+ return i, False
274
+ return len(lines), False
275
+
276
+
277
+ def _set_meta(lines: list[str], key: str, value: str | None) -> None:
278
+ """Set (or remove, when value is None) a frontmatter key, keeping every other line."""
279
+ end, _ = _frontmatter_end(lines)
280
+ for i in range(1, end):
281
+ if lines[i].partition(":")[0].strip() == key:
282
+ if value is None:
283
+ del lines[i]
284
+ else:
285
+ lines[i] = f"{key}: {value}"
286
+ return
287
+ if value is not None:
288
+ lines.insert(end, f"{key}: {value}")
289
+
290
+
291
+ def _parse_list(value: str) -> list[str]:
292
+ value = value.strip().removeprefix("[").removesuffix("]")
293
+ return [v.strip() for v in value.split(",") if v.strip()]
294
+
295
+
296
+ def _read_frontmatter(lines: list[str]) -> tuple[dict[str, str], int]:
297
+ if not lines or lines[0].strip() != "---":
298
+ return {}, 0
299
+ end, closed = _frontmatter_end(lines)
300
+ meta: dict[str, str] = {}
301
+ for line in lines[1:end]:
302
+ key, sep, value = line.partition(":")
303
+ if sep:
304
+ meta[key.strip()] = value.strip()
305
+ return meta, end + 1 if closed else end
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: rumo-cli
3
+ Version: 0.1.0
4
+ Summary: Your dev work, past, now and later: Markdown files, a terminal Timeline, and AI agents that keep it current
5
+ Project-URL: Homepage, https://github.com/pedrobuzzi/rumo
6
+ Project-URL: Issues, https://github.com/pedrobuzzi/rumo/issues
7
+ Author: Pedro Buzzi
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agents,claude-code,markdown,productivity,tasks,todo,tui,worklog
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Scheduling
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: textual>=1.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # rumo
23
+
24
+ **Your work, past, now and later.** rumo is a local, file-based organizer for a developer's own
25
+ work. Every piece of work is a Markdown file on your machine. Run `rumo` to see it all as a
26
+ Timeline, and let your AI agent sessions keep it up to date while you work.
27
+
28
+ ![rumo Timeline](docs/timeline.svg)
29
+
30
+ - **Now**: what you are doing or waiting on, with the next step of each item.
31
+ - **Past**: what you finished in the last two weeks (answers "what did I do?" for a standup).
32
+ - **Later**: ideas and paused work, so nothing you meant to do gets lost.
33
+
34
+ Items can belong to a **group** (an initiative): the title takes the group color and the footer
35
+ shows a legend. The interface is in English; the content (titles, notes, steps) is in your own
36
+ language.
37
+
38
+ Nothing leaves your machine. There is no server, no database and no sync with Jira or ClickUp
39
+ (ask your agent to update those if you want).
40
+
41
+ ## Install
42
+
43
+ **1. The `rumo` command** (pick one):
44
+
45
+ ```bash
46
+ uv tool install rumo-cli # macOS, Linux, Windows (https://docs.astral.sh/uv/)
47
+ pipx install rumo-cli # macOS, Linux, Windows
48
+ brew install pedrobuzzi/tap/rumo # macOS, Linux
49
+ ```
50
+
51
+ Then run `rumo`. The first run creates `~/rumo/` for your items and a config file with your
52
+ system language.
53
+
54
+ **2. Your AI agent** (optional, but this is where rumo shines):
55
+
56
+ - **Claude Code**, full experience (the agent keeps linked items up to date):
57
+
58
+ ```
59
+ /plugin marketplace add pedrobuzzi/rumo
60
+ /plugin install rumo@rumo
61
+ ```
62
+
63
+ - **Codex, Gemini CLI, Cursor, GitHub Copilot and other agents** that read `SKILL.md` files,
64
+ simple mode (create items and ideas, list and search them; no automatic updates):
65
+
66
+ ```bash
67
+ npx skills add pedrobuzzi/rumo
68
+ ```
69
+
70
+ **Update**: `uv tool upgrade rumo-cli` (or `pipx upgrade rumo-cli`, `brew upgrade rumo`), and
71
+ `/plugin marketplace update rumo` in Claude Code.
72
+
73
+ **Uninstall**: `/plugin uninstall rumo@rumo`, then `uv tool uninstall rumo-cli` (or
74
+ `pipx uninstall rumo-cli`, `brew uninstall rumo`). Your items in `~/rumo/` are never touched.
75
+
76
+ ## Use
77
+
78
+ **In the terminal**: `rumo` opens the Timeline.
79
+
80
+ | Key | Action |
81
+ |---|---|
82
+ | ↑ ↓ | move through the Timeline |
83
+ | Tab | switch between Timeline, steps and sessions |
84
+ | `s` | change status (1-5 picks directly) |
85
+ | `g` | set the group, or create a new one with a color |
86
+ | `t` | toggle tags |
87
+ | `n` | capture a new idea |
88
+ | `e` | open the item in `$EDITOR` |
89
+ | space | check or uncheck the selected step |
90
+ | `c` | copy the command that resumes the selected agent session |
91
+ | `/` | filter by title, tag, group or text (Esc clears) |
92
+ | `q` | quit |
93
+
94
+ **In an agent session**: type `/rumo` for a menu that fits the moment, or go straight to:
95
+
96
+ | Command | What it does |
97
+ |---|---|
98
+ | `/rumo new` | Track this session's work as an item. Looks for a similar idea first and offers to promote it. |
99
+ | `/rumo idea <text>` | Note something for later without leaving the topic. |
100
+ | `/rumo link [item]` | Continue an existing item in this session. |
101
+ | `/rumo now` | List what is in progress, with the next step of each. |
102
+ | `/rumo find <text>` | Search past, current and future items. |
103
+ | `/rumo status` | Show where you left off on the linked item. |
104
+ | `/rumo step <text>` | Add a step to the linked item. |
105
+ | `/rumo park [reason]` | Pause the item with an accurate "Left off" and stop updating it here. |
106
+ | `/rumo done` | Finish the item (checks the steps, asks first). |
107
+ | `/rumo unlink` | Stop updating the item from this session. |
108
+
109
+ `link`, `status`, `step`, `park` and `unlink` need Claude Code with the plugin, because they rely on
110
+ its session id and hook. Other agents get the rest.
111
+
112
+ When you create an item or an idea, the agent checks whether it belongs to an existing group. If
113
+ other ungrouped items look like the same initiative, it suggests a new group (name and color) and
114
+ applies it only if you agree.
115
+
116
+ While a session is linked, the agent updates the item after every response in which something
117
+ changed. `/clear` starts a new, unlinked session; `claude --resume <id>` picks the old one up again.
118
+
119
+ ## Item format
120
+
121
+ ```markdown
122
+ ---
123
+ title: Redirect after logout
124
+ status: doing
125
+ group: auth
126
+ tags: [bug, frontend]
127
+ created: 2026-10-11 10:08
128
+ updated: 2026-10-11 16:30
129
+ ---
130
+
131
+ ## Left off
132
+ Point the redirect to the home page; the e2e test is still missing.
133
+
134
+ ## Context
135
+ Users land on a protected page after logging out.
136
+
137
+ ## Steps
138
+ - [x] [agent] Fix the logout handler
139
+ - [ ] [manual] Ask for a PR review
140
+ - [ ] [agent] e2e test for logout
141
+
142
+ ## Sessions
143
+ - 2026-10-11 10:08 | web-app (main) | 7134aad0-4ba6-4583-9503-c08e98d28270 | /Users/you/repo/web-app
144
+ ```
145
+
146
+ - Statuses: `idea`, `doing`, `waiting`, `paused`, `done`. `done: <date>` is set when an item is finished.
147
+ - Steps are `[agent]` (the AI can do it) or `[manual]` (you or someone else does it).
148
+ - An idea can be just the frontmatter; it grows when you start working on it.
149
+ - A session line ending in `| closed` no longer updates the item.
150
+
151
+ ## Config
152
+
153
+ `config.toml` lives in `~/.config/rumo/` on macOS and Linux (or `$XDG_CONFIG_HOME/rumo/`) and in
154
+ `%APPDATA%\rumo\` on Windows. `rumo index` prints its path.
155
+
156
+ ```toml
157
+ folder = "~/rumo"
158
+ language = "pt-BR" # language of the content the agent writes; defaults to your system language
159
+ tags = ["bug", "feature", "refactor", "research", "spike", "review",
160
+ "infra", "deploy", "meeting", "docs", "support", "tech-debt"]
161
+
162
+ [groups.auth]
163
+ name = "Auth migration"
164
+ color = "blue" # blue, green, yellow, orange, red, magenta, purple, cyan
165
+ ```
166
+
167
+ `RUMO_DIR` and `RUMO_CONFIG` override the folder and the config path.
168
+
169
+ ## Contributing
170
+
171
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the layout, tests, trying the plugin locally and how
172
+ releases work.
173
+
174
+ ## License
175
+
176
+ [MIT](LICENSE)
@@ -0,0 +1,11 @@
1
+ rumo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ rumo/app.py,sha256=kmPIdwlb1JLdvbgzw9B6QmWLTAKGizs914QyLiP1kIQ,19713
3
+ rumo/cli.py,sha256=ABnKtx_lSKdrGMHs6_15f_44heJmB_trf-JW3YEBwuY,2361
4
+ rumo/config.py,sha256=u4szi5GT0Sw1JhaSytaSfglMGMMrdVG6kGfExKrOLn8,5182
5
+ rumo/hook.py,sha256=6iXvUc3xtAUaM0VVZ78e_A4cZVnATu9hIyEInkeJxQw,2082
6
+ rumo/items.py,sha256=PqcvrFO3x-yuJMAv3hitEYjw86LaEzbmlJo2mjg5Lo4,10722
7
+ rumo_cli-0.1.0.dist-info/METADATA,sha256=dXYdMRgXERaw1AdM8pJGRksHZ-x_ULr8Dy7z4i493Do,6554
8
+ rumo_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ rumo_cli-0.1.0.dist-info/entry_points.txt,sha256=dgLTBmdLBqXNofuG0FF6vxkht27rMqF4R5Kg7R_Lve8,39
10
+ rumo_cli-0.1.0.dist-info/licenses/LICENSE,sha256=NJ_6PjU8Tz0ohCibbfwKuUgfqouLxiLbET5q27TyYfQ,1074
11
+ rumo_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rumo = rumo.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pedro Buzzi Filho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.