rumo-cli 0.2.1__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rumo-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Your dev work, past, now and later: Markdown files, a terminal Timeline, and AI agents that keep it current
5
5
  Project-URL: Homepage, https://github.com/pedrobuzzi/rumo
6
6
  Project-URL: Issues, https://github.com/pedrobuzzi/rumo/issues
@@ -33,7 +33,7 @@ Timeline, and let your AI agent sessions keep it up to date while you work.
33
33
 
34
34
  Items can belong to a **group** (a project or initiative), shown as a label with its name on its
35
35
  color; color in rumo only ever means the group. One item is one line, plus its next step. rumo shows
36
- one horizon at a time (Now, or Past with `p`, or Later with `l`) in three views: the **Timeline**,
36
+ one horizon at a time (`h` steps Now, Later, Past), named in a banner atop the list, in three views: the **Timeline**,
37
37
  **Projects** (one block per group) and **Repos** (one block per repository of the item's latest
38
38
  agent session). The interface is in English; the content (titles, notes, steps) is in your own
39
39
  language.
@@ -71,7 +71,9 @@ system language.
71
71
  ```
72
72
 
73
73
  **Update**: `uv tool upgrade rumo-cli` (or `pipx upgrade rumo-cli`, `brew upgrade rumo`), and
74
- `/plugin marketplace update rumo` in Claude Code.
74
+ `/plugin marketplace update rumo` in Claude Code, then `/reload-plugins` in sessions already open.
75
+ To get new plugin versions on their own, turn on auto-update for the `rumo` marketplace in `/plugin`
76
+ (Marketplaces tab); the `rumo` command updates with your package manager.
75
77
 
76
78
  **Uninstall**: `/plugin uninstall rumo@rumo`, then `uv tool uninstall rumo-cli` (or
77
79
  `pipx uninstall rumo-cli`, `brew uninstall rumo`). Your items in `~/rumo/` are never touched.
@@ -85,7 +87,7 @@ system language.
85
87
  | ↑ ↓ | move through the list |
86
88
  | Tab | switch between the list, steps and sessions |
87
89
  | `v` | next view: Timeline, Projects, Repos (`1` `2` `3` go straight to one; rumo remembers it) |
88
- | `p` / `l` | show Past / Later; press again to go back to Now |
90
+ | `h` | next horizon: Now, then Later, then Past, then Now again |
89
91
  | `s` | change status (1-5 picks directly) |
90
92
  | `g` | set the group or create one; inside, `r` renames, `c` recolors and `d` deletes the highlighted group (its items stay) |
91
93
  | `t` | toggle tags |
@@ -120,8 +122,15 @@ applies it only if you agree.
120
122
 
121
123
  Steps are the heart of an item: while a session is linked, the agent checks off what got done, adds
122
124
  what came up and keeps the next action as the first open step, after every response in which work
123
- happened. Left off is one line and Context at most three. `/clear` starts a new, unlinked session;
124
- `claude --resume <id>` picks the old one up again.
125
+ happened. If a turn did work and no linked item changed, the plugin reminds the agent before the turn
126
+ ends. Open steps are never deleted unless you ask, so half-done work stays in Now. One session can
127
+ follow several topics: when you start something no linked item covers, the agent asks once whether to
128
+ track it as a new item, add it as a step, or leave it out. Left off is one line and Context at most
129
+ three. `/clear` starts a new, unlinked session; `claude --resume <id>` picks the old one up again.
130
+
131
+ In a session without an item, once it edits files, the agent asks whether to track it. The first time
132
+ it also asks whether to keep asking in future sessions (`ask_to_track` in the config). If you say no,
133
+ it reminds you that the option exists every 15 days or 15 sessions.
125
134
 
126
135
  ## Item format
127
136
 
@@ -165,14 +174,15 @@ folder = "~/rumo"
165
174
  language = "pt-BR" # language of the content the agent writes; defaults to your system language
166
175
  tags = ["bug", "feature", "refactor", "research", "spike", "review",
167
176
  "infra", "deploy", "meeting", "docs", "support", "tech-debt"]
177
+ ask_to_track = true # offer rumo in agent sessions without an item; unset until you first answer
168
178
 
169
179
  [groups.auth]
170
180
  name = "Auth migration"
171
181
  color = "blue" # blue, green, yellow, orange, red, magenta, purple, cyan
172
182
  ```
173
183
 
174
- Groups appear in the order of this file in every view. The last view is remembered in
175
- `state.json`, next to `config.toml`.
184
+ Groups appear in the order of this file in every view. The last view, and when the agent last
185
+ asked about tracking, are remembered in `state.json`, next to `config.toml`.
176
186
 
177
187
  `RUMO_DIR` and `RUMO_CONFIG` override the folder and the config path.
178
188
 
@@ -12,7 +12,7 @@ Timeline, and let your AI agent sessions keep it up to date while you work.
12
12
 
13
13
  Items can belong to a **group** (a project or initiative), shown as a label with its name on its
