rumo-cli 0.4.0__tar.gz → 0.5.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.4.0
3
+ Version: 0.5.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
@@ -120,9 +120,18 @@ To get new plugin versions on their own, turn on auto-update for the `rumo` mark
120
120
  | `/rumo park [reason]` | Pause the item with an accurate "Left off" and stop updating it here. |
121
121
  | `/rumo done` | Finish the item (checks the steps, asks first). |
122
122
  | `/rumo unlink` | Stop updating the item from this session. |
123
-
124
- `link`, `status`, `park` and `unlink` need Claude Code with the plugin, because they rely on
125
- its session id and hook. Other agents get the rest.
123
+ | `/rumo sessions [7d]` | Catch up on the sessions open or active since the last review: create or update their items. |
124
+
125
+ `link`, `status`, `park`, `unlink` and `sessions` need Claude Code with the plugin, because they rely
126
+ on its session id and hook. Other agents get the rest.
127
+
128
+ `/rumo sessions` is for when you worked in several sessions without rumo. From any session, it reads
129
+ what each recent Claude Code session did (where it ran, what you asked, what it edited, the PRs it
130
+ opened) and matches it to an item: the one it is linked to, one that worked on the same branch, or one
131
+ about the same subject, before proposing a new item. Quick questions are proposed as ignored. You see
132
+ one short plan (update, link, create, ignore) and nothing is written until you say yes or adjust it.
133
+ Each review starts where the last one ended (3 days back the first time); `/rumo sessions 7d` looks
134
+ further back. Sessions run by scripts (`claude -p`) and in temporary folders are left out.
126
135
 
127
136
  When you create an item or an idea, the agent checks whether it belongs to an existing group. If
128
137
  other ungrouped items look like the same initiative, it suggests a new group (name and color) and
@@ -99,9 +99,18 @@ To get new plugin versions on their own, turn on auto-update for the `rumo` mark
99
99
  | `/rumo park [reason]` | Pause the item with an accurate "Left off" and stop updating it here. |
100
100
  | `/rumo done` | Finish the item (checks the steps, asks first). |
101
101
  | `/rumo unlink` | Stop updating the item from this session. |
102
-
103
- `link`, `status`, `park` and `unlink` need Claude Code with the plugin, because they rely on
104
- its session id and hook. Other agents get the rest.
102
+ | `/rumo sessions [7d]` | Catch up on the sessions open or active since the last review: create or update their items. |
103
+
104
+ `link`, `status`, `park`, `unlink` and `sessions` need Claude Code with the plugin, because they rely
105
+ on its session id and hook. Other agents get the rest.
106
+
107
+ `/rumo sessions` is for when you worked in several sessions without rumo. From any session, it reads
108
+ what each recent Claude Code session did (where it ran, what you asked, what it edited, the PRs it
109
+ opened) and matches it to an item: the one it is linked to, one that worked on the same branch, or one
110
+ about the same subject, before proposing a new item. Quick questions are proposed as ignored. You see
111
+ one short plan (update, link, create, ignore) and nothing is written until you say yes or adjust it.
112
+ Each review starts where the last one ended (3 days back the first time); `/rumo sessions 7d` looks
113
+ further back. Sessions run by scripts (`claude -p`) and in temporary folders are left out.
105
114
 
106
115
  When you create an item or an idea, the agent checks whether it belongs to an existing group. If
107
116
  other ungrouped items look like the same initiative, it suggests a new group (name and color) and
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rumo-cli"
3
- version = "0.4.0"
3
+ version = "0.5.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"
@@ -415,6 +415,11 @@ class KeyBar(Static):
415
415
  self.update(key_line(self.keys, self.content_size.width))
416
416
 
417
417
 
418
+ def wrapping_row(mark: Text, text: Text) -> ListItem:
419
+ """A list row whose text wraps, going on under itself rather than under the mark."""
420
+ return ListItem(Label(mark, classes="mark"), Label(text, classes="text"), classes="wrapping")
421
+
422
+
418
423
  class StepList(ListView):
