ticketrail 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ticketrail/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """A work tracker for coding-agent sessions (Claude Code, Codex CLI): work items by ticket or topic, with status."""
2
+ __version__ = "0.1.0"
ticketrail/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
ticketrail/cli.py ADDED
@@ -0,0 +1,396 @@
1
+ """ticketrail — a work tracker for coding-agent sessions: Claude Code and Codex CLI sessions of a workspace grouped
2
+ into work items by ticket or topic, with their status, links, and one-key resume.
3
+
4
+ ticketrail [pick] [query] interactive picker (Textual); opens the chosen session
5
+ ticketrail list [--days N] [--task KEY] [--all]
6
+ ticketrail find <words…> [--deep] [--brief] [--limit N]
7
+ ticketrail show <id-prefix | words in the name>
8
+ ticketrail index [--out FILE] markdown index of sessions by ticket
9
+ ticketrail label [--limit N] LLM labels (topic, role, status) for idle sessions
10
+ ticketrail dump [--json] groups as text
11
+ ticketrail config show|init effective configuration / write a starter .ticketrail.toml
12
+ ticketrail doctor check paths, tools and caches
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import collections
18
+ import datetime
19
+ import json
20
+ import os
21
+ import shlex
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+
26
+ from . import __version__, config, digests, groups as G, labeler as L
27
+ from .i18n import t
28
+ from .sources import SOURCES, claude
29
+ from .util import fmt_date, fmt_dt, fmt_epoch_date, one_line, rel_path
30
+
31
+
32
+ def resume_cmds(d):
33
+ if d["tool"] == "codex":
34
+ return "codex resume %s" % d["id"], "codex fork %s" % d["id"]
35
+ return "claude --resume %s" % d["id"], "claude --fork-session --resume %s" % d["id"]
36
+
37
+
38
+ # ---------- list / find / show / index ----------
39
+ def cmd_list(args):
40
+ ds = digests.load_digests(args.source)
41
+ cutoff = None
42
+ if not args.all:
43
+ cutoff = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=args.days)).isoformat()
44
+ rows = []
45
+ for d in ds:
46
+ if cutoff and (d["last_ts"] or "") < cutoff:
47
+ continue
48
+ if args.task:
49
+ k = args.task.upper()
50
+ if k not in d["tickets"] and k not in digests.title_of(d).upper():
51
+ continue
52
+ rows.append(d)
53
+ rows.sort(key=lambda d: d["last_ts"] or "", reverse=True)
54
+ print("%-13s %-6s %-8s %-4s %-6s %-55s %s" % (t("cli.h_date"), "tool", "id", "#p", "KB", t("cli.h_name"), t("cli.h_tickets")))
55
+ for d in rows:
56
+ a, b = fmt_date(d["first_ts"]), fmt_date(d["last_ts"])
57
+ rng = a + ("→" + b if b != a else "")
58
+ name = ("★ " if d["title_custom"] else " ") + one_line(digests.title_of(d), 52)
59
+ print("%-13s %-6s %-8s %-4d %-6d %-55s %s" % (rng, d["tool"], d["id"][:8], d["n_prompts"], d["size_kb"], name, " ".join(d["tickets"])))
60
+ print("\n" + t("cli.list_footer", n=len(rows)))
61
+
62
+
63
+ def cmd_find(args):
64
+ query = " ".join(args.query).strip()
65
+ if not query:
66
+ sys.exit(t("cli.no_query"))
67
+ files = digests.list_files(args.source)
68
+ paths = [p for _, p in files]
69
+ matched = []
70
+ try:
71
+ for i in range(0, len(paths), 500): # stay under the argument-length limit
72
+ r = subprocess.run(["grep", "-l", "-i", "-F", "--", query] + paths[i:i + 500], capture_output=True, text=True)
73
+ matched += [ln for ln in r.stdout.splitlines() if ln]
74
+ except FileNotFoundError:
75
+ matched = paths
76
+ tool_of = {p: name for name, p in files}
77
+ by_path = {d["path"]: d for d in digests.load_digests(args.source)}
78
+ results = []
79
+ for p in matched:
80
+ d = by_path.get(p)
81
+ if not d:
82
+ continue
83
+ h = SOURCES[tool_of[p]].hits(p, query, args.deep)
84
+ total = sum(h.values())
85
+ if total:
86
+ results.append((total, h, d))
87
+ if args.brief:
88
+ results.sort(key=lambda r: r[2]["last_ts"] or "", reverse=True)
89
+ else:
90
+ results.sort(key=lambda r: (r[0], r[2]["last_ts"] or ""), reverse=True)
91
+ results = results[: args.limit]
92
+
93
+ print(t("cli.find_head", q=query, n=len(results), deep=" " + t("cli.find_deep") if args.deep else ""))
94
+ for total, h, d in results:
95
+ print("\n%s %s %s → %s %s%s" % (d["tool"], d["id"][:8], fmt_dt(d["first_ts"]), fmt_dt(d["last_ts"]),
96
+ "★ " if d["title_custom"] else "", digests.title_of(d)))
97
+ print(" " + t("cli.find_hits", total=total, p=h["prompt"], a=h["assistant"], tl=h["tool_input"],
98
+ r=(" · result %d" % h["tool_result"]) if args.deep else "", n=d["n_prompts"], kb=d["size_kb"],
99
+ branch=(" branch=" + d["branch"]) if d.get("branch") and d["branch"] != "HEAD" else ""))
100
+ if d["prompts"]:
101
+ print(" %s: %s" % (t("cli.first"), one_line(d["prompts"][0]["text"], 160)))
102
+ if d["last_assistant"]:
103
+ print(" %s: %s" % (t("cli.conclusion"), one_line(d["last_assistant"], 400 if args.brief else 300)))
104
+
105
+ # Claude sessions removed by retention: only their prompts are left in history.jsonl
106
+ if not args.brief and "claude" in digests.source_names(args.source):
107
+ ondisk = {d["id"] for d in by_path.values()}
108
+ gone = []
109
+ for sid, e in claude.deleted_history(config.current()).items():
110
+ if sid in ondisk:
111
+ continue
112
+ hits = [p for p in e["prompts"] if query.lower() in p.lower()]
113
+ if hits:
114
+ gone.append((e["first_ms"] or 0, sid, hits))
115
+ if gone:
116
+ print("\n" + t("cli.deleted_head", n=len(gone)))
117
+ for ms, sid, hits in sorted(gone, reverse=True)[:15]:
118
+ day = fmt_epoch_date(ms / 1000) if ms else "??"
119
+ print(" %s %s %s" % (day, (sid or "")[:8], one_line(hits[0], 120)))
120
+ if results:
121
+ r, fk = resume_cmds(results[0][2])
122
+ print("\n" + t("cli.resume_line", r=r, f=fk))
123
+
124
+
125
+ def cmd_show(args):
126
+ key = args.key.strip()
127
+ ds = digests.load_digests(args.source)
128
+ cands = [d for d in ds if d["id"].startswith(key)] or [d for d in ds if key.lower() in digests.title_of(d).lower()]
129
+ if not cands:
130
+ sys.exit(t("cli.no_match", key=key))
131
+ if len(cands) > 1:
132
+ print(t("cli.many"))
133
+ for d in sorted(cands, key=lambda d: d["last_ts"] or "", reverse=True):
134
+ print(" %s %s %s %s" % (d["tool"], d["id"][:8], fmt_date(d["first_ts"]), digests.title_of(d)))
135
+ return
136
+ d = cands[0]
137
+ print("# %s%s" % ("★ " if d["title_custom"] else "", digests.title_of(d)))
138
+ print("%-9s: %s" % (t("cli.tool"), d["tool"]))
139
+ print("id : %s" % d["id"])
140
+ print("%-9s: %s → %s (%d prompt, %d KB)" % (t("cli.time"), fmt_dt(d["first_ts"]), fmt_dt(d["last_ts"]), d["n_prompts"], d["size_kb"]))
141
+ if d.get("branch") or d.get("cwd"):
142
+ print("branch : %s cwd: %s" % (d.get("branch"), d.get("cwd")))
143
+ if d["tickets"]:
144
+ print("%-9s: %s" % (t("cli.h_tickets"), ", ".join("%s×%d" % kv for kv in d["tickets"].items())))
145
+ if d["plan_file"]:
146
+ print("plan : %s" % d["plan_file"])
147
+ print("\n## " + t("cli.prompts"))
148
+ for p in d["prompts"]:
149
+ print("- [%s] %s" % (fmt_dt(p["ts"]), p["text"]))
150
+ if d["files_written"]:
151
+ print("\n## " + t("cli.files", n=len(d["files_written"])) + (" — " + t("cli.codex_files") if d["tool"] == "codex" else ""))
152
+ for fp in d["files_written"]:
153
+ print("- " + rel_path(fp))
154
+ if d["memory_files"]:
155
+ print("\n## " + t("cli.memory"))
156
+ for fp in d["memory_files"]:
157
+ print("- " + os.path.basename(fp))
158
+ if d["artifacts"]:
159
+ print("\n## " + t("cli.artifacts"))
160
+ for a in d["artifacts"]:
161
+ print("- %s — %s" % (a.get("title"), a.get("url")))
162
+ if d["last_assistant"]:
163
+ print("\n## " + t("cli.final") + "\n" + d["last_assistant"].strip())
164
+ r, fk = resume_cmds(d)
165
+ print("\n%-6s: %s\n%-6s: %s" % (t("cli.resume"), r, t("cli.fork"), fk))
166
+
167
+
168
+ def cmd_index(args):
169
+ cfg = config.current()
170
+ ds = digests.load_digests(args.source)
171
+ by_ticket = collections.defaultdict(list)
172
+ no_ticket = []
173
+ for d in ds:
174
+ keys = set(d["tickets"]) | set(cfg.tickets.find(digests.title_of(d)))
175
+ if keys:
176
+ for k in keys:
177
+ by_ticket[k].append(d)
178
+ else:
179
+ no_ticket.append(d)
180
+
181
+ def row(d, with_range=True):
182
+ rng = fmt_date(d["first_ts"])
183
+ if with_range and fmt_date(d["last_ts"]) != fmt_date(d["first_ts"]):
184
+ rng += "→" + fmt_date(d["last_ts"])
185
+ first = one_line(d["prompts"][0]["text"], 110).replace("|", "\\|") if d["prompts"] else ""
186
+ return "| %s | %s | `%s` | %s%s | %s |" % (rng, d["tool"], d["id"][:8], "★ " if d["title_custom"] else "",
187
+ one_line(digests.title_of(d), 60).replace("|", "\\|"), first)
188
+
189
+ n_codex = sum(1 for d in ds if d["tool"] == "codex")
190
+ lines = ["# " + t("idx.title", name=os.path.basename(cfg.root)), "",
191
+ "> " + t("idx.note", when=config.now_local().strftime("%Y-%m-%d %H:%M"), n=len(ds), codex=n_codex,
192
+ claude=len(ds) - n_codex), ""]
193
+ hdr = ["| %s | Tool | id | %s | %s |" % (t("cli.h_date"), t("cli.h_name"), t("cli.first")), "|---|---|---|---|---|"]
194
+ for k in sorted(by_ticket, key=G.ticket_order):
195
+ lines += ["## %s" % k, ""] + hdr
196
+ for d in sorted(by_ticket[k], key=lambda d: d["last_ts"] or "", reverse=True):
197
+ lines.append(row(d))
198
+ lines.append("")
199
+ if no_ticket:
200
+ lines += ["## " + t("idx.no_ticket"), ""] + hdr
201
+ for d in sorted(no_ticket, key=lambda d: d["last_ts"] or "", reverse=True):
202
+ lines.append(row(d, with_range=False))
203
+ lines.append("")
204
+ out = cfg.abspath(args.out) if args.out else cfg.index_file
205
+ os.makedirs(os.path.dirname(out), exist_ok=True)
206
+ ignore = os.path.join(os.path.dirname(out), ".gitignore")
207
+ if os.path.basename(os.path.dirname(out)) == ".ticketrail" and not os.path.exists(ignore):
208
+ with open(ignore, "w", encoding="utf-8") as f: # the index quotes first prompts: keep it out of commits by default
209
+ f.write(os.path.basename(out) + "\n")
210
+ with open(out, "w", encoding="utf-8") as f:
211
+ f.write("\n".join(lines))
212
+ print(t("idx.written", path=out, tickets=len(by_ticket), rest=len(no_ticket)))
213
+
214
+
215
+ # ---------- groups / labels ----------
216
+ def cmd_label(args):
217
+ L.run(limit=args.limit, retry_errors=args.retry_errors)
218
+
219
+
220
+ def cmd_dump(args):
221
+ ss = digests.load_sessions()
222
+ labels = {} if args.no_labels else L.load_labels()
223
+ groups, member_of, _, _ = G.resolve_groups(ss, labels, G.load_overrides()) # a broken override file raises here
224
+ if args.json:
225
+ json.dump({"groups": groups, "member_of": member_of}, sys.stdout, ensure_ascii=False, indent=1, default=list)
226
+ print()
227
+ return
228
+ by_id = {d["id"]: d for d in ss}
229
+ for g in sorted(groups.values(), key=lambda g: g["last"], reverse=True):
230
+ print("\n## %s — %s (%d)" % (g["key"], g["title"], len(g["sessions"])))
231
+ for sid in g["sessions"]:
232
+ d = by_id[sid]
233
+ lb = labels.get(sid) or {}
234
+ print(" %s %s %-6s %s" % (fmt_date(d["last_ts"]), sid[:8], d["tool"], one_line(lb.get("summary") or digests.title_of(d), 90)))
235
+ print("\n" + t("cli.dump_footer", n=len(ss), g=len(groups), unlabeled=sum(1 for d in ss if d["id"] not in labels)))
236
+
237
+
238
+ # ---------- picker ----------
239
+ def cmd_pick(args):
240
+ from .model import Model
241
+ from .tui.app import Picker
242
+ act = Picker(Model(no_llm=args.no_llm), " ".join(args.query)).run()
243
+ if not act or act[0] == "quit":
244
+ return
245
+ _, cmd, cwd = act
246
+ shown = cmd if act[0] == "print" or len(cmd[-1]) < 200 else cmd[:-1] + ["<prompt: %d chars>" % len(cmd[-1])]
247
+ print("cd %s && %s" % (shlex.quote(cwd), shlex.join(shown)))
248
+ if act[0] == "print":
249
+ return
250
+ os.chdir(cwd)
251
+ try:
252
+ os.execvp(cmd[0], cmd)
253
+ except OSError as e:
254
+ sys.exit(t("cli.exec_failed", cmd=cmd[0], err=e))
255
+
256
+
257
+ # ---------- config / doctor ----------
258
+ STARTER = '''# ticketrail workspace configuration — see docs/configuration.md
259
+ # lang = "en" # "en" or "vi"
260
+
261
+ [tickets]
262
+ # patterns = ['\\bPROJ-\\d+\\b'] # default: any Jira-style key (ABC-123)
263
+ # url = "https://jira.example.com/browse/{key}"
264
+
265
+ [naming]
266
+ # tags = ["OPS", "DOC"] # session-name prefixes for work without a ticket
267
+
268
+ [labeler]
269
+ # enabled = true
270
+ # context = "What this workspace is about, in one or two sentences."
271
+ '''
272
+
273
+
274
+ def cmd_config(args):
275
+ cfg = config.current()
276
+ if args.action == "init":
277
+ p = os.path.join(cfg.root, config.CONFIG_FILE)
278
+ if os.path.exists(p):
279
+ sys.exit(t("cfg.exists", path=p))
280
+ with open(p, "w", encoding="utf-8") as f:
281
+ f.write(STARTER)
282
+ print(t("cfg.written", path=p))
283
+ return
284
+ print("root : " + cfg.root)
285
+ print("config files: " + (", ".join(cfg.sources) or "(defaults only)"))
286
+ print("cache : " + cfg.cache_dir)
287
+ print("groups file : " + cfg.groups_file)
288
+ print()
289
+ print(json.dumps(cfg.data, indent=2, ensure_ascii=False))
290
+
291
+
292
+ def cmd_doctor(args):
293
+ cfg = config.current()
294
+ ok = True
295
+
296
+ def line(good, label, detail=""):
297
+ nonlocal ok
298
+ ok = ok and good is not False
299
+ mark = {True: "ok ", False: "ERR", None: " - "}[good]
300
+ print("[%s] %-22s %s" % (mark, label, detail))
301
+
302
+ line(True, "workspace root", cfg.root)
303
+ line(True, "config files", ", ".join(cfg.sources) or "(defaults only)")
304
+ for name, src in SOURCES.items():
305
+ n = len(src.files(cfg))
306
+ line(True if n else None, "%s transcripts" % name, str(n))
307
+ for tool in ("claude", "codex"):
308
+ p = shutil.which(tool)
309
+ line(True if p else None, "%s on PATH" % tool, p or "not found (cannot resume %s sessions)" % tool)
310
+ if shutil.which("claude"):
311
+ from . import live
312
+ line(True if live.claude_live() is not None else None, "live state",
313
+ "claude agents --json" if live.claude_live() is not None else "unavailable (Claude Code < 2.1.139?): transcript activity is used")
314
+ if cfg.github_prs:
315
+ from . import signals
316
+ g = shutil.which("gh")
317
+ n = len(signals.github_prs(wait=True)["heads"]) if g else 0
318
+ line(True if g and n else False, "github pull requests", "%d branches with PRs" % n if g else "gh not found — install the GitHub CLI or set github.pull_requests = false")
319
+ if cfg.labeler_enabled:
320
+ p = shutil.which(cfg.labeler_command)
321
+ line(True if p else False, "labeler command", p or "%s not found — set labeler.enabled = false" % cfg.labeler_command)
322
+ else:
323
+ line(None, "labeler", "disabled")
324
+ line(True, "cache", cfg.cache_dir)
325
+ labels = L.load_labels()
326
+ line(True, "labels", str(len(labels)))
327
+ try:
328
+ ov = G.load_overrides()
329
+ line(True, "groups file", "%s (%d groups, %d moved sessions)" % (cfg.groups_file, len(ov["groups"]), len(ov["sessions"]))
330
+ if os.path.exists(cfg.groups_file) else "%s (not created yet)" % cfg.groups_file)
331
+ except G.OverrideError as e:
332
+ line(False, "groups file", str(e))
333
+ sys.exit(0 if ok else 1)
334
+
335
+
336
+ def build_parser():
337
+ ap = argparse.ArgumentParser(prog="ticketrail", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
338
+ ap.add_argument("--version", action="version", version="%(prog)s " + __version__)
339
+ ap.add_argument("--root", help="workspace root (default: nearest .ticketrail.toml, git toplevel, or cwd)")
340
+ ap.add_argument("--lang", choices=["en", "vi"], help="interface language")
341
+ sub = ap.add_subparsers(dest="cmd")
342
+
343
+ def add_source(p):
344
+ p.add_argument("--source", choices=["all", "codex", "claude"], default="all")
345
+
346
+ a = sub.add_parser("pick", help="interactive picker")
347
+ a.add_argument("query", nargs="*")
348
+ a.add_argument("--no-llm", action="store_true", help="never start the background labeler")
349
+ a.set_defaults(fn=cmd_pick)
350
+ a = sub.add_parser("list", help="list sessions")
351
+ a.add_argument("--days", type=int, default=30)
352
+ a.add_argument("--task", help="filter by ticket key or words in the name")
353
+ a.add_argument("--all", action="store_true", help="no date limit")
354
+ add_source(a)
355
+ a.set_defaults(fn=cmd_list)
356
+ a = sub.add_parser("find", help="full-text search")
357
+ a.add_argument("query", nargs="+")
358
+ a.add_argument("--deep", action="store_true", help="also count tool results (slower, noisier)")
359
+ a.add_argument("--brief", action="store_true", help="newest first, longer conclusions (for hand-offs)")
360
+ a.add_argument("--limit", type=int, default=12)
361
+ add_source(a)
362
+ a.set_defaults(fn=cmd_find)
363
+ a = sub.add_parser("show", help="digest of one session")
364
+ a.add_argument("key")
365
+ add_source(a)
366
+ a.set_defaults(fn=cmd_show)
367
+ a = sub.add_parser("index", help="write a markdown index by ticket")
368
+ a.add_argument("--out", help="output file (default: paths.index_file)")
369
+ add_source(a)
370
+ a.set_defaults(fn=cmd_index)
371
+ a = sub.add_parser("label", help="label idle sessions with the LLM")
372
+ a.add_argument("--limit", type=int, default=0)
373
+ a.add_argument("--retry-errors", action="store_true")
374
+ a.set_defaults(fn=cmd_label)
375
+ a = sub.add_parser("dump", help="print work items and their sessions")
376
+ a.add_argument("--json", action="store_true")
377
+ a.add_argument("--no-labels", action="store_true", help="ignore LLM labels (tickets and manual changes only)")
378
+ a.set_defaults(fn=cmd_dump)
379
+ a = sub.add_parser("config", help="show the effective configuration or write a starter file")
380
+ a.add_argument("action", nargs="?", choices=["show", "init"], default="show")
381
+ a.set_defaults(fn=cmd_config)
382
+ a = sub.add_parser("doctor", help="check paths, tools and caches")
383
+ a.set_defaults(fn=cmd_doctor)
384
+ return ap
385
+
386
+
387
+ def main(argv=None):
388
+ ap = build_parser()
389
+ args = ap.parse_args(argv)
390
+ if args.cmd is None: # bare `ticketrail` opens the picker
391
+ args = ap.parse_args((argv or sys.argv[1:]) + ["pick"])
392
+ try:
393
+ config.use(config.load(root=args.root, overrides={"lang": args.lang} if args.lang else None))
394
+ except config.ConfigError as e:
395
+ sys.exit("config: %s" % e)
396
+ args.fn(args)