14
14
  color; color in rumo only ever means the group. One item is one line, plus its next step. rumo shows
15
- one horizon at a time (Now, or Past with `p`, or Later with `l`) in three views: the **Timeline**,
15
+ one horizon at a time (`h` steps Now, Later, Past), named in a banner atop the list, in three views: the **Timeline**,
16
16
  **Projects** (one block per group) and **Repos** (one block per repository of the item's latest
17
17
  agent session). The interface is in English; the content (titles, notes, steps) is in your own
18
18
  language.
@@ -50,7 +50,9 @@ system language.
50
50
  ```
51
51
 
52
52
  **Update**: `uv tool upgrade rumo-cli` (or `pipx upgrade rumo-cli`, `brew upgrade rumo`), and
53
- `/plugin marketplace update rumo` in Claude Code.
53
+ `/plugin marketplace update rumo` in Claude Code, then `/reload-plugins` in sessions already open.
54
+ To get new plugin versions on their own, turn on auto-update for the `rumo` marketplace in `/plugin`
55
+ (Marketplaces tab); the `rumo` command updates with your package manager.
54
56
 
55
57
  **Uninstall**: `/plugin uninstall rumo@rumo`, then `uv tool uninstall rumo-cli` (or
56
58
  `pipx uninstall rumo-cli`, `brew uninstall rumo`). Your items in `~/rumo/` are never touched.
@@ -64,7 +66,7 @@ system language.
64
66
  | ↑ ↓ | move through the list |
65
67
  | Tab | switch between the list, steps and sessions |
66
68
  | `v` | next view: Timeline, Projects, Repos (`1` `2` `3` go straight to one; rumo remembers it) |
67
- | `p` / `l` | show Past / Later; press again to go back to Now |
69
+ | `h` | next horizon: Now, then Later, then Past, then Now again |
68
70
  | `s` | change status (1-5 picks directly) |
69
71
  | `g` | set the group or create one; inside, `r` renames, `c` recolors and `d` deletes the highlighted group (its items stay) |
70
72
  | `t` | toggle tags |
@@ -99,8 +101,15 @@ applies it only if you agree.
99
101
 
100
102
  Steps are the heart of an item: while a session is linked, the agent checks off what got done, adds
101
103
  what came up and keeps the next action as the first open step, after every response in which work
102
- happened. Left off is one line and Context at most three. `/clear` starts a new, unlinked session;
103
- `claude --resume <id>` picks the old one up again.
104
+ happened. If a turn did work and no linked item changed, the plugin reminds the agent before the turn
105
+ ends. Open steps are never deleted unless you ask, so half-done work stays in Now. One session can
106
+ follow several topics: when you start something no linked item covers, the agent asks once whether to
107
+ track it as a new item, add it as a step, or leave it out. Left off is one line and Context at most
108
+ three. `/clear` starts a new, unlinked session; `claude --resume <id>` picks the old one up again.
109
+
110
+ In a session without an item, once it edits files, the agent asks whether to track it. The first time
111
+ it also asks whether to keep asking in future sessions (`ask_to_track` in the config). If you say no,
112
+ it reminds you that the option exists every 15 days or 15 sessions.
104
113
 
105
114
  ## Item format
106
115
 
@@ -144,14 +153,15 @@ folder = "~/rumo"
144
153
  language = "pt-BR" # language of the content the agent writes; defaults to your system language
145
154
  tags = ["bug", "feature", "refactor", "research", "spike", "review",
146
155
  "infra", "deploy", "meeting", "docs", "support", "tech-debt"]
156
+ ask_to_track = true # offer rumo in agent sessions without an item; unset until you first answer
147
157
 
148
158
  [groups.auth]
149
159
  name = "Auth migration"
150
160
  color = "blue" # blue, green, yellow, orange, red, magenta, purple, cyan
151
161
  ```
152
162
 
153
- Groups appear in the order of this file in every view. The last view is remembered in
154
- `state.json`, next to `config.toml`.
163
+ Groups appear in the order of this file in every view. The last view, and when the agent last
164
+ asked about tracking, are remembered in `state.json`, next to `config.toml`.
155
165
 
156
166
  `RUMO_DIR` and `RUMO_CONFIG` override the folder and the config path.
157
167
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rumo-cli"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Your dev work, past, now and later: Markdown files, a terminal Timeline, and AI agents that keep it current"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
6
  import re
8
7
  import shlex
@@ -30,6 +29,12 @@ from rumo.items import Item, WriteConflict
30
29
  SHAPES = {"doing": "●", "waiting": "◐", "idea": "○", "paused": "◌", "done": "✓"}
31
30
  VIEWS = {"timeline": "Timeline", "projects": "Projects", "repos": "Repos"}
32
31
  HORIZONS: tuple[str, ...] = ("past", "now", "later")