419
424
  BINDINGS = [
420
425
  Binding("space", "app.toggle_step", "toggle step"),
@@ -463,6 +468,8 @@ class RumoApp(App):
463
468
  #header { margin-bottom: 1; }
464
469
  .section { text-style: bold; color: $accent; margin-top: 1; }
465
470
  #steps, #prs, #sessions { height: auto; }
471
+ .wrapping { layout: horizontal; }
472
+ .wrapping > .text, #sessions Label { width: 1fr; }
466
473
  Markdown { margin: 0; padding: 0; }
467
474
  #footer { height: auto; }
468
475
  #legend, #keys { height: auto; padding: 0 1; }
@@ -858,17 +865,16 @@ class RumoApp(App):
858
865
  body.update("\n\n".join(parts))
859
866
 
860
867
  self.shown = [s for s in item.steps if not (self.hide_done and s.done)]
861
- hidden = len(item.steps) - len(self.shown)
862
- self.query_one("#steps-title", Label).update(
863
- Text.assemble("Steps", (f" {hidden} done hidden · x shows them", "dim") if hidden else "")
864
- )
865
- steps.extend(ListItem(Label(self.step_label(s))) for s in self.shown)
868
+ done = sum(s.done for s in item.steps)
869
+ hint = f'{done} done hidden · "x" shows them' if self.hide_done else f'{done} done · "x" hides them'
870
+ self.query_one("#steps-title", Label).update(Text.assemble("Steps", (f" {hint}", "dim") if done else ""))
871
+ steps.extend(self.step_row(s) for s in self.shown)
866
872
  if has_prs:
867
873
  guessed = self.gh is None and any(not p.finished and "/pull/" in p.url for p in item.prs)
868
874
  self.query_one("#prs-title", Label).update(Text.assemble(
869
875
  "PRs", (" (enter opens" + (" · no gh: states as written" if guessed else " the link") + ")", "dim")
870
876
  ))
871
- pull_requests.extend(ListItem(Label(self.pr_label(p))) for p in item.prs)
877
+ pull_requests.extend(self.pr_row(p) for p in item.prs)
872
878
  self.highlight(pull_requests, pr_index or 0)
873
879
  sessions.extend(
874
880
  ListItem(Label(Text.assemble((s.when, "dim"), " ", s.repo, " ", (s.id[:8], "cyan"), (" closed" if s.closed else "", "dim"))))
@@ -880,20 +886,19 @@ class RumoApp(App):
880
886
  sessions.index = min(session_index or 0, len(item.sessions) - 1)
881
887
 
882
888
  @staticmethod
883
- def pr_label(pr: items.PR) -> Text:
889
+ def pr_row(pr: items.PR) -> ListItem:
884
890
  """`● web-app#12 Logout novo open`, the name linked to the PR."""
885
891
  color = PR_COLORS.get(pr.state, "dim")
886
- return Text.assemble(
887
- ("●" if pr.state else "?", color), " ", (pr.name, Style(link=pr.url)),
888
- f" {pr.title}" if pr.title else "", (f" {pr.state or 'state unknown'}", color),
889
- )
892
+ return wrapping_row(Text("● " if pr.state else "? ", color), Text.assemble(
893
+ (pr.name, Style(link=pr.url)), f" {pr.title}" if pr.title else "", (f" {pr.state or 'state unknown'}", color),
894
+ ))
890
895
 
891
896
  @staticmethod
892
- def step_label(step: items.Step) -> Text:
897
+ def step_row(step: items.Step) -> ListItem:
893
898
  kind = f"[{step.kind}] " if step.kind else ""
894
899
  if step.done:
895
- return Text(f"[x] {kind}{step.text}", "dim strike")
896
- return Text.assemble("[ ] ", (kind, "yellow" if step.kind == "manual" else "dim"), step.text)
900
+ return wrapping_row(Text(f"[x] {kind}", "dim strike"), Text(step.text, "dim strike"))
901
+ return wrapping_row(Text.assemble("[ ] ", (kind, "yellow" if step.kind == "manual" else "dim")), Text(step.text))
897
902
 
898
903
  # ---------- actions ----------
899
904
 
@@ -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`, `now`, `path`, `group-add`, `ask-to-track`, `--version`.
4
+ the help on purpose: `hook`, `index`, `now`, `sessions`, `path`, `group-add`, `ask-to-track`, `--version`.
5
5
  `index` reads the session id itself: Claude Code refuses to run a command that expands a variable
6
6
  without asking, so the skill only ever runs `rumo ...`.
7
7
  """
@@ -46,6 +46,8 @@ def main(argv: list[str] | None = None) -> None:
46
46
  _index(args[1:])
47
47
  elif command == "now":
48
48
  _now()
49
+ elif command == "sessions":
50
+ _sessions(args[1:])
49
51
  elif command == "group-add":
50
52
  _group_add(args[1:])
51
53
  elif command == "ask-to-track":
@@ -105,6 +107,48 @@ def _now() -> None:
105
107
  print("Nothing in progress.")
106
108
 
107
109
 
110
+ def _sessions(rest: list[str]) -> None:
111
+ """`rumo sessions [--since <N>d|<N>h]`: the Claude Code sessions active since the last review (3 days
112
+ back the first time), digested for `/rumo sessions`. `rumo sessions --reviewed "<time>"` saves the
113
+ `# now:` of the listing that was reviewed as where the next review starts.
114
+ """
115
+ import re
116
+ from datetime import datetime, timedelta
117
+
118
+ from rumo import config, items, sessions
119
+
120
+ usage = 'usage: rumo sessions [--since <N>d] | rumo sessions --reviewed "<YYYY-MM-DD HH:MM>"'
121
+ cfg = config.load()
122
+ if rest[:1] == ["--reviewed"]:
123
+ when = " ".join(rest[1:]).replace("T", " ")
124
+ try:
125
+ datetime.strptime(when, items.TIME_FORMAT)
126
+ except ValueError:
127
+ print(usage, file=sys.stderr)
128
+ raise SystemExit(2) from None
129
+ config.update_state(cfg.path, sessions_reviewed_at=when)
130
+ return
131
+ now = datetime.now().replace(second=0, microsecond=0)
132
+ if rest[:1] == ["--since"] and len(rest) == 2 and (m := re.fullmatch(r"(\d+)([dh])", rest[1])):
133
+ since, why = now - timedelta(**{"days" if m[2] == "d" else "hours": int(m[1])}), f"--since {rest[1]}"
134
+ elif rest:
135
+ print(usage, file=sys.stderr)
136
+ raise SystemExit(2)
137
+ else:
138
+ try:
139
+ since, why = datetime.strptime(str(config.load_state(cfg.path).get("sessions_reviewed_at")), items.TIME_FORMAT), "the last review"
140
+ except ValueError:
141
+ since, why = now - timedelta(days=sessions.FIRST_REVIEW_DAYS), f"{sessions.FIRST_REVIEW_DAYS} days ago: the first review"
142
+ claude = sessions.claude_dir()
143
+ found = sessions.recent(claude, since)
144
+ if found is None:
145
+ print(f"No Claude Code sessions found in {claude}.")
146
+ return
147
+ print(f"# since: {since:%Y-%m-%d %H:%M} ({why})")
148
+ print(f"# now: {now:%Y-%m-%d %H:%M}")
149
+ print("\n".join(sessions.digest(found, items.list_items(cfg.folder), config.load_state(cfg.path), now, cfg.folder)))
150
+
151
+
108
152
  def _session_line(session: str, folder: str) -> str:
109
153
  """The Sessions line of an item: when, the repository and branch of the folder, the session and the folder."""
110
154
  import subprocess
@@ -62,7 +62,7 @@ def check(item: items.Item, noted: list, run: Run, asked: dict | None = None) ->
62
62
  for folder, branch in branches(item, noted):
63
63
  if not os.path.isdir(folder):
64
64
  continue
65
- key = (_repository(folder), branch)
65
+ key = (repository(folder), branch)
66
66
  if key not in asked:
67
67
  asked[key] = _read(run(["pr", "list", "--head", branch, "--state", "all", "--json", f"{FIELDS},createdAt",
68
68
  "--limit", "20"], key[0]))
@@ -111,7 +111,7 @@ def _since(item: items.Item) -> str:
111
111
  return ""
112
112
 
113
113
 
114
- def _repository(folder: str) -> str:
114
+ def repository(folder: str) -> str:
115
115
  """The repository holding the folder, so a subfolder and the root ask gh once; the folder outside one."""
116
116
  start = Path(os.path.abspath(folder))
117
117
  return str(next((f for f in (start, *start.parents) if (f / ".git").exists()), start))
@@ -0,0 +1,324 @@
1
+ """The sessions Claude Code ran lately, digested for `rumo sessions`: where each one ran, what the user
2
+ asked, what it answered last, what it edited and which items it touches, so the agent can catch their
3
+ items up in one review (`/rumo sessions`).
4
+
5
+ Claude Code keeps a transcript per session in `<claude>/projects/<folder>/<session id>.jsonl` and a file
6
+ per running session in `<claude>/sessions/<pid>.json`. Neither format is documented: anything unreadable
7
+ is skipped, and without the registry no session counts as open.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import tempfile
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from rumo import hook, items, prs
21
+
22
+ FIRST_REVIEW_DAYS = 3
23
+ MIN_PROMPTS = 3 # fewer, with no file edited and few tool calls: a quick question, not work to track
24
+ MIN_TOOL_CALLS = 10
25
+ FULL = 20 # sessions shown whole; older ones come without prompts, answer and edits
26
+ LAST_PROMPTS = 5
27
+ PROMPT_CHARS = 200
28
+ ANSWER_CHARS = 400
29
+ EDITED = 8
30
+ NOT_TYPED = ("<local-command", "<task-notification", "<bash-stdout", "<bash-stderr", "[Request interrupted")
31
+ HOUSEKEEPING = { # Claude Code's own commands, which say nothing about the work
32
+ "clear", "compact", "context", "cost", "usage", "model", "effort", "fast", "config", "status", "help", "resume",
33
+ "login", "logout", "permissions", "memory", "mcp", "plugin", "reload-plugins", "hooks", "agents", "doctor",
34
+ "theme", "ide", "exit", "rewind",
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class Recorded:
40
+ """One session, as its transcript tells it."""
41
+
42
+ id: str
43
+ started: datetime
44
+ last: datetime
45
+ folder: str # where it started: `claude --resume` finds it from there
46
+ cwd: str # where it was last
47
+ branch: str
48
+ entrypoint: str = ""
49
+ title: str = ""
50
+ prompts: list[str] = field(default_factory=list)
51
+ answer: str = ""
52
+ edited: list[str] = field(default_factory=list) # the latest first
53
+ prs: list[str] = field(default_factory=list)
54
+ tool_calls: int = 0
55
+ open: bool = False
56
+
57
+ @property
58
+ def repo(self) -> str:
59
+ if not self.cwd:
60
+ return "-"
61
+ return os.path.basename(str(hook.repo_root(Path(self.cwd)) or self.cwd))
62
+
63
+
64
+ def claude_dir() -> Path:
65
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser()
66
+
67
+
68
+ def recent(claude: Path, since: datetime) -> list[Recorded] | None:
69
+ """The sessions worth reviewing with activity after `since`, newest first; None without Claude Code."""
70
+ projects = claude / "projects"
71
+ if not projects.is_dir():
72
+ return None
73
+ found: dict[str, Recorded] = {}
74
+ for path in projects.glob("*/*.jsonl"): # subagents live deeper: they are not sessions
75
+ try:
76
+ if path.stat().st_mtime < since.timestamp():
77
+ continue
78
+ except OSError:
79
+ continue
80
+ session = read(path)
81
+ if session and session.last > since and worth_reviewing(session):
82
+ if session.id not in found or found[session.id].last < session.last:
83
+ found[session.id] = session
84
+ running = open_ids(claude)
85
+ for session in found.values():
86
+ session.open = session.id in running
87
+ return sorted(found.values(), key=lambda s: s.last, reverse=True)
88
+
89
+
90
+ def worth_reviewing(session: Recorded) -> bool:
91
+ """Typed by a person (not `claude -p` or the SDK), outside a temporary folder, and more than a quick question."""
92
+ return (session.entrypoint in ("", "cli") and not temporary(session.folder)
93
+ and (len(session.prompts) >= MIN_PROMPTS or bool(session.edited) or session.tool_calls >= MIN_TOOL_CALLS))
94
+
95
+
96
+ def temporary(folder: str) -> bool:
97
+ path = os.path.realpath(folder)
98
+ roots = {os.path.realpath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/folders")}
99
+ return any(path == root or path.startswith(root.rstrip(os.sep) + os.sep) for root in roots)
100
+
101
+
102
+ def read(path: Path) -> Recorded | None:
103
+ """The session in a transcript, read line by line; None when it holds no message."""
104
+ session: Recorded | None = None
105
+ title = entrypoint = answer = ""
106
+ tool_calls = 0
107
+ prompts: list[str] = []
108
+ edited: dict[str, None] = {}
109
+ asked: dict[str, tuple[str, dict]] = {} # the calls that may open a PR, until their result comes
110
+ pr_calls: list[tuple[str, dict]] = []
111
+ pr_outputs: list[str] = []
112
+ try:
113
+ with path.open(encoding="utf-8", errors="replace") as f:
114
+ for line in f:
115
+ try:
116
+ entry = json.loads(line)
117
+ except ValueError:
118
+ continue
119
+ if not isinstance(entry, dict):
120
+ continue
121
+ if entry.get("type") == "ai-title":
122
+ title = " ".join(str(entry.get("aiTitle") or "").split())
123
+ if entry.get("type") not in ("user", "assistant") or (at := local_time(entry.get("timestamp"))) is None:
124
+ continue
125
+ cwd, branch = str(entry.get("cwd") or ""), str(entry.get("gitBranch") or "")
126
+ branch = "" if branch == "HEAD" else branch # what Claude Code says outside a branch
127
+ if session is None:
128
+ session = Recorded(str(entry.get("sessionId") or path.stem), at, at, cwd, cwd, branch)
129
+ session.last = max(session.last, at)
130
+ if cwd: # the branch belongs to the folder the entry was written in
131
+ session.cwd, session.branch = cwd, branch
132
+ session.folder = session.folder or cwd
133
+ entrypoint = entrypoint or str(entry.get("entrypoint") or "")
134
+ message = entry.get("message")
135
+ content = message.get("content") if isinstance(message, dict) else None
136
+ if entry["type"] == "assistant" and isinstance(content, list):
137
+ for block in content:
138
+ if not isinstance(block, dict):
139
+ continue
140
+ if block.get("type") == "text" and str(block.get("text", "")).strip():
141
+ answer = str(block["text"])
142
+ elif block.get("type") == "tool_use":
143
+ tool_calls += 1
144
+ name, tool_input = str(block.get("name", "")), block.get("input")
145
+ tool_input = tool_input if isinstance(tool_input, dict) else {}
146
+ file = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
147
+ if name in hook.EDIT_TOOLS and file and not agent_notes(file, path):
148
+ edited.pop(file, None)
149
+ edited[file] = None
150
+ if may_open_pr(name, tool_input):
151
+ asked[str(block.get("id"))] = (name, tool_input)
152
+ elif entry["type"] == "user" and isinstance(content, list) and not hook.is_prompt(content):
153
+ for block in content:
154
+ if isinstance(block, dict) and (call := asked.pop(str(block.get("tool_use_id")), None)):
155
+ pr_calls.append(call)
156
+ pr_outputs.append(hook.text_of(block.get("content")))
157
+ elif entry["type"] == "user" and (text := typed(entry)):
158
+ prompts.append(text)
159
+ except OSError:
160
+ return None
161
+ if session is None:
162
+ return None
163
+ session.title, session.entrypoint, session.prompts = title, entrypoint, prompts
164
+ session.answer, session.edited, session.tool_calls = one_line(answer), list(edited)[::-1], tool_calls
165
+ session.prs = [link for link, _ in hook.opened_prs(hook.Turn(None, pr_calls, pr_outputs))]
166
+ return session
167
+
168
+
169
+ def agent_notes(file: str, transcript: Path) -> bool:
170
+ """A file the agent keeps for itself (its memory, a scratchpad page), not work on the project."""
171
+ claude = transcript.parent.parent.parent
172
+ return temporary(file) or os.path.realpath(file).startswith(os.path.realpath(claude) + os.sep)
173
+
174
+
175
+ def may_open_pr(name: str, tool_input: dict) -> bool:
176
+ if hook.PR_TOOLS.search(name):
177
+ return True
178
+ command = str(tool_input.get("command", "")) if name == "Bash" else ""
179
+ return bool(command) and (hook.creates_pr(command) is not None or "git push" in command)
180
+
181
+
182
+ def typed(entry: dict) -> str:
183
+ """What the person typed in this entry, on one line ("" for tool results, notifications, skill text...)."""
184
+ origin = entry.get("origin")
185
+ if entry.get("isMeta") or entry.get("isCompactSummary") or (isinstance(origin, dict) and origin.get("kind") != "human"):
186
+ return ""
187
+ message = entry.get("message")
188
+ content = message.get("content") if isinstance(message, dict) else None
189
+ if not hook.is_prompt(content):
190
+ return ""
191
+ text = (content if isinstance(content, str)
192
+ else " ".join(str(b.get("text", "")) for b in content if isinstance(b, dict) and b.get("type") == "text")
193
+ if isinstance(content, list) else "").strip()
194
+ if command := re.search(r"<command-name>(.*?)</command-name>", text, re.S):
195
+ args = re.search(r"<command-args>(.*?)</command-args>", text, re.S)
196
+ name = command[1].strip().lstrip("/")
197
+ if name in HOUSEKEEPING:
198
+ return ""
199
+ text = f"/{name} {args[1].strip() if args else ''}"
200
+ elif bash := re.fullmatch(r"<bash-input>(.*)</bash-input>", text, re.S):
201
+ text = f"! {bash[1]}"
202
+ elif text.startswith(NOT_TYPED):
203
+ return ""
204
+ return one_line(re.sub(r"<pasted_content[^>]*>.*?</pasted_content[^>]*>", "[pasted text]", text, flags=re.S))
205
+
206
+
207
+ def one_line(text: str) -> str:
208
+ return " ".join(text.split())
209
+
210
+
211
+ def cut(text: str, size: int) -> str:
212
+ return text if len(text) <= size else text[: size - 1] + "…"
213
+
214
+
215
+ def local_time(value: object) -> datetime | None:
216
+ """A transcript time (UTC) as the local time items are written in, without a zone."""
217
+ at = hook.parse_time(value)
218
+ return at.astimezone().replace(tzinfo=None) if at and at.tzinfo else at
219
+
220
+
221
+ def open_ids(claude: Path) -> set[str]:
222
+ """The sessions whose process is still running, from Claude Code's registry."""
223
+ running: set[str] = set()
224
+ for path in (claude / "sessions").glob("*.json"):
225
+ try:
226
+ data = json.loads(path.read_text(encoding="utf-8"))
227
+ except (OSError, ValueError):
228
+ continue
229
+ if isinstance(data, dict) and isinstance(data.get("pid"), int) and alive(data["pid"]):
230
+ running.add(str(data.get("sessionId") or ""))
231
+ return running
232
+
233
+
234
+ def alive(pid: int) -> bool:
235
+ if pid <= 0:
236
+ return False
237
+ if os.name == "nt": # os.kill would end the process there
238
+ import ctypes
239
+
240
+ kernel32 = getattr(ctypes, "windll").kernel32
241
+ handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
242
+ if not handle:
243
+ return False
244
+ code = ctypes.c_ulong()
245
+ try:
246
+ return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
247
+ finally:
248
+ kernel32.CloseHandle(handle)
249
+ try:
250
+ os.kill(pid, 0)
251
+ except PermissionError:
252
+ return True # someone else's process
253
+ except OSError:
254
+ return False
255
+ return True
256
+
257
+
258
+ # ---------- the digest ----------
259
+
260
+
261
+ def digest(found: list[Recorded], all_items: list[items.Item], state: dict, now: datetime, folder: Path) -> list[str]:
262
+ """The lines `rumo sessions` prints after its header: one block per session. Edits to the items (in
263
+ `folder`) are rumo's own bookkeeping, not work."""
264
+ kept = os.path.realpath(folder)
265
+ for session in found:
266
+ session.edited = [f for f in session.edited if os.path.dirname(os.path.realpath(f)) != kept]
267
+ lines = [f"# sessions: {len(found)} ({sum(s.open for s in found)} open)"
268
+ + (f"; the {len(found) - FULL} oldest without prompts, last answer and edits" if len(found) > FULL else "")]
269
+ for n, session in enumerate(found):
270
+ lines += block(session, all_items, state, now, brief=n >= FULL)
271
+ return lines
272
+
273
+
274
+ def block(session: Recorded, all_items: list[items.Item], state: dict, now: datetime, brief: bool) -> list[str]:
275
+ last = session.last.strftime(items.TIME_FORMAT)
276
+ where = f"{session.repo} ({session.branch or '-'})"
277
+ linked = [i for i in all_items if i.linked_to(session.id)]
278
+ closed = [i for i in all_items if i not in linked and any(s.id == session.id for s in i.sessions)]
279
+ lines = [f"## {session.id}" + (" open" if session.open else ""),
280
+ f"- last: {last} · started {session.started.strftime(items.TIME_FORMAT)}",
281
+ f"- where: {where} · {session.folder}"]
282
+ if session.title:
283
+ lines.append(f"- title: {session.title}")
284
+ if linked:
285
+ lines.append("- linked: " + ", ".join(i.slug + (" (up to date)" if i.meta.get("updated", "") >= last else "") for i in linked))
286
+ if closed:
287
+ lines.append("- closed: " + ", ".join(i.slug for i in closed))
288
+ if same := same_branch(session, [i for i in all_items if i not in linked and i not in closed], state):
289
+ lines.append(f"- same branch: {', '.join(i.slug for i in same)} ({session.branch})")
290
+ if not brief:
291
+ if session.prompts:
292
+ first, rest = session.prompts[0], session.prompts[1:][-LAST_PROMPTS:]
293
+ lines.append(f'- prompts ({len(session.prompts)}): first "{cut(first, PROMPT_CHARS)}"'
294
+ + (" · last: " + " / ".join(f'"{cut(p, PROMPT_CHARS)}"' for p in rest) if rest else ""))
295
+ if session.answer:
296
+ lines.append(f'- last answer: "{cut(session.answer, ANSWER_CHARS)}"')
297
+ if session.edited:
298
+ shown = [relative(f, session) for f in session.edited[:EDITED]]
299
+ more = len(session.edited) - EDITED
300
+ lines.append(f"- edited: {', '.join(shown)}" + (f" (+{more} more)" if more > 0 else ""))
301
+ if session.prs:
302
+ lines.append(f"- prs: {', '.join(session.prs)}")
303
+ lines.append(f"- line: - {now.strftime(items.TIME_FORMAT)} | {where} | {session.id} | {session.folder}")
304
+ return lines
305
+
306
+
307
+ def same_branch(session: Recorded, candidates: list[items.Item], state: dict) -> list[items.Item]:
308
+ """The items not done whose work happened on this session's repository and branch (not a shared one)."""
309
+ if session.branch in prs.SHARED_BRANCHES or session.branch in ("", "-", "HEAD"):
310
+ return []
311
+ here = (prs.repository(session.cwd), session.branch)
312
+ return [i for i in candidates if i.status != "done" and any(
313
+ (prs.repository(folder), branch) == here for folder, branch in prs.branches(i, prs.noted_branches(state, str(i.path))))]
314
+
315
+
316
+ def relative(file: str, session: Recorded) -> str:
317
+ """The file from the root of the repository the session was last in (or its folder), when inside it."""
318
+ if not session.cwd:
319
+ return file
320
+ root = hook.repo_root(Path(session.cwd)) or hook.repo_root(Path(session.folder)) or Path(session.cwd)
321
+ try:
322
+ return str(Path(file).relative_to(root))
323
+ except ValueError:
324
+ return file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes