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.
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/PKG-INFO +18 -8
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/README.md +17 -7
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/pyproject.toml +1 -1
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/src/rumo/app.py +41 -29
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/src/rumo/cli.py +15 -1
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/src/rumo/config.py +50 -0
- rumo_cli-0.3.0/src/rumo/hook.py +294 -0
- rumo_cli-0.2.1/src/rumo/hook.py +0 -60
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/.gitignore +0 -0
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/LICENSE +0 -0
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/src/rumo/__init__.py +0 -0
- {rumo_cli-0.2.1 → rumo_cli-0.3.0}/src/rumo/items.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: rumo-cli
|
|
3
|
-
Version: 0.
|
|
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,
|
|
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
|
-
| `
|
|
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.
|
|
124
|
-
|
|
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
|
|
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,
|
|
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
|
-
| `
|
|
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.
|
|
103
|
-
|
|
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
|
|
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
|
|
|
@@ -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"), ("
|
|
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
|
|
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("
|
|
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(
|
|
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.
|
|
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
|
|
573
|
-
|
|
574
|
-
text.append(" " if horizon ==
|
|
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
|
|
663
|
-
"""`
|
|
664
|
-
self.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
|
|
669
|
-
#
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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()
|
rumo_cli-0.2.1/src/rumo/hook.py
DELETED
|
@@ -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
|