cueloop 0.1.0-alpha.7 → 0.1.0-alpha.71

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Primitive-first review-open resolution: turn a primitive (plan/diff/review) plus an
3
+ * optional id-or-title selector into a single session to open, or a clear
4
+ * miss. Pure over a session list so it unit-tests without a daemon or a TUI -
5
+ * main.ts fetches the sessions and prints the miss messages.
6
+ *
7
+ * Rules, in order:
8
+ * - no selector opens the latest pending session in scope (most recent
9
+ * createdAt, verdict still open) - the `--latest` default;
10
+ * - a selector that exactly equals a session id opens that session, pending
11
+ * or resolved;
12
+ * - otherwise the selector matches session titles, case-insensitive: an
13
+ * exact title wins, else a unique substring match wins, else the substring
14
+ * candidates are reported as ambiguous.
15
+ * Every lookup is scoped by the caller's `match` predicate, so a primitive only
16
+ * ever resolves to its own artifact kind.
17
+ */
18
+
19
+ import type { Thread } from "@cueloop/schema";
20
+
21
+ /**
22
+ * The primitive scopes, kept disjoint on purpose so a single session never resolves
23
+ * under two primitives. A PR review is a diff artifact that carries a `pr`
24
+ * reference (openReview maps a top-level `pr` into `meta.pr`), so the plain
25
+ * diff scope must exclude those - otherwise `cueloop diff` and `cueloop
26
+ * review` would both open the same pending PR review.
27
+ */
28
+ export function isPlanReview(session: Thread): boolean {
29
+ return session.artifact.type === "plan";
30
+ }
31
+ export function isReplyReview(session: Thread): boolean {
32
+ return session.artifact.type === "reply";
33
+ }
34
+ export function isPrototypeReview(session: Thread): boolean {
35
+ return session.artifact.type === "prototype";
36
+ }
37
+ export function isDiffReview(session: Thread): boolean {
38
+ return session.artifact.type === "diff" && session.artifact.meta.pr === undefined;
39
+ }
40
+ export function isPrReview(session: Thread): boolean {
41
+ return session.artifact.type === "diff" && session.artifact.meta.pr !== undefined;
42
+ }
43
+
44
+ /** Session ids are minted with this prefix; the id-vs-title split lives here. */
45
+ export function isSessionId(value: string): boolean {
46
+ return value.startsWith("ses_");
47
+ }
48
+
49
+ export interface OpenTargetQuery {
50
+ /** Only sessions this predicate accepts are eligible - the primitive's artifact scope. */
51
+ match: (session: Thread) => boolean;
52
+ /** Positional id-or-title selector; absent means "latest pending". */
53
+ selector?: string;
54
+ }
55
+
56
+ export type OpenTarget =
57
+ | { kind: "session"; sessionId: string }
58
+ | { kind: "no-pending" }
59
+ | { kind: "no-match"; selector: string }
60
+ | { kind: "ambiguous"; selector: string; titles: string[] };
61
+
62
+ /** Most-recent-first, so the head of a sorted list is always the latest. */
63
+ function newestFirst(left: Thread, right: Thread): number {
64
+ return right.createdAt.localeCompare(left.createdAt);
65
+ }
66
+
67
+ export function resolveOpenTarget(sessions: Thread[], query: OpenTargetQuery): OpenTarget {
68
+ const scoped = sessions.filter(query.match);
69
+
70
+ if (query.selector === undefined) {
71
+ const latestPending = scoped
72
+ .filter((candidate) => candidate.status === "pending")
73
+ .sort(newestFirst)[0];
74
+
75
+ return latestPending
76
+ ? { kind: "session", sessionId: latestPending.id }
77
+ : { kind: "no-pending" };
78
+ }
79
+
80
+ const selector = query.selector;
81
+ const byId = scoped.find((candidate) => candidate.id === selector);
82
+
83
+ if (byId) return { kind: "session", sessionId: byId.id };
84
+
85
+ const loweredSelector = selector.toLowerCase();
86
+ const titled = scoped.filter((candidate) => (candidate.artifact.meta.title ?? "").length > 0);
87
+
88
+ const exactTitle = titled
89
+ .filter((candidate) => candidate.artifact.meta.title!.toLowerCase() === loweredSelector)
90
+ .sort(newestFirst)[0];
91
+
92
+ if (exactTitle) return { kind: "session", sessionId: exactTitle.id };
93
+
94
+ const substringMatches = titled
95
+ .filter((candidate) => candidate.artifact.meta.title!.toLowerCase().includes(loweredSelector))
96
+ .sort(newestFirst);
97
+
98
+ if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
99
+ if (substringMatches.length === 0) return { kind: "no-match", selector };
100
+
101
+ return {
102
+ kind: "ambiguous",
103
+ selector,
104
+ titles: substringMatches.map((candidate) => candidate.artifact.meta.title!),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The reviewer-facing line for a miss, in the "working tree is clean - nothing
110
+ * to review" tone. `label` is the human word for the primitive's scope ("plan",
111
+ * "diff", "PR"). The "session" outcome has no message - the caller opens it.
112
+ */
113
+ export function openTargetMessage(
114
+ label: string,
115
+ target: Exclude<OpenTarget, { kind: "session" }>,
116
+ ): string {
117
+ switch (target.kind) {
118
+ case "no-pending":
119
+ return `no pending ${label} review - nothing to open`;
120
+ case "no-match":
121
+ return `no ${label} review matches "${target.selector}" - nothing to open`;
122
+ case "ambiguous":
123
+ return [
124
+ `"${target.selector}" matches several ${label} reviews - name one:`,
125
+ ...target.titles.map((title) => ` - ${title}`),
126
+ ].join("\n");
127
+ }
128
+ }
package/src/pr.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * PR review entry (#18): `cueloop review <pr>` fetches the PR diff through the
2
+ * PR review entry: `cueloop review <pr>` fetches the PR diff through the
3
3
  * user's `gh` CLI (auth fully delegated), opens a diff session, and posts the
4
4
  * verdict back to the PR as a real review when the session resolves.
5
5
  * `cueloop review-post <session-id> <pr>` is the non-interactive post-back
6
6
  * half for agents and scripts.
7
7
  */
8
8
 
9
- import type { ReviewSession, VerdictKind } from "@cueloop/schema";
9
+ import type { Thread, VerdictKind } from "@cueloop/schema";
10
10
  import { DaemonClient } from "@cueloop/daemon/client";
11
11
  import { openReview } from "@cueloop/daemon/review";
12
12
  import { parseArgs } from "./args";
@@ -29,6 +29,7 @@ async function gh(args: string[]): Promise<GhResult> {
29
29
  new Response(proc.stderr).text(),
30
30
  proc.exited,
31
31
  ]);
32
+
32
33
  return { code, stdout, stderr };
33
34
  }
34
35
 
@@ -42,20 +43,27 @@ const VERDICT_FLAG: Record<VerdictKind, string> = {
42
43
  export async function reviewCommand(argv: string[]): Promise<number> {
43
44
  const { positional, flags } = parseArgs(argv);
44
45
  const pr = positional[0];
46
+
45
47
  if (!pr) {
46
48
  console.error("usage: cueloop review <pr> [--no-tui]");
49
+
47
50
  return 2;
48
51
  }
49
52
  const diff = await gh(["pr", "diff", pr]);
53
+
50
54
  if (diff.code !== 0) {
51
55
  console.error(diff.stderr.trim() || `gh pr diff ${pr} failed (exit ${diff.code})`);
56
+
52
57
  return 1;
53
58
  }
54
59
  if (!diff.stdout.trim()) {
55
60
  console.error(`PR ${pr} has an empty diff - nothing to review`);
61
+
56
62
  return 1;
57
63
  }
58
64
  const client = await DaemonClient.connect({ autostart: true });
65
+ // A PR diff is a partial patch with no full file contents, so no `files` here;
66
+ // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
59
67
  const review = await openReview(client, {
60
68
  type: "diff",
61
69
  content: diff.stdout,
@@ -63,21 +71,27 @@ export async function reviewCommand(argv: string[]): Promise<number> {
63
71
  pr,
64
72
  });
65
73
  const session = review.session;
74
+
66
75
  client.close();
67
76
 
68
77
  if (flags["no-tui"]) {
69
78
  console.log(JSON.stringify(session, null, 2));
79
+
70
80
  return 0;
71
81
  }
72
82
 
73
83
  const { runClient } = await import("@cueloop/client");
84
+
74
85
  await runClient({ sessionId: session.id });
75
86
 
76
87
  const after = await getSession(session.id);
88
+
77
89
  if (after.status !== "resolved" || !after.verdict) {
78
90
  console.log(`session ${session.id} is unresolved - nothing was posted to PR ${pr}`);
91
+
79
92
  return 0;
80
93
  }
94
+
81
95
  return postVerdict(after, pr);
82
96
  }
83
97
 
@@ -85,20 +99,26 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
85
99
  const { positional } = parseArgs(argv);
86
100
  const sessionId = positional[0];
87
101
  const pr = positional[1];
102
+
88
103
  if (!sessionId || !pr) {
89
104
  console.error("usage: cueloop review-post <session-id> <pr>");
105
+
90
106
  return 2;
91
107
  }
92
108
  const session = await getSession(sessionId);
109
+
93
110
  if (session.status !== "resolved" || !session.verdict) {
94
111
  console.error(`session ${sessionId} is unresolved - nothing was posted to PR ${pr}`);
112
+
95
113
  return 1;
96
114
  }
115
+
97
116
  return postVerdict(session, pr);
98
117
  }
99
118
 
100
- async function getSession(id: string): Promise<ReviewSession> {
119
+ async function getSession(id: string): Promise<Thread> {
101
120
  const client = await DaemonClient.connect({ autostart: true });
121
+
102
122
  try {
103
123
  return await client.sessionGet(id);
104
124
  } finally {
@@ -107,13 +127,23 @@ async function getSession(id: string): Promise<ReviewSession> {
107
127
  }
108
128
 
109
129
  /** Post the resolved session's verdict to the PR: feedback.md is the review body. */
110
- async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
130
+ async function postVerdict(session: Thread, pr: string): Promise<number> {
111
131
  const verdict = session.verdict!;
112
- const result = await gh(["pr", "review", pr, VERDICT_FLAG[verdict.kind], "--body", verdict.feedback]);
132
+ const result = await gh([
133
+ "pr",
134
+ "review",
135
+ pr,
136
+ VERDICT_FLAG[verdict.kind],
137
+ "--body",
138
+ verdict.feedback,
139
+ ]);
140
+
113
141
  if (result.code !== 0) {
114
142
  console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
143
+
115
144
  return 1;
116
145
  }
117
146
  console.log(`posted ${verdict.kind} review to PR ${pr} (session ${session.id})`);
147
+
118
148
  return 0;
119
149
  }
@@ -0,0 +1,158 @@
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, Thread, Verdict, VerdictKind } from "@cueloop/schema";
8
+ import { ThreadStore } 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<Thread> & { type?: Thread["artifact"]["type"] } = {},
33
+ ): Thread {
34
+ const { type, ...rest } = overrides;
35
+
36
+ return {
37
+ schemaVersion: "1",
38
+ id,
39
+ workspace: { repoRoot: "/repo", branch: "main" },
40
+ artifact: { type: type ?? "plan", content: "", meta: {} },
41
+ revisions: [],
42
+ annotations: [],
43
+ verdict: null,
44
+ status: "pending",
45
+ createdAt: "2026-08-20T10:00:00Z",
46
+ ...rest,
47
+ };
48
+ }
49
+
50
+ describe("buildRefineReport", () => {
51
+ test("reports the analyzed and total counts, distributions, and annotations", () => {
52
+ // Arrange
53
+ const analyzed = [
54
+ session("ses_a", { verdict: verdict("approve"), status: "resolved" }),
55
+ session("ses_b", {
56
+ type: "diff",
57
+ verdict: verdict("request_changes"),
58
+ status: "resolved",
59
+ annotations: [annotation("skip tests", "please add a regression test")],
60
+ }),
61
+ ];
62
+
63
+ // Act
64
+ const markdown = buildRefineReport(analyzed, 3, "2026-08-26T00:00:00.000Z");
65
+
66
+ // Assert
67
+ expect(markdown).toContain("2 sessions analyzed (3 total).");
68
+ expect(markdown).toContain("- plan: 1");
69
+ expect(markdown).toContain("- diff: 1");
70
+ expect(markdown).toContain("- approve: 1");
71
+ expect(markdown).toContain("- request changes: 1");
72
+ expect(markdown).toContain("skip tests");
73
+ expect(markdown).toContain("please add a regression test");
74
+ });
75
+
76
+ test("notes when there are no reviewer annotations", () => {
77
+ // Act
78
+ const markdown = buildRefineReport([], 0, "2026-08-26T00:00:00.000Z");
79
+
80
+ // Assert
81
+ expect(markdown).toContain("0 sessions analyzed (0 total).");
82
+ expect(markdown).toContain("No reviewer annotations in the analyzed sessions.");
83
+ });
84
+ });
85
+
86
+ describe("refineCommand", () => {
87
+ test("writes a report, skips signal-free sessions, and consumes only resolved sessions", async () => {
88
+ // Arrange
89
+ const home = tempHome();
90
+ const store = new ThreadStore(home);
91
+
92
+ store.upsert(session("ses_a", { verdict: verdict("approve"), status: "resolved" }));
93
+ store.upsert(
94
+ session("ses_b", {
95
+ verdict: verdict("request_changes"),
96
+ status: "resolved",
97
+ annotations: [annotation("wrong file", "this belongs in store.ts")],
98
+ }),
99
+ );
100
+ store.upsert(
101
+ session("ses_pending", { annotations: [annotation("open point", "still deciding")] }),
102
+ );
103
+ store.upsert(session("ses_empty"));
104
+ const reportPath = join(reportsDir(home), "report.md");
105
+
106
+ // Act
107
+ const firstCode = await refineCommand(["--home", home]);
108
+
109
+ // Assert
110
+ expect(firstCode).toBe(0);
111
+ expect(readFileSync(reportPath, "utf8")).toContain("3 sessions analyzed (4 total).");
112
+
113
+ // Act
114
+ await refineCommand(["--home", home]);
115
+
116
+ // Assert
117
+ expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (4 total).");
118
+ });
119
+
120
+ test("re-analyzes a resolved session that is reopened and resolved again with new feedback", async () => {
121
+ // Arrange
122
+ const home = tempHome();
123
+ const store = new ThreadStore(home);
124
+
125
+ store.upsert(
126
+ session("ses_x", {
127
+ verdict: verdict("approve"),
128
+ status: "resolved",
129
+ annotations: [annotation("first", "one")],
130
+ }),
131
+ );
132
+ const reportPath = join(reportsDir(home), "report.md");
133
+
134
+ // Act
135
+ await refineCommand(["--home", home]);
136
+
137
+ // Assert
138
+ expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (1 total).");
139
+
140
+ // Arrange
141
+ store.upsert(
142
+ session("ses_x", {
143
+ verdict: { ...verdict("request_changes"), resolvedAt: "2026-08-21T09:00:00Z" },
144
+ status: "resolved",
145
+ annotations: [annotation("first", "one"), annotation("second", "two")],
146
+ }),
147
+ );
148
+
149
+ // Act
150
+ await refineCommand(["--home", home]);
151
+
152
+ // Assert
153
+ const secondReport = readFileSync(reportPath, "utf8");
154
+
155
+ expect(secondReport).toContain("1 sessions analyzed (1 total).");
156
+ expect(secondReport).toContain("two");
157
+ });
158
+ });
@@ -0,0 +1,262 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cueloopHome, reportsDir } from "@cueloop/daemon/paths";
4
+ import { ThreadStore } from "@cueloop/daemon/store";
5
+ import {
6
+ LATEST_REPORT_FILENAME,
7
+ parseRefineState,
8
+ pruneExpiredReports,
9
+ resolveCleanupPeriodDays,
10
+ timestampedReportFilename,
11
+ } from "@cueloop/daemon/retention";
12
+ import { isAgentNote, type Annotation, type Thread } from "@cueloop/schema";
13
+ import { parseArgs, stringFlag } from "./args";
14
+
15
+ const DEFAULT_SESSION_LIMIT = 200;
16
+ const QUOTE_LIMIT = 60;
17
+ const BODY_LIMIT = 100;
18
+
19
+ interface AnnotatedEntry {
20
+ annotation: Annotation;
21
+ session: Thread;
22
+ }
23
+
24
+ export async function refineCommand(argv: string[]): Promise<number> {
25
+ const { flags } = parseArgs(argv);
26
+ const home = stringFlag(flags, "home") ?? cueloopHome();
27
+ const limit = parseLimit(stringFlag(flags, "limit"));
28
+ const nowMs = Date.now();
29
+
30
+ const store = new ThreadStore(home);
31
+
32
+ store.recover();
33
+ const all = store.list();
34
+
35
+ const analyzedState = readState(home);
36
+ const fresh = all.filter(
37
+ (session) => analyzedState.get(session.id) !== analysisFingerprint(session),
38
+ );
39
+ const analyzed = fresh.filter(hasReviewSignal).slice(0, limit);
40
+
41
+ const markdown = buildRefineReport(analyzed, all.length, new Date(nowMs).toISOString());
42
+
43
+ const directory = reportsDir(home);
44
+
45
+ mkdirSync(directory, { recursive: true });
46
+ pruneExpiredReports(directory, resolveCleanupPeriodDays(), nowMs);
47
+ const latestPath = join(directory, LATEST_REPORT_FILENAME);
48
+ const timestampedPath = join(directory, timestampedReportFilename(nowMs));
49
+
50
+ writeFileSync(latestPath, markdown);
51
+ writeFileSync(timestampedPath, markdown);
52
+
53
+ for (const session of analyzed) {
54
+ if (session.status === "resolved") analyzedState.set(session.id, analysisFingerprint(session));
55
+ }
56
+ writeState(home, analyzedState);
57
+
58
+ console.log(
59
+ JSON.stringify(
60
+ {
61
+ report: latestPath,
62
+ timestamped: timestampedPath,
63
+ analyzed: analyzed.length,
64
+ total: all.length,
65
+ },
66
+ null,
67
+ 2,
68
+ ),
69
+ );
70
+
71
+ return 0;
72
+ }
73
+
74
+ export function buildRefineReport(
75
+ analyzed: Thread[],
76
+ totalCount: number,
77
+ generatedAt: string,
78
+ ): string {
79
+ const entries = flattenReviewAnnotations(analyzed);
80
+ const lines: string[] = [
81
+ "# refine report",
82
+ "",
83
+ `Generated ${generatedAt}.`,
84
+ "",
85
+ `${analyzed.length} sessions analyzed (${totalCount} total).`,
86
+ "",
87
+ "## Corpus",
88
+ "",
89
+ "By primitive:",
90
+ ...byCount(analyzed, primitiveLabel).map(([label, count]) => `- ${label}: ${count}`),
91
+ "",
92
+ "By verdict:",
93
+ ...byCount(analyzed, verdictLabel).map(([label, count]) => `- ${label}: ${count}`),
94
+ "",
95
+ "## Annotations by kind",
96
+ "",
97
+ ];
98
+
99
+ if (entries.length === 0) {
100
+ lines.push("No reviewer annotations in the analyzed sessions.", "");
101
+ } else {
102
+ for (const [kind, group] of groupByKind(entries)) {
103
+ lines.push(`### ${kind} (${group.length})`, "");
104
+ for (const { annotation, session } of group) {
105
+ const meta = [
106
+ session.id,
107
+ primitiveLabel(session),
108
+ verdictLabel(session),
109
+ isoWeek(annotation.createdAt),
110
+ ].join(" · ");
111
+
112
+ lines.push(
113
+ `- "${truncate(annotation.anchor.quote, QUOTE_LIMIT)}": ${truncate(annotation.body, BODY_LIMIT)} · ${meta}`,
114
+ );
115
+ }
116
+ lines.push("");
117
+ }
118
+ }
119
+
120
+ lines.push("## Weekly volume", "");
121
+ for (const [week, count] of byKey(
122
+ entries.map((entry) => entry.annotation),
123
+ (annotation) => isoWeek(annotation.createdAt),
124
+ )) {
125
+ lines.push(`- ${week}: ${count}`);
126
+ }
127
+ lines.push(
128
+ "",
129
+ "## Next",
130
+ "",
131
+ "Group the annotations above into named patterns of three or more members.",
132
+ "Rank each pattern by how often its members sit on a request-changes or comment verdict.",
133
+ "Draft one writeback per pattern, routed to a skill, AGENTS.md, CLAUDE.md, or memory.",
134
+ "",
135
+ );
136
+
137
+ return lines.join("\n");
138
+ }
139
+
140
+ function hasReviewSignal(session: Thread): boolean {
141
+ return (
142
+ session.verdict !== null || session.annotations.some((annotation) => !isAgentNote(annotation))
143
+ );
144
+ }
145
+
146
+ function flattenReviewAnnotations(sessions: Thread[]): AnnotatedEntry[] {
147
+ const entries: AnnotatedEntry[] = [];
148
+
149
+ for (const session of sessions) {
150
+ for (const annotation of session.annotations) {
151
+ if (!isAgentNote(annotation)) entries.push({ annotation, session });
152
+ }
153
+ }
154
+
155
+ return entries;
156
+ }
157
+
158
+ function groupByKind(entries: AnnotatedEntry[]): [string, AnnotatedEntry[]][] {
159
+ const groups = new Map<string, AnnotatedEntry[]>();
160
+
161
+ for (const entry of entries) {
162
+ const group = groups.get(entry.annotation.kind) ?? [];
163
+
164
+ group.push(entry);
165
+ groups.set(entry.annotation.kind, group);
166
+ }
167
+
168
+ return [...groups.entries()].toSorted(
169
+ (left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]),
170
+ );
171
+ }
172
+
173
+ function byCount<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
174
+ return [...tally(items, keyOf).entries()].toSorted(
175
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
176
+ );
177
+ }
178
+
179
+ function byKey<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
180
+ return [...tally(items, keyOf).entries()].toSorted((left, right) =>
181
+ left[0].localeCompare(right[0]),
182
+ );
183
+ }
184
+
185
+ function tally<T>(items: T[], keyOf: (item: T) => string): Map<string, number> {
186
+ const counts = new Map<string, number>();
187
+
188
+ for (const item of items) {
189
+ const key = keyOf(item);
190
+
191
+ counts.set(key, (counts.get(key) ?? 0) + 1);
192
+ }
193
+
194
+ return counts;
195
+ }
196
+
197
+ function primitiveLabel(session: Thread): string {
198
+ if (session.artifact.type === "diff" && session.artifact.meta.pr) return "pull request";
199
+
200
+ return session.artifact.type;
201
+ }
202
+
203
+ function verdictLabel(session: Thread): string {
204
+ switch (session.verdict?.kind) {
205
+ case "approve":
206
+ return "approve";
207
+ case "request_changes":
208
+ return "request changes";
209
+ case "comment":
210
+ return "comment";
211
+ default:
212
+ return "none";
213
+ }
214
+ }
215
+
216
+ function isoWeek(iso: string): string {
217
+ const date = new Date(iso);
218
+
219
+ if (Number.isNaN(date.getTime())) return "undated";
220
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
221
+ const weekday = utc.getUTCDay() || 7;
222
+
223
+ utc.setUTCDate(utc.getUTCDate() + 4 - weekday);
224
+ const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
225
+ const week = Math.ceil(((utc.getTime() - yearStart.getTime()) / (24 * 60 * 60 * 1000) + 1) / 7);
226
+
227
+ return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
228
+ }
229
+
230
+ function truncate(text: string, max: number): string {
231
+ const collapsed = text.replace(/\s+/g, " ").trim();
232
+
233
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed;
234
+ }
235
+
236
+ function parseLimit(raw: string | undefined): number {
237
+ const value = Number(raw);
238
+
239
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_SESSION_LIMIT;
240
+ }
241
+
242
+ function analysisFingerprint(session: Thread): string {
243
+ return `${session.revisions.length}:${session.annotations.length}:${session.verdict?.resolvedAt ?? "pending"}`;
244
+ }
245
+
246
+ function statePath(home: string): string {
247
+ return join(home, "refine-state.json");
248
+ }
249
+
250
+ function readState(home: string): Map<string, string> {
251
+ try {
252
+ return parseRefineState(JSON.parse(readFileSync(statePath(home), "utf8")));
253
+ } catch {
254
+ return new Map();
255
+ }
256
+ }
257
+
258
+ function writeState(home: string, analyzed: Map<string, string>): void {
259
+ const state = { analyzed: Object.fromEntries(analyzed) };
260
+
261
+ writeFileSync(statePath(home), JSON.stringify(state, null, 2));
262
+ }