cueloop 0.1.0-alpha.49 → 0.1.0-alpha.50

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.49",
3
+ "version": "0.1.0-alpha.50",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/adapters": "0.1.0-alpha.49",
31
- "@cueloop/client": "0.1.0-alpha.49",
32
- "@cueloop/daemon": "0.1.0-alpha.49",
33
- "@cueloop/schema": "0.1.0-alpha.49"
30
+ "@cueloop/adapters": "0.1.0-alpha.50",
31
+ "@cueloop/client": "0.1.0-alpha.50",
32
+ "@cueloop/daemon": "0.1.0-alpha.50",
33
+ "@cueloop/schema": "0.1.0-alpha.50"
34
34
  },
35
35
  "engines": {
36
36
  "bun": ">=1.3.0"
package/src/main.ts CHANGED
@@ -97,6 +97,10 @@ async function main(): Promise<number> {
97
97
  const { actionsCommand } = await import("./actions-command");
98
98
  return actionsCommand(argv.slice(1));
99
99
  }
100
+ case "refine": {
101
+ const { refineCommand } = await import("./refine-command");
102
+ return refineCommand(argv.slice(1));
103
+ }
100
104
  case "review":
101
105
  return reviewEntry(argv.slice(1));
102
106
  case "review-post": {
@@ -297,6 +301,7 @@ function printHelp(): void {
297
301
  "scripting:",
298
302
  " cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
299
303
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
304
+ " cueloop refine mine past reviews into a markdown report + writeback proposals",
300
305
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
301
306
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
302
307
  " cueloop daemon run the daemon in the foreground",
@@ -0,0 +1,117 @@
1
+ /** refine builds a corpus report from stored sessions and skips sessions it has already analyzed on a later run. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, readFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { Annotation, ReviewSession, Verdict, VerdictKind } from "@cueloop/schema";
8
+ import { SessionStore } from "@cueloop/daemon/store";
9
+ import { reportsDir } from "@cueloop/daemon/paths";
10
+ import { buildRefineReport, refineCommand } from "./refine-command";
11
+
12
+ function tempHome(): string {
13
+ return mkdtempSync(join(tmpdir(), "cueloop-refine-home-"));
14
+ }
15
+
16
+ function annotation(quote: string, body: string): Annotation {
17
+ return {
18
+ id: `a_${quote}`,
19
+ kind: "comment",
20
+ anchor: { quote, prefix: "", suffix: "" },
21
+ body,
22
+ createdAt: "2026-08-20T10:00:00Z",
23
+ };
24
+ }
25
+
26
+ function verdict(kind: VerdictKind): Verdict {
27
+ return { kind, summary: "", feedback: "", resolvedAt: "2026-08-20T11:00:00Z" };
28
+ }
29
+
30
+ function session(
31
+ id: string,
32
+ overrides: Partial<ReviewSession> & { type?: ReviewSession["artifact"]["type"] } = {},
33
+ ): ReviewSession {
34
+ const { type, ...rest } = overrides;
35
+ return {
36
+ schemaVersion: "1",
37
+ id,
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: type ?? "plan", content: "", meta: {} },
40
+ revisions: [],
41
+ annotations: [],
42
+ verdict: null,
43
+ status: "pending",
44
+ createdAt: "2026-08-20T10:00:00Z",
45
+ ...rest,
46
+ };
47
+ }
48
+
49
+ describe("buildRefineReport", () => {
50
+ test("reports the analyzed and total counts, distributions, and annotations", () => {
51
+ // Arrange
52
+ const analyzed = [
53
+ session("ses_a", { verdict: verdict("approve"), status: "resolved" }),
54
+ session("ses_b", {
55
+ type: "diff",
56
+ verdict: verdict("request_changes"),
57
+ status: "resolved",
58
+ annotations: [annotation("skip tests", "please add a regression test")],
59
+ }),
60
+ ];
61
+
62
+ // Act
63
+ const markdown = buildRefineReport(analyzed, 3, "2026-08-26T00:00:00.000Z");
64
+
65
+ // Assert
66
+ expect(markdown).toContain("2 sessions analyzed (3 total).");
67
+ expect(markdown).toContain("- plan: 1");
68
+ expect(markdown).toContain("- diff: 1");
69
+ expect(markdown).toContain("- approve: 1");
70
+ expect(markdown).toContain("- request changes: 1");
71
+ expect(markdown).toContain("skip tests");
72
+ expect(markdown).toContain("please add a regression test");
73
+ });
74
+
75
+ test("notes when there are no reviewer annotations", () => {
76
+ // Act
77
+ const markdown = buildRefineReport([], 0, "2026-08-26T00:00:00.000Z");
78
+
79
+ // Assert
80
+ expect(markdown).toContain("0 sessions analyzed (0 total).");
81
+ expect(markdown).toContain("No reviewer annotations in the analyzed sessions.");
82
+ });
83
+ });
84
+
85
+ describe("refineCommand", () => {
86
+ test("writes a report, skips signal-free sessions, and consumes only resolved sessions", async () => {
87
+ // Arrange
88
+ const home = tempHome();
89
+ const store = new SessionStore(home);
90
+ store.upsert(session("ses_a", { verdict: verdict("approve"), status: "resolved" }));
91
+ store.upsert(
92
+ session("ses_b", {
93
+ verdict: verdict("request_changes"),
94
+ status: "resolved",
95
+ annotations: [annotation("wrong file", "this belongs in store.ts")],
96
+ }),
97
+ );
98
+ store.upsert(
99
+ session("ses_pending", { annotations: [annotation("open point", "still deciding")] }),
100
+ );
101
+ store.upsert(session("ses_empty"));
102
+ const reportPath = join(reportsDir(home), "report.md");
103
+
104
+ // Act
105
+ const firstCode = await refineCommand(["--home", home]);
106
+
107
+ // Assert
108
+ expect(firstCode).toBe(0);
109
+ expect(readFileSync(reportPath, "utf8")).toContain("3 sessions analyzed (4 total).");
110
+
111
+ // Act
112
+ await refineCommand(["--home", home]);
113
+
114
+ // Assert
115
+ expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (4 total).");
116
+ });
117
+ });
@@ -0,0 +1,241 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cueloopHome, reportsDir } from "@cueloop/daemon/paths";
4
+ import { SessionStore } from "@cueloop/daemon/store";
5
+ import {
6
+ LATEST_REPORT_FILENAME,
7
+ pruneExpiredReports,
8
+ resolveCleanupPeriodDays,
9
+ timestampedReportFilename,
10
+ } from "@cueloop/daemon/retention";
11
+ import { isAgentNote, type Annotation, type ReviewSession } from "@cueloop/schema";
12
+ import { parseArgs, stringFlag } from "./args";
13
+
14
+ const DEFAULT_SESSION_LIMIT = 200;
15
+ const QUOTE_LIMIT = 60;
16
+ const BODY_LIMIT = 100;
17
+
18
+ interface AnnotatedEntry {
19
+ annotation: Annotation;
20
+ session: ReviewSession;
21
+ }
22
+
23
+ export async function refineCommand(argv: string[]): Promise<number> {
24
+ const { flags } = parseArgs(argv);
25
+ const home = stringFlag(flags, "home") ?? cueloopHome();
26
+ const limit = parseLimit(stringFlag(flags, "limit"));
27
+ const nowMs = Date.now();
28
+
29
+ const store = new SessionStore(home);
30
+ store.recover();
31
+ const all = store.list();
32
+
33
+ const seen = readSeen(home);
34
+ const fresh = all.filter((session) => !seen.has(session.id));
35
+ const analyzed = fresh.filter(hasReviewSignal).slice(0, limit);
36
+
37
+ const markdown = buildRefineReport(analyzed, all.length, new Date(nowMs).toISOString());
38
+
39
+ const directory = reportsDir(home);
40
+ mkdirSync(directory, { recursive: true });
41
+ pruneExpiredReports(directory, resolveCleanupPeriodDays(), nowMs);
42
+ const latestPath = join(directory, LATEST_REPORT_FILENAME);
43
+ const timestampedPath = join(directory, timestampedReportFilename(nowMs));
44
+ writeFileSync(latestPath, markdown);
45
+ writeFileSync(timestampedPath, markdown);
46
+
47
+ for (const session of analyzed) {
48
+ if (session.status === "resolved") seen.add(session.id);
49
+ }
50
+ writeSeen(home, seen);
51
+
52
+ console.log(
53
+ JSON.stringify(
54
+ {
55
+ report: latestPath,
56
+ timestamped: timestampedPath,
57
+ analyzed: analyzed.length,
58
+ total: all.length,
59
+ },
60
+ null,
61
+ 2,
62
+ ),
63
+ );
64
+ return 0;
65
+ }
66
+
67
+ export function buildRefineReport(
68
+ analyzed: ReviewSession[],
69
+ totalCount: number,
70
+ generatedAt: string,
71
+ ): string {
72
+ const entries = flattenReviewAnnotations(analyzed);
73
+ const lines: string[] = [
74
+ "# refine report",
75
+ "",
76
+ `Generated ${generatedAt}.`,
77
+ "",
78
+ `${analyzed.length} sessions analyzed (${totalCount} total).`,
79
+ "",
80
+ "## Corpus",
81
+ "",
82
+ "By primitive:",
83
+ ...byCount(analyzed, primitiveLabel).map(([label, count]) => `- ${label}: ${count}`),
84
+ "",
85
+ "By verdict:",
86
+ ...byCount(analyzed, verdictLabel).map(([label, count]) => `- ${label}: ${count}`),
87
+ "",
88
+ "## Annotations by kind",
89
+ "",
90
+ ];
91
+
92
+ if (entries.length === 0) {
93
+ lines.push("No reviewer annotations in the analyzed sessions.", "");
94
+ } else {
95
+ for (const [kind, group] of groupByKind(entries)) {
96
+ lines.push(`### ${kind} (${group.length})`, "");
97
+ for (const { annotation, session } of group) {
98
+ const meta = [
99
+ session.id,
100
+ primitiveLabel(session),
101
+ verdictLabel(session),
102
+ isoWeek(annotation.createdAt),
103
+ ].join(" · ");
104
+ lines.push(
105
+ `- "${truncate(annotation.anchor.quote, QUOTE_LIMIT)}": ${truncate(annotation.body, BODY_LIMIT)} · ${meta}`,
106
+ );
107
+ }
108
+ lines.push("");
109
+ }
110
+ }
111
+
112
+ lines.push("## Weekly volume", "");
113
+ for (const [week, count] of byKey(
114
+ entries.map((entry) => entry.annotation),
115
+ (annotation) => isoWeek(annotation.createdAt),
116
+ )) {
117
+ lines.push(`- ${week}: ${count}`);
118
+ }
119
+ lines.push(
120
+ "",
121
+ "## Next",
122
+ "",
123
+ "Group the annotations above into named patterns of three or more members.",
124
+ "Rank each pattern by how often its members sit on a request-changes or comment verdict.",
125
+ "Draft one writeback per pattern, routed to a skill, AGENTS.md, CLAUDE.md, or memory.",
126
+ "",
127
+ );
128
+
129
+ return lines.join("\n");
130
+ }
131
+
132
+ function hasReviewSignal(session: ReviewSession): boolean {
133
+ return (
134
+ session.verdict !== null || session.annotations.some((annotation) => !isAgentNote(annotation))
135
+ );
136
+ }
137
+
138
+ function flattenReviewAnnotations(sessions: ReviewSession[]): AnnotatedEntry[] {
139
+ const entries: AnnotatedEntry[] = [];
140
+ for (const session of sessions) {
141
+ for (const annotation of session.annotations) {
142
+ if (!isAgentNote(annotation)) entries.push({ annotation, session });
143
+ }
144
+ }
145
+ return entries;
146
+ }
147
+
148
+ function groupByKind(entries: AnnotatedEntry[]): [string, AnnotatedEntry[]][] {
149
+ const groups = new Map<string, AnnotatedEntry[]>();
150
+ for (const entry of entries) {
151
+ const group = groups.get(entry.annotation.kind) ?? [];
152
+ group.push(entry);
153
+ groups.set(entry.annotation.kind, group);
154
+ }
155
+ return [...groups.entries()].toSorted(
156
+ (left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]),
157
+ );
158
+ }
159
+
160
+ function byCount<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
161
+ return [...tally(items, keyOf).entries()].toSorted(
162
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
163
+ );
164
+ }
165
+
166
+ function byKey<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
167
+ return [...tally(items, keyOf).entries()].toSorted((left, right) =>
168
+ left[0].localeCompare(right[0]),
169
+ );
170
+ }
171
+
172
+ function tally<T>(items: T[], keyOf: (item: T) => string): Map<string, number> {
173
+ const counts = new Map<string, number>();
174
+ for (const item of items) {
175
+ const key = keyOf(item);
176
+ counts.set(key, (counts.get(key) ?? 0) + 1);
177
+ }
178
+ return counts;
179
+ }
180
+
181
+ function primitiveLabel(session: ReviewSession): string {
182
+ if (session.artifact.type === "diff" && session.artifact.meta.pr) return "pull request";
183
+ return session.artifact.type;
184
+ }
185
+
186
+ function verdictLabel(session: ReviewSession): string {
187
+ switch (session.verdict?.kind) {
188
+ case "approve":
189
+ return "approve";
190
+ case "request_changes":
191
+ return "request changes";
192
+ case "comment":
193
+ return "comment";
194
+ default:
195
+ return "none";
196
+ }
197
+ }
198
+
199
+ function isoWeek(iso: string): string {
200
+ const date = new Date(iso);
201
+ if (Number.isNaN(date.getTime())) return "undated";
202
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
203
+ const weekday = utc.getUTCDay() || 7;
204
+ utc.setUTCDate(utc.getUTCDate() + 4 - weekday);
205
+ const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
206
+ const week = Math.ceil(((utc.getTime() - yearStart.getTime()) / (24 * 60 * 60 * 1000) + 1) / 7);
207
+ return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
208
+ }
209
+
210
+ function truncate(text: string, max: number): string {
211
+ const collapsed = text.replace(/\s+/g, " ").trim();
212
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed;
213
+ }
214
+
215
+ function parseLimit(raw: string | undefined): number {
216
+ const value = Number(raw);
217
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_SESSION_LIMIT;
218
+ }
219
+
220
+ interface RefineState {
221
+ seenSessionIds: string[];
222
+ }
223
+
224
+ function statePath(home: string): string {
225
+ return join(home, "refine-state.json");
226
+ }
227
+
228
+ function readSeen(home: string): Set<string> {
229
+ try {
230
+ const parsed = JSON.parse(readFileSync(statePath(home), "utf8")) as Partial<RefineState>;
231
+ const ids = Array.isArray(parsed.seenSessionIds) ? parsed.seenSessionIds : [];
232
+ return new Set(ids.filter((id): id is string => typeof id === "string"));
233
+ } catch {
234
+ return new Set();
235
+ }
236
+ }
237
+
238
+ function writeSeen(home: string, seen: Set<string>): void {
239
+ const state: RefineState = { seenSessionIds: [...seen] };
240
+ writeFileSync(statePath(home), JSON.stringify(state, null, 2));
241
+ }