32
+ CYCLE = ("now", "later", "past") # what `h` steps through, starting where rumo opens
33
+ HORIZON_SAYS = {
34
+ "now": "doing and waiting",
35
+ "later": "ideas and paused work",
36
+ "past": f"done in the last {items.PAST_WINDOW_DAYS} days",
37
+ }
33
38
  UNGROUPED = "-" # the "No group" or "No repo" block, which always comes last
34
39
  MAX_LABEL = 16 # the project label fits the longest group name up to this many characters
35
40
  NEW_GROUP = "+new"
@@ -39,7 +44,7 @@ KEYS = Text.assemble(
39
44
  *(
40
45
  part
41
46
  for key, label in [
42
- ("v", "view"), ("p", "past"), ("l", "later"), ("s", "status"), ("g", "group"), ("t", "tags"),
47
+ ("v", "view"), ("h", "now/later/past"), ("s", "status"), ("g", "group"), ("t", "tags"),
43
48
  ("n", "new idea"), ("e", "edit"), ("space", "step"), ("c", "copy resume"), ("/", "filter"), ("q", "quit"),
44
49
  ]
45
50
  for part in ((key, "bold"), (f" {label} ", "dim"))
@@ -65,7 +70,7 @@ def short_date(stamp: str) -> str:
65
70
 
66
71
  EMPTY = {
67
72
  "past": f"Nothing finished in the last {items.PAST_WINDOW_DAYS} days.",
68
- "now": "Nothing in progress. Press l to see what is next.",
73
+ "now": "Nothing in progress. Press h to see what is next.",
69
74
  "later": "Nothing for later. Press n to capture an idea.",
70
75
  }
71
76
 
@@ -318,8 +323,7 @@ class RumoApp(App):
318
323
  Binding("1", "view('timeline')", show=False),
319
324
  Binding("2", "view('projects')", show=False),
320
325
  Binding("3", "view('repos')", show=False),
321
- Binding("p", "horizon('past')", "past"),
322
- Binding("l", "horizon('later')", "later"),
326
+ Binding("h", "next_horizon", "horizon"),
323
327
  Binding("s", "status", "status"),
324
328
  Binding("g", "group", "group"),
325
329
  Binding("t", "tags", "tags"),
@@ -425,8 +429,10 @@ class RumoApp(App):
425
429
  build = {"timeline": self.timeline_rows, "projects": self.project_rows, "repos": self.repo_rows}[self.view]
426
430
  if not self.items:
427
431
  options = [self.message("No items yet. Press n to capture an idea, or run /rumo new in an agent session.")]
432
+ elif filtering:
433
+ options = build(tl, shown) or [self.message("No matches.")]
428
434
  else:
429
- options = build(tl, shown) or [self.message("No matches." if filtering else EMPTY[self.horizon])]
435
+ options = [self.banner(tl), *(build(tl, shown) or [self.message(EMPTY[self.horizon])])]
430
436
 
431
437
  timeline.clear_options()
432
438
  timeline.add_options(options)
@@ -455,7 +461,7 @@ class RumoApp(App):
455
461
  if len(shown) > 1:
456
462
  if not its and not older:
457
463
  continue
458
- rows.append(Option(Text(horizon.capitalize(), "bold"), id=f"header:{horizon}", disabled=True))
464
+ rows.append(Option(Text(f" {horizon.upper()} ", "bold reverse"), id=f"header:{horizon}", disabled=True))
459
465
  if older:
460
466
  rows.append(self.older_option(older))
461
467
  rows += [self.item_option(i) for i in its]
@@ -569,11 +575,28 @@ class RumoApp(App):
569
575
  def horizon_tabs(self, tl: items.Timeline) -> Text:
570
576
  counts = {horizon: len(its) for horizon, its in self.horizons(tl).items()}
571
577
  text = Text(" ")
572
- for horizon, key in (("past", "p "), ("now", ""), ("later", "l ")):
573
- text.append(f"{key}{horizon.capitalize()} {counts[horizon]}", "bold" if horizon == self.horizon else "dim")
574
- text.append(" " if horizon == "later" else " ")
578
+ for horizon in HORIZONS:
579
+ name = horizon.upper() if horizon == self.horizon else horizon.capitalize()
580
+ text.append(f"{name} {counts[horizon]}", "bold" if horizon == self.horizon else "dim")
581
+ text.append(" " if horizon == HORIZONS[-1] else " ")
575
582
  return text
576
583
 
584
+ def banner(self, tl: items.Timeline) -> Option:
585
+ """The list's first row: which horizon this is, what it holds, and where `h` goes next."""
586
+ count = len(self.horizons(tl)[self.horizon])
587
+ following = CYCLE[(CYCLE.index(self.horizon) + 1) % len(CYCLE)]
588
+ grid = Table.grid(padding=(0, 1), expand=True)
589
+ grid.add_column(no_wrap=True)
590
+ grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
591
+ grid.add_column(no_wrap=True, justify="right")
592
+ grid.add_row(
593
+ Text(f" {self.horizon.upper()} ", "bold reverse"),
594
+ Text(f"{plural(count, 'item')} · {HORIZON_SAYS[self.horizon]}", "dim"),
595
+ Text.assemble(("h", "bold"), (f" ▸ {following.capitalize()}", "dim")),
596
+ )
597
+ grid.add_row("", "", "")
598
+ return Option(grid, id=f"horizon:{self.horizon}", disabled=True)
599
+
577
600
  def legend(self) -> Text:
578
601
  text = Text()
579
602
  if self.cfg.error:
@@ -659,31 +682,20 @@ class RumoApp(App):
659
682
  self.build_timeline()
660
683
  self.show_detail()
661
684
 
662
- def action_horizon(self, horizon: str) -> None:
663
- """`p` shows Past and `p` again goes back to Now; `l` does the same for Later."""
664
- self.horizon = "now" if self.horizon == horizon else horizon
685
+ def action_next_horizon(self) -> None:
686
+ """`h` steps Now -> Later -> Past -> Now."""
687
+ self.horizon = CYCLE[(CYCLE.index(self.horizon) + 1) % len(CYCLE)]
665
688
  self.build_timeline()
666
689
  self.show_detail()
667
690
 
668
- # The view survives restarts, in a file of its own: config.toml only ever changes when the
669
- # user edits a group.
670
- def state_path(self) -> Path:
671
- return self.config_path.parent / "state.json"
672
-
691
+ # The view survives restarts, in state.json: config.toml only ever changes when the user
692
+ # edits a group.
673
693
  def load_state(self) -> None:
674
- try:
675
- data = json.loads(self.state_path().read_text(encoding="utf-8"))
676
- except (OSError, ValueError):
677
- return
678
- if isinstance(data, dict) and data.get("view") in VIEWS:
679
- self.view = data["view"]
694
+ if (view := config.load_state(self.config_path).get("view")) in VIEWS:
695
+ self.view = view
680
696
 
681
697
  def save_state(self) -> None:
682
- state = {"view": self.view}
683
- try:
684
- self.state_path().write_text(json.dumps(state) + "\n", encoding="utf-8")
685
- except OSError:
686
- pass # remembering is a convenience; never fail an action over it
698
+ config.update_state(self.config_path, view=self.view)
687
699
 
688
700
  def action_toggle_step(self) -> None:
689
701
  item = self.current_item()
@@ -1,7 +1,7 @@
1
1
  """The `rumo` command: opens the Timeline.
2
2
 
3
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`.
4
+ the help on purpose: `hook`, `index`, `path`, `group-add`, `ask-to-track`, `--version`.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -37,6 +37,8 @@ def main(argv: list[str] | None = None) -> None:
37
37
  _index()
38
38
  elif command == "group-add":
39
39
  _group_add(args[1:])
40
+ elif command == "ask-to-track":
41
+ _ask_to_track(args[1:])
40
42
  elif command in ("-V", "--version"):
41
43
  from importlib.metadata import version
42
44
 
@@ -73,5 +75,17 @@ def _group_add(rest: list[str]) -> None:
73
75
  print(config.add_group(config.load().path, rest[0].strip(), rest[1]))
74
76
 
75
77
 
78
+ def _ask_to_track(rest: list[str]) -> None:
79
+ """`rumo ask-to-track true|false`: whether the hook offers rumo in agent sessions without an item."""
80
+ from rumo import config
81
+
82
+ if len(rest) != 1 or rest[0] not in ("true", "false"):
83
+ print("usage: rumo ask-to-track <true|false>", file=sys.stderr)
84
+ raise SystemExit(2)
85
+ path = config.load().path
86
+ config.set_ask_to_track(path, rest[0] == "true")
87
+ print(f"ask_to_track = {rest[0]} in {path}")
88
+
89
+
76
90
  if __name__ == "__main__":
77
91
  main()
@@ -3,6 +3,7 @@
3
3
  folder = "~/rumo"
4
4
  language = "pt-BR" # language of the content the agent writes
5
5
  tags = ["bug", "feature"] # fixed list the agent picks from
6
+ ask_to_track = true # in agent sessions without an item, offer to track them (unset: ask once)
6
7
 
7
8
  [groups.auth]
8
9
  name = "Migração auth" # content: any language
@@ -57,6 +58,7 @@ class Config:
57
58
  language: str = "en"
58
59
  tags: list[str] = field(default_factory=lambda: list(DEFAULT_TAGS))
59
60
  groups: dict[str, Group] = field(default_factory=dict)
61
+ ask_to_track: bool | None = None # None: the user was never asked
60
62
  error: str | None = None
61
63
 
62
64
 
@@ -90,6 +92,8 @@ def load(path: Path | None = None) -> Config:
90
92
  cfg.language = language.strip()
91
93
  if isinstance(tags := data.get("tags"), list):
92
94
  cfg.tags = [str(t) for t in tags]
95
+ if isinstance(ask := data.get("ask_to_track"), bool):
96
+ cfg.ask_to_track = ask
93
97
  groups = data.get("groups")
94
98
  for key, value in (groups.items() if isinstance(groups, dict) else []):
95
99
  if isinstance(value, dict):
@@ -129,6 +133,29 @@ def add_group(path: Path, name: str, color: str) -> str:
129
133
  return key
130
134
 
131
135
 
136
+ def set_ask_to_track(path: Path, value: bool) -> None:
137
+ """Write `ask_to_track` as a top-level key: replace it in place, or add it before the first table.
138
+
139
+ Without a config file, write only this key: the hook that records the answer may not see the
140
+ system language, so the defaults are left to the first `rumo` run or to `load`.
141
+ """
142
+ new_line = f"ask_to_track = {'true' if value else 'false'}\n"
143
+ if not path.exists():
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ path.write_text(new_line, encoding="utf-8")
146
+ return
147
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
148
+ if lines and not lines[-1].endswith("\n"):
149
+ lines[-1] += "\n"
150
+ first_table = next((i for i, line in enumerate(lines) if _TABLE_RE.match(line)), len(lines))
151
+ found = next((i for i in range(first_table) if re.match(r"^\s*ask_to_track\s*=", lines[i])), None)
152
+ if found is not None:
153
+ lines[found] = new_line
154
+ else:
155
+ lines.insert(_content_end(lines, -1, first_table), new_line)
156
+ path.write_text("".join(lines), encoding="utf-8")
157
+
158
+
132
159
  class GroupNotEditable(Exception):
133
160
  """The group has no `[groups.<key>]` table in the plain form these text edits understand."""
134
161
 
@@ -197,5 +224,28 @@ def default_text(folder: Path, language: str) -> str:
197
224
  )
198
225
 
199
226
 
227
+ def state_path(config_path: Path) -> Path:
228
+ """Small state rumo writes for itself (the TUI's view, the hook's counters), apart from the user's config."""
229
+ return config_path.parent / "state.json"
230
+
231
+
232
+ def load_state(config_path: Path) -> dict:
233
+ try:
234
+ data = json.loads(state_path(config_path).read_text(encoding="utf-8"))
235
+ except (OSError, ValueError):
236
+ return {}
237
+ return data if isinstance(data, dict) else {}
238
+
239
+
240
+ def update_state(config_path: Path, **changes: object) -> None:
241
+ """Merge `changes` into state.json. Never fails: remembering is a convenience."""
242
+ try:
243
+ path = state_path(config_path)
244
+ path.parent.mkdir(parents=True, exist_ok=True)
245
+ path.write_text(json.dumps(load_state(config_path) | changes) + "\n", encoding="utf-8")
246
+ except OSError:
247
+ pass
248
+
249
+
200
250
  def toml_str(value: str) -> str:
201
251
  return json.dumps(value, ensure_ascii=False) # a JSON string is a valid TOML basic string
@@ -0,0 +1,294 @@
1
+ """Claude Code hooks: keep the items a session works on up to date, and offer rumo for untracked work.
2
+
3
+ The plugin sends two events to `rumo hook`:
4
+
5
+ - UserPromptSubmit: remind a linked session of its items and the update rules. The link is the
6
+ session id written in an item's Sessions section (by `/rumo new` or `/rumo link`). A line
7
+ ending in `| closed` (from park/unlink) no longer links, and after `/clear` the id changes, so
8
+ the reminder stops on its own.
9
+ - Stop: at the end of a turn that did work, check that it reached the items. A linked session
10
+ whose items did not change gets one more nudge. A session without items that edited files is
11
+ asked once whether to track it (see `untracked`).
12
+
13
+ The messages carry the rules, so they reach the agent exactly when they matter and nothing has to be
14
+ added to the user's global instructions. Runs on every prompt and every stop: stdlib only, UTF-8
15
+ regardless of the console, silent on anything unexpected.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ from collections.abc import Iterator
25
+ from dataclasses import dataclass, field
26
+ from datetime import date, datetime
27
+ from pathlib import Path
28
+
29
+ from rumo import config, items
30
+
31
+ RULES = (
32
+ "Steps are what matters. Before you finish this response, if any work happened, edit the item it "
33
+ "belongs to with the Edit tool: in Steps, check what is done, add a step for anything new or left for "
34
+ "later (`- [ ] [agent] ...`, or `- [ ] [manual] ...` for what the user or someone else does), reword "
35
+ "steps that changed, and keep the very next action as the first open step. Never delete an open step "
36
+ "unless the user explicitly asks; switching topics never closes, parks or deletes anything. If the user "
37
+ "starts work that belongs to none of these items, ask once, in one line at the end of your response: "
38
+ "track it as a new item ({create}; this session links to it too), add it as a step to one of these, "
39
+ "or not track it. One short line per step, starting with a verb. Then set `updated`; rewrite Left off "
40
+ "(one line: where it stands or what blocks it) only if that changed, and keep Context to at most 3 "
41
+ "lines. Conversation-only replies need no update. Write content in the configured language (`rumo "
42
+ "index`); keep keys, sections, statuses and tags in English. Never set `done` without every step "
43
+ "checked and the user's confirmation. To stop updating from this session, append `| closed` to its "
44
+ "line in Sessions."
45
+ )
46
+ SHOWN_STEPS = 3
47
+
48
+ WORK_TOOLS = {"Edit", "Write", "NotebookEdit", "Bash"} # a linked turn with these should reach its items
49
+ EDIT_TOOLS = {"Edit", "Write", "NotebookEdit"} # an untracked session is worth asking about once it edits
50
+ ASK_AGAIN_DAYS = 15 # after the user said not to be asked, remind them the option exists
51
+ ASK_AGAIN_SESSIONS = 15
52
+ REMEMBERED_SESSIONS = 200 # sessions already asked or counted, so each one is asked at most once
53
+
54
+ # Shell commands that only look around: a turn that ran nothing else did no work to record.
55
+ LOOK_COMMANDS = {
56
+ "ls", "cat", "head", "tail", "less", "wc", "pwd", "echo", "printf", "grep", "rg", "find", "fd", "tree",
57
+ "which", "type", "file", "stat", "du", "df", "date", "env", "printenv", "diff", "cd",
58
+ }
59
+ LOOK_SUBCOMMANDS = {
60
+ "git": {"status", "diff", "log", "show", "branch", "rev-parse", "remote", "blame", "ls-files"},
61
+ "rumo": {"index", "path", "--version"},
62
+ }
63
+
64
+
65
+ def open_steps(item: items.Item) -> str:
66
+ """The next open steps, so the agent sees what to check off without opening the file."""
67
+ pending = [s for s in item.steps if not s.done]
68
+ if not pending:
69
+ return "Open steps: none."
70
+ shown = [(f"[{s.kind}] " if s.kind else "") + (s.text if len(s.text) <= 80 else s.text[:79] + "…") for s in pending[:SHOWN_STEPS]]
71
+ more = len(pending) - SHOWN_STEPS
72
+ return "Open steps: " + "; ".join(shown + ([f"(+{more} more)"] if more > 0 else [])) + "."
73
+
74
+
75
+ def how_to_create() -> str:
76
+ """The agent cannot run the /rumo skill itself, but it can follow it when the plugin says where it is."""
77
+ root = os.environ.get("CLAUDE_PLUGIN_ROOT")
78
+ skill = Path(root) / "skills" / "rumo" / "SKILL.md" if root else None
79
+ if skill and skill.is_file():
80
+ return f"create it the way `/rumo new` does, following the `new` section of {skill}"
81
+ return "ask them to type `/rumo new`"
82
+
83
+
84
+ def linked_items(cfg: config.Config, session_id: str) -> list[items.Item]:
85
+ return [
86
+ item for item in items.list_items(cfg.folder)
87
+ if any(s.id == session_id and not s.closed for s in item.sessions)
88
+ ]
89
+
90
+
91
+ def reminder(cfg: config.Config, session_id: str) -> str:
92
+ linked = [f"{item.path} (status: {item.status}). {open_steps(item)}" for item in linked_items(cfg, session_id)]
93
+ if not linked:
94
+ return ""
95
+ return "This session is linked to rumo item(s): " + " ".join(linked) + " " + RULES.format(create=how_to_create()) + "\n"
96
+
97
+
98
+ @dataclass
99
+ class Turn:
100
+ """What the agent did since the user's last prompt."""
101
+
102
+ started: datetime | None = None # None: the prompt was not found
103
+ tools: list[tuple[str, dict]] = field(default_factory=list)
104
+
105
+
106
+ def last_turn(transcript: Path) -> Turn:
107
+ """Read the transcript backwards up to the last prompt, collecting the tools used after it."""
108
+ tools: list[tuple[str, dict]] = []
109
+ for line in reverse_lines(transcript):
110
+ try:
111
+ entry = json.loads(line)
112
+ except ValueError:
113
+ continue
114
+ message = entry.get("message") if isinstance(entry, dict) else None
115
+ content = message.get("content") if isinstance(message, dict) else None
116
+ if entry.get("type") == "assistant" and isinstance(content, list):
117
+ for block in reversed(content):
118
+ if isinstance(block, dict) and block.get("type") == "tool_use":
119
+ tool_input = block.get("input")
120
+ tools.append((str(block.get("name", "")), tool_input if isinstance(tool_input, dict) else {}))
121
+ elif entry.get("type") == "user" and not entry.get("isMeta") and is_prompt(content):
122
+ return Turn(parse_time(entry.get("timestamp")), tools[::-1])
123
+ return Turn(None, tools[::-1])
124
+
125
+
126
+ def is_prompt(content: object) -> bool:
127
+ """A user entry the person typed (or a notification that starts a turn), not a tool result."""
128
+ if isinstance(content, str):
129
+ return True
130
+ return isinstance(content, list) and bool(content) and not any(
131
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
132
+ )
133
+
134
+
135
+ def parse_time(value: object) -> datetime | None:
136
+ try:
137
+ return datetime.fromisoformat(str(value))
138
+ except ValueError:
139
+ return None
140
+
141
+
142
+ def reverse_lines(path: Path, block: int = 1 << 16) -> Iterator[str]:
143
+ """The file's lines from last to first, without reading a long transcript whole."""
144
+ with path.open("rb") as f:
145
+ position = f.seek(0, os.SEEK_END)
146
+ rest = b""
147
+ while position > 0:
148
+ size = min(block, position)
149
+ position -= size
150
+ f.seek(position)
151
+ lines = (f.read(size) + rest).split(b"\n")
152
+ rest = lines.pop(0)
153
+ for line in reversed(lines):
154
+ if line.strip():
155
+ yield line.decode("utf-8", "replace")
156
+ if rest.strip():
157
+ yield rest.decode("utf-8", "replace")
158
+
159
+
160
+ def changed_since(path: Path, since: datetime | None) -> bool:
161
+ if since is None:
162
+ return True # cannot tell: never nag on a guess
163
+ try:
164
+ return path.stat().st_mtime >= since.timestamp()
165
+ except OSError:
166
+ return False
167
+
168
+
169
+ def stop(cfg: config.Config, session_id: str, payload: dict) -> str:
170
+ if payload.get("stop_hook_active"):
171
+ return "" # this stop already comes from a nudge: never insist twice in a row
172
+ transcript = payload.get("transcript_path")
173
+ if not transcript:
174
+ return ""
175
+ turn = last_turn(Path(transcript))
176
+ linked = linked_items(cfg, session_id)
177
+ if not linked:
178
+ return untracked(cfg, session_id, turn)
179
+ if not any(name in WORK_TOOLS and not only_looks(name, tool_input) for name, tool_input in turn.tools):
180
+ return ""
181
+ if any(changed_since(item.path, turn.started) for item in linked):
182
+ return ""
183
+ if asks_the_user(payload.get("last_assistant_message")):
184
+ return "" # the agent waits for an answer (maybe whether to track this); the next reminder follows up
185
+ return (
186
+ f"rumo: this turn did work, but no linked item changed ({', '.join(str(i.path) for i in linked)}). "
187
+ "Update the Steps of the item this work belongs to: check what is done, add what is new or left for "
188
+ "later, never delete open steps. If the work belongs to none of them, ask the user in one line: track "
189
+ "it as a new item, add it as a step to one of these, or not track it. If they already chose not to "
190
+ "track it, or nothing worth recording happened, change nothing. Either way, never repeat or summarize "
191
+ "your previous answer, and end without any further text."
192
+ )
193
+
194
+
195
+ def only_looks(name: str, tool_input: dict) -> bool:
196
+ """A Bash call whose every command only reads (ls, git status, ...) and writes no file."""
197
+ if name != "Bash":
198
+ return False
199
+ command = re.sub(r"\d?>\s*/dev/null|\d>&\d", "", str(tool_input.get("command", "")))
200
+ if ">" in command or re.search(r"\s-(delete|exec|execdir)\b", command):
201
+ return False
202
+ for part in re.split(r"&&|\|\||[;|\n]", command):
203
+ words = [w for w in part.split() if not re.match(r"^\w+=", w)] # skip FOO=1 prefixes
204
+ if not words:
205
+ continue
206
+ program, rest = words[0], words[1:]
207
+ if program in LOOK_SUBCOMMANDS:
208
+ if not rest or rest[0] not in LOOK_SUBCOMMANDS[program]:
209
+ return False
210
+ elif program not in LOOK_COMMANDS:
211
+ return False
212
+ return True
213
+
214
+
215
+ def asks_the_user(answer: object) -> bool:
216
+ """Whether the agent's final message asks something (any script's question mark)."""
217
+ return isinstance(answer, str) and any(mark in answer for mark in ("?", "?", "؟"))
218
+
219
+
220
+ def untracked(cfg: config.Config, session_id: str, turn: Turn) -> str:
221
+ """Ask a session without items, once, whether to track it; `ask_to_track` says how often to ask."""
222
+ folder = cfg.folder.resolve()
223
+ edited = [
224
+ Path(str(target)) for name, tool_input in turn.tools if name in EDIT_TOOLS
225
+ if (target := tool_input.get("file_path") or tool_input.get("notebook_path"))
226
+ ]
227
+ if not any(not path.resolve().is_relative_to(folder) for path in edited):
228
+ return "" # nothing edited, or only items (an idea noted with /rumo idea)
229
+
230
+ state = config.load_state(cfg.path).get("ask_to_track")
231
+ state = state if isinstance(state, dict) else {}
232
+ seen = [s for s in state.get("seen", []) if isinstance(s, str)]
233
+ if session_id in seen:
234
+ return ""
235
+ state["seen"] = (seen + [session_id])[-REMEMBERED_SESSIONS:]
236
+
237
+ today = date.today()
238
+ head = "rumo: this session changed files, but no rumo item tracks it. "
239
+ track = f"should this work be tracked as a rumo item (if yes, {how_to_create()})"
240
+ message = ""
241
+ if cfg.ask_to_track is None:
242
+ message = (
243
+ head + f"Ask the user once, in one short line in their language: {track}, and should you ask like "
244
+ "this in future sessions without an item? Record that second answer with `rumo ask-to-track true` "
245
+ "or `rumo ask-to-track false`. Do not ask again in this session."
246
+ )
247
+ state.update(last_asked=today.isoformat(), sessions=0)
248
+ elif cfg.ask_to_track:
249
+ message = (
250
+ head + f"Ask the user once, in one short line in their language: {track}? If they say to stop "
251
+ "asking, run `rumo ask-to-track false`. Do not ask again in this session."
252
+ )
253
+ else:
254
+ try:
255
+ last = date.fromisoformat(str(state.get("last_asked")))
256
+ except ValueError:
257
+ last = today
258
+ sessions = state.get("sessions")
259
+ sessions = (sessions if isinstance(sessions, int) else 0) + 1
260
+ if sessions >= ASK_AGAIN_SESSIONS or (today - last).days >= ASK_AGAIN_DAYS:
261
+ message = (
262
+ head + "The user chose not to be asked; it has been a while, so remind them once that the "
263
+ f"option exists, in one short line in their language: {track}, and do they want to be asked "
264
+ "again in sessions without an item? If they do, run `rumo ask-to-track true`; otherwise change "
265
+ "nothing. Do not ask again in this session."
266
+ )
267
+ last, sessions = today, 0
268
+ state.update(last_asked=last.isoformat(), sessions=sessions)
269
+ config.update_state(cfg.path, ask_to_track=state)
270
+ return message
271
+
272
+
273
+ def main() -> None:
274
+ try:
275
+ payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
276
+ session_id = payload.get("session_id", "")
277
+ event = payload.get("hook_event_name") or "UserPromptSubmit"
278
+ except (ValueError, OSError, AttributeError):
279
+ return
280
+ if not session_id:
281
+ return
282
+
283
+ cfg = config.load()
284
+ if event == "Stop":
285
+ try:
286
+ message = stop(cfg, session_id, payload)
287
+ except Exception: # a hook must never break the session; stay silent instead
288
+ return
289
+ output = json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": message}}) if message else ""
290
+ else:
291
+ output = reminder(cfg, session_id)
292
+ if output:
293
+ sys.stdout.buffer.write(output.encode("utf-8"))
294
+ sys.stdout.flush()
@@ -1,60 +0,0 @@
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
- "Steps are what matters. Before you finish this response, if any work happened, edit the item "
21
- "with the Edit tool: in Steps, check what is done, add a step for anything new or left for later "
22
- "(`- [ ] [agent] ...`, or `- [ ] [manual] ...` for what the user or someone else does), reword steps "
23
- "that changed, and keep the very next action as the first open step. One short line per step, "
24
- "starting with a verb. Then set `updated`; rewrite Left off (one line: where it stands or what "
25
- "blocks it) only if that changed, and keep Context to at most 3 lines. Conversation-only replies "
26
- "need no update. Write content in the configured language (`rumo index`); keep keys, sections, "
27
- "statuses and tags in English. Never set `done` without every step checked and the user's "
28
- "confirmation. To stop updating from this session, append `| closed` to its line in Sessions."
29
- )
30
- SHOWN_STEPS = 3
31
-
32
-
33
- def open_steps(item: items.Item) -> str:
34
- """The next open steps, so the agent sees what to check off without opening the file."""
35
- pending = [s for s in item.steps if not s.done]
36
- if not pending:
37
- return "Open steps: none."
38
- shown = [(f"[{s.kind}] " if s.kind else "") + (s.text if len(s.text) <= 80 else s.text[:79] + "…") for s in pending[:SHOWN_STEPS]]
39
- more = len(pending) - SHOWN_STEPS
40
- return "Open steps: " + "; ".join(shown + ([f"(+{more} more)"] if more > 0 else [])) + "."
41
-
42
-
43
- def main() -> None:
44
- try:
45
- payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
46
- session_id = payload.get("session_id", "")
47
- except (ValueError, OSError, AttributeError):
48
- return
49
- if not session_id:
50
- return
51
-
52
- linked = []
53
- for item in items.list_items(config.load().folder):
54
- if any(s.id == session_id and not s.closed for s in item.sessions):
55
- linked.append(f"{item.path} (status: {item.status}). {open_steps(item)}")
56
-
57
- if linked:
58
- message = "This session is linked to rumo item(s): " + " ".join(linked) + " " + RULES + "\n"
59
- sys.stdout.buffer.write(message.encode("utf-8"))
60
- sys.stdout.flush()
File without changes
File without changes
File without changes
File without changes