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.
- package/package.json +28 -23
- package/src/actions-command.ts +49 -0
- package/src/args.ts +21 -10
- package/src/dev-command.ts +85 -0
- package/src/main.ts +330 -91
- package/src/open-target.test.ts +241 -0
- package/src/open-target.ts +128 -0
- package/src/pr.ts +35 -5
- package/src/refine-command.test.ts +158 -0
- package/src/refine-command.ts +262 -0
- package/src/session-commands.ts +464 -0
- package/src/share-command.test.ts +322 -0
- package/src/share-command.ts +145 -0
- package/src/update-command.test.ts +163 -0
- package/src/update-command.ts +177 -0
- package/src/version.ts +5 -0
- package/src/wake-command.test.ts +14 -0
- package/src/wake-command.ts +44 -0
- package/src/session-cmds.ts +0 -107
- package/src/working-tree.ts +0 -27
|
@@ -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
|
|
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 {
|
|
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<
|
|
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:
|
|
130
|
+
async function postVerdict(session: Thread, pr: string): Promise<number> {
|
|
111
131
|
const verdict = session.verdict!;
|
|
112
|
-
const result = await gh([
|
|
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
|
+
}
|