parawatch 0.1.0 → 0.2.0

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,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { parsePrRef } from "./github.js";
4
5
  import { sessionTitle, UserError } from "./ops.js";
5
6
  /**
6
7
  * Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
@@ -60,10 +61,33 @@ export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
60
61
  export function desktopSessionUrl(s) {
61
62
  return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
62
63
  }
63
- /** Where `goto` leads for a card id or a session id (prefix). */
64
+ /** The session that owns a pull request: by URL, `owner/repo#N`, or `#N` / `repo#N` when only one matches. */
65
+ function prSession(target, state) {
66
+ const ref = parsePrRef(target);
67
+ if (ref) {
68
+ const link = state.prLinks.get(ref.key);
69
+ if (!link)
70
+ throw new UserError(`no session owns ${ref.repo}#${ref.number}; link it from that session with: parawatch pr link ${ref.url}`);
71
+ if (!link.session)
72
+ throw new UserError(`${ref.repo}#${ref.number} is linked to a project but not to a session`);
73
+ return link.session;
74
+ }
75
+ const m = /^(?:([^/\s#]+))?#(\d+)$/.exec(target.trim());
76
+ if (!m)
77
+ return undefined;
78
+ const hits = [...state.prLinks.values()].filter((l) => l.pr.number === Number(m[2]) && (!m[1] || l.pr.repo.split("/")[1] === m[1]));
79
+ if (!hits.length)
80
+ throw new UserError(`no session owns a pull request ${target}`);
81
+ if (hits.length > 1)
82
+ throw new UserError(`${target} matches ${hits.map((h) => h.pr.repo + "#" + h.pr.number).join(", ")}; give owner/repo#N`);
83
+ if (!hits[0].session)
84
+ throw new UserError(`${target} is linked to a project but not to a session`);
85
+ return hits[0].session;
86
+ }
87
+ /** Where `goto` leads for a card id, a pull request, or a session id (prefix). */
64
88
  export function resolveGoto(target, state) {
65
89
  const card = state.cards.get(target);
66
- const session = card ? card.session : target;
90
+ const session = card ? card.session : (prSession(target, state) ?? target);
67
91
  if (!session) {
68
92
  throw new UserError(`card ${target} does not say which session opened it (set one with: parawatch edit ${target} --session <id>)`);
69
93
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What each session did on one day, from the event log and the agent logs: the work it claimed,
3
+ * cards it opened and what came of them, jobs it declared, news on its pull requests, and how it
4
+ * said it ended.
5
+ */
6
+ import { collectRuns } from "./runs.js";
7
+ import { fold } from "./state.js";
8
+ import { readEvents } from "./store.js";
9
+ import { prChangeText } from "./format.js";
10
+ /** Local midnight to local midnight. */
11
+ export function dayRange(date) {
12
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
13
+ throw new Error(`"${date}" is not a date (use YYYY-MM-DD)`);
14
+ const start = new Date(`${date}T00:00:00`).getTime();
15
+ const end = new Date(start);
16
+ end.setDate(end.getDate() + 1);
17
+ return [start, end.getTime()];
18
+ }
19
+ export function localDate(t = Date.now()) {
20
+ const d = new Date(t);
21
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
22
+ }
23
+ export function digest(date, now = Date.now()) {
24
+ const [from, to] = dayRange(date);
25
+ const events = readEvents();
26
+ const state = fold(events);
27
+ const inDay = (e) => {
28
+ const t = Date.parse(e.ts);
29
+ return t >= from && t < to;
30
+ };
31
+ const sessions = new Map();
32
+ const sessionKey = (id) => {
33
+ if (!id)
34
+ return "";
35
+ const c = state.claims.find((x) => id.startsWith(x.session) || x.session.startsWith(id));
36
+ return c?.session ?? id;
37
+ };
38
+ const get = (id) => {
39
+ const k = sessionKey(id);
40
+ let s = sessions.get(k);
41
+ if (!s) {
42
+ const c = state.claims.find((x) => x.session === k);
43
+ s = { session: k, title: c?.title ?? (k ? k.slice(0, 8) : "no session"), project: c?.project, task: c?.task, cards: [], runs: [], prs: [] };
44
+ sessions.set(k, s);
45
+ }
46
+ return s;
47
+ };
48
+ // agent logs: what was active that day
49
+ const sinceHours = Math.max(1, Math.ceil((now - from) / 3_600_000));
50
+ const observed = collectRuns(state, { sinceHours, includeEnded: true, keepRepeats: true }, now).filter((r) => r.kind === "session" && r.lastActivity && Date.parse(r.lastActivity) >= from && (!r.startedAt || Date.parse(r.startedAt) < to));
51
+ for (const r of observed) {
52
+ const s = get(r.id);
53
+ s.title = r.title;
54
+ s.project ??= r.project;
55
+ s.task ??= r.task;
56
+ s.recap = r.recap;
57
+ s.lastActivity = r.lastActivity;
58
+ }
59
+ for (const e of events) {
60
+ if (!inDay(e))
61
+ continue;
62
+ switch (e.type) {
63
+ case "run.claim": {
64
+ const s = get(e.claim.session);
65
+ s.project = e.claim.project;
66
+ s.task = e.claim.task ?? s.task;
67
+ if (e.claim.title)
68
+ s.title = e.claim.title;
69
+ if (e.claim.done)
70
+ s.done = e.claim.done;
71
+ break;
72
+ }
73
+ case "card.open":
74
+ case "card.answer":
75
+ case "card.close": {
76
+ const id = e.type === "card.open" ? e.card.id : e.card;
77
+ const card = state.cards.get(id);
78
+ if (!card)
79
+ break;
80
+ const s = get(card.session);
81
+ s.project ??= card.project;
82
+ let row = s.cards.find((c) => c.id === id);
83
+ if (!row)
84
+ s.cards.push((row = { id, title: card.title, events: [] }));
85
+ if (e.type === "card.open")
86
+ row.events.push("opened");
87
+ else if (e.type === "card.answer") {
88
+ const choice = e.choice !== undefined ? card.options?.[e.choice - 1] : undefined;
89
+ row.events.push(`answered${choice ? `: ${choice}` : e.comment ? `: ${e.comment}` : ""}`);
90
+ }
91
+ else
92
+ row.events.push(`closed${e.note ? `: ${e.note}` : ""}`);
93
+ break;
94
+ }
95
+ case "run.start":
96
+ case "run.end": {
97
+ const id = e.type === "run.start" ? e.run.id : e.run;
98
+ const run = state.runs.get(id);
99
+ if (!run)
100
+ break;
101
+ const s = get(run.parent);
102
+ s.project ??= run.project;
103
+ if (!s.runs.some((x) => x.id === id))
104
+ s.runs.push({ id, title: run.title, status: run.status, note: run.note });
105
+ break;
106
+ }
107
+ case "pr.change": {
108
+ const link = state.prLinks.get(e.pr.key);
109
+ const s = get(link?.session);
110
+ s.project ??= link?.project;
111
+ let row = s.prs.find((p) => p.key === e.pr.key);
112
+ if (!row)
113
+ s.prs.push((row = { key: e.pr.key, url: e.pr.url, title: e.title, changes: [] }));
114
+ row.changes.push(prChangeText(e.change, e.by) + (e.text ? `: ${e.text}` : ""));
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ const list = [...sessions.values()].filter((s) => s.session || s.cards.length || s.runs.length || s.prs.length);
120
+ list.sort((a, b) => (a.project ?? "~").localeCompare(b.project ?? "~") || (a.lastActivity ?? "").localeCompare(b.lastActivity ?? ""));
121
+ return { date, sessions: list };
122
+ }
123
+ export function digestText(d) {
124
+ const out = [d.date];
125
+ if (!d.sessions.length)
126
+ return `${d.date}\nNothing recorded.`;
127
+ let project = null;
128
+ for (const s of d.sessions) {
129
+ if (s.project !== project) {
130
+ project = s.project;
131
+ out.push("", project ?? "other");
132
+ }
133
+ out.push(` ${s.task ? `${s.task} · ` : ""}${s.title}`);
134
+ const end = s.done ? `done: ${s.done}` : s.recap ? `last: ${s.recap}` : undefined;
135
+ if (end)
136
+ out.push(` ${end}`);
137
+ for (const c of s.cards)
138
+ out.push(` card ${c.id} "${c.title}": ${c.events.join(", ")}`);
139
+ for (const r of s.runs)
140
+ out.push(` job ${r.id} "${r.title}": ${r.status}${r.note ? ` (${r.note})` : ""}`);
141
+ for (const p of s.prs)
142
+ out.push(` PR ${p.key.replace(/^[^/]+\//, "")}${p.title ? ` "${p.title}"` : ""}: ${p.changes.join(", ")}`);
143
+ }
144
+ return out.join("\n");
145
+ }
146
+ //# sourceMappingURL=digest.js.map
@@ -86,10 +86,10 @@ export function cardStart(c) {
86
86
  return c.since && c.since < c.openedAt ? c.since : c.openedAt;
87
87
  }
88
88
  /** Cards as aligned rows: id, kind, project, title, age. */
89
- export function cardRows(cards, v = PLAIN, now = Date.now(), indent = "") {
89
+ export function cardRows(cards, v = PLAIN, now = Date.now(), indent = "", extra = {}) {
90
90
  const idW = Math.max(3, ...cards.map((c) => c.id.length));
91
91
  const projW = Math.min(16, Math.max(0, ...cards.map((c) => textWidth(c.project + (c.task ? `/${c.task}` : "")))));
92
- const noteOf = (c) => (c.status === "answered" ? " · answered" : c.status === "closed" ? " · closed" : "");
92
+ const noteOf = (c) => (c.status === "answered" ? " · answered" : c.status === "closed" ? " · closed" : "") + (extra[c.id] ? ` · ${extra[c.id]}` : "");
93
93
  const fixed = textWidth(indent) + idW + 2 + 7 + 2 + projW + 2 + 2 + 4;
94
94
  const titleW = Math.min(v.width - fixed, Math.max(0, ...cards.map((c) => textWidth(c.title + noteOf(c)))));
95
95
  return cards.map((c) => {
@@ -125,6 +125,10 @@ export function cardDetail(c) {
125
125
  out.push("");
126
126
  c.options.forEach((o, i) => out.push(` ${i + 1}. ${o}${c.recommend === i + 1 ? " (recommended)" : ""}`));
127
127
  }
128
+ if (c.action)
129
+ out.push("", `Runs on yes: ${c.action}`);
130
+ if (c.preview)
131
+ out.push("", "Preview:", ...c.preview.split("\n").map((l) => ` ${l}`));
128
132
  if (c.look)
129
133
  out.push("", `Look at: ${c.look}`);
130
134
  for (const o of c.open ?? [])
@@ -146,8 +150,31 @@ export function cardDetail(c) {
146
150
  out.push("", `Closed: ${c.closeNote}`);
147
151
  return out.join("\n");
148
152
  }
153
+ const PR_WORDS = {
154
+ review: "new review",
155
+ approved: "approved",
156
+ changes_requested: "changes requested",
157
+ commented: "new comment",
158
+ ci_failed: "CI failed",
159
+ ci_passed: "CI passed",
160
+ queued: "entered the merge queue",
161
+ queue_removed: "left the merge queue without merging",
162
+ conflict: "has a merge conflict",
163
+ merged: "merged",
164
+ closed: "closed without merging",
165
+ note: "note",
166
+ };
167
+ export function prChangeText(change, by) {
168
+ const w = PR_WORDS[change] ?? change;
169
+ return by && by !== "github" && by !== "external" ? `${w} by ${by}` : w;
170
+ }
149
171
  export function inboxLine(i) {
150
- const head = `[${i.card.project}] ${i.card.id} "${i.card.title}"`;
172
+ if (i.kind === "pr" && i.pr) {
173
+ const p = i.pr;
174
+ return `[${p.project ?? "?"}] PR ${p.ref.repo}#${p.ref.number}${p.title ? ` "${p.title}"` : ""}: ${prChangeText(p.change, i.by)}${p.text ? `; ${p.text}` : ""} (${p.ref.url})`;
175
+ }
176
+ const card = i.card;
177
+ const head = `[${card.project}] ${card.id} "${card.title}"`;
151
178
  if (i.kind === "comment")
152
179
  return `${head} comment: ${i.comment}`;
153
180
  const parts = [];
@@ -178,17 +205,77 @@ const STATE_PAINT = {
178
205
  unknown: ["dim"],
179
206
  };
180
207
  const ENDED = new Set(["done", "idle"]);
208
+ // ------------------------------------------------------------------ pull requests
209
+ function prLabel(p) {
210
+ return `${p.repo.split("/")[1]}#${p.number}`;
211
+ }
212
+ /** Open pull requests: what each waits on, for how long, and the session that owns it. */
213
+ export function prRows(prs, v = PLAIN, now = Date.now(), indent = "") {
214
+ const idW = Math.max(4, ...prs.map((p) => textWidth(prLabel(p))));
215
+ const waitW = Math.min(30, Math.max(6, ...prs.map((p) => textWidth(waitText(p)))));
216
+ const ownW = Math.min(24, Math.max(0, ...prs.map((p) => textWidth(ownerText(p)))));
217
+ const fixed = textWidth(indent) + idW + 2 + waitW + 2 + 4 + 2 + (ownW ? ownW + 2 : 0);
218
+ const titleW = Math.max(8, v.width - fixed);
219
+ return prs.map((p) => {
220
+ const age = ago(p.since, now).padStart(4);
221
+ return [
222
+ indent,
223
+ paint(v, pad(prLabel(p), idW), "bold"),
224
+ " ",
225
+ paint(v, pad(fit(waitText(p), waitW), waitW), ...(p.stalled ? ["red"] : ["yellow"])),
226
+ " ",
227
+ paint(v, age, ...(p.stalled ? ["red"] : ["dim"])),
228
+ " ",
229
+ ownW ? paint(v, pad(fit(ownerText(p), ownW), ownW), "dim") + " " : "",
230
+ fit(p.title, titleW),
231
+ ].join("").trimEnd();
232
+ });
233
+ }
234
+ export function waitText(p) {
235
+ if (p.reviewRequested && !p.mine)
236
+ return `${p.stalled ? "long " : ""}waiting for your review`;
237
+ const who = p.who?.length && p.waitingOn === "review" ? ` (${p.who.join(", ")})` : "";
238
+ return `${p.stalled ? "stuck in " : ""}${p.waitingOn}${who}`;
239
+ }
240
+ function ownerText(p) {
241
+ return p.sessionTitle ?? (p.project ? p.project + (p.task ? `/${p.task}` : "") : "");
242
+ }
243
+ /** Sessions that asked the human something in chat instead of on a card. */
244
+ export function askRows(runs, v = PLAIN, now = Date.now(), indent = "") {
245
+ const asking = runs.filter((r) => r.asks);
246
+ const nameW = Math.min(32, Math.max(8, ...asking.map((r) => textWidth(r.title))));
247
+ return asking.map((r) => {
248
+ const age = ago(r.asks.at ?? r.lastActivity, now).padStart(4);
249
+ const room = Math.max(8, v.width - textWidth(indent) - nameW - 2 - 4 - 2 - 10);
250
+ const where = r.project ? `${r.project}` : "other";
251
+ return [
252
+ indent,
253
+ paint(v, pad(fit(r.title, nameW), nameW), "bold"),
254
+ " ",
255
+ paint(v, age, "dim"),
256
+ " ",
257
+ paint(v, fit(clean(r.asks.text), room), ...(r.asks.blocked ? ["red"] : ["yellow"])),
258
+ " ",
259
+ paint(v, `(${where})`, "dim"),
260
+ ].join("");
261
+ });
262
+ }
181
263
  /** Finished subagents older than this fold into one "N finished" line. */
182
264
  const FOLD_AFTER_MS = 5 * 60_000;
183
265
  export const OTHER = "other";
184
266
  function whatText(r) {
185
267
  if (r.error)
186
268
  return `unreadable: ${r.error}`;
187
- const a = clean(r.action ?? "");
269
+ if (r.done)
270
+ return `done · ${clean(r.done)}`;
271
+ if (r.asks)
272
+ return `${r.asks.blocked ? "blocked" : "asks"}: ${clean(r.asks.text)}`;
273
+ const a = clean((r.state === "idle" && r.recap) || r.action || "");
188
274
  if (r.state === "stalled")
189
275
  return a ? `stalled · ${a}` : "stalled";
190
276
  return a;
191
277
  }
278
+ const SOURCE_NOTE = { edits: "~edits", prompt: "~prompt" };
192
279
  /** Runs grouped by project, children drawn as a tree under their parent, one terminal line each. */
193
280
  export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
194
281
  const groups = new Map();
@@ -248,11 +335,14 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
248
335
  const ended = ENDED.has(r.state);
249
336
  const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
250
337
  const mark = paint(v, STATE_MARK[r.state], ...STATE_PAINT[r.state]);
251
- const title = paint(v, pad(fit(r.title, room), room), ...titlePaint);
338
+ const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "");
339
+ const title = paint(v, pad(fit(label, room), room), ...titlePaint);
252
340
  const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
253
- const wide = v.wide ? " " + paint(v, pad(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}`, wideW - 2), "dim") : "";
341
+ const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
342
+ const wide = v.wide ? " " + paint(v, pad(fit(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
254
343
  const restW = v.width - nameW - 2 - 4 - wideW - 2;
255
- const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...(r.error || r.state === "stalled" || r.state === "failed" ? ["red"] : ended ? ["dim"] : [])) : "";
344
+ const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
345
+ const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
256
346
  out.push(`${lead}${mark} ${title} ${age}${wide}${what}`.trimEnd());
257
347
  }
258
348
  }