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.
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/PKG-INFO +13 -4
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/README.md +12 -3
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/pyproject.toml +1 -1
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/app.py +19 -14
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/cli.py +45 -1
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/prs.py +2 -2
- rumo_cli-0.5.0/src/rumo/sessions.py +324 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/.gitignore +0 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/LICENSE +0 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/__init__.py +0 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/config.py +0 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.0}/src/rumo/hook.py +0 -0
- {rumo_cli-0.4.0 → rumo_cli-0.5.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.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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
@@ -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
|
-
|
|
862
|
-
self.
|
|
863
|
-
|
|
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(
|
|
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
|
|
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
|
-
("
|
|
888
|
-
|
|
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
|
|
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}
|
|
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 = (
|
|
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
|
|
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
|