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.
- package/README.md +79 -24
- package/dist/adapters/claudeCode.js +109 -4
- package/dist/bin/cli.js +153 -22
- package/dist/core/desktop.js +26 -2
- package/dist/core/digest.js +146 -0
- package/dist/core/format.js +97 -7
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +11 -4
- package/dist/core/ops.js +151 -7
- package/dist/core/runs.js +148 -24
- package/dist/core/snapshot.js +50 -3
- package/dist/core/state.js +14 -2
- package/dist/core/store.js +11 -12
- package/dist/core/types.js +1 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +54 -24
package/dist/core/desktop.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/core/format.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|