cueloop 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +11 -6
- package/src/args.ts +10 -10
- package/src/main.ts +160 -33
- package/src/open-target.test.ts +214 -0
- package/src/open-target.ts +106 -0
- package/src/pr.ts +6 -5
- package/src/session-commands.ts +121 -0
- package/src/share-command.test.ts +179 -0
- package/src/share-command.ts +104 -0
- package/src/version.ts +5 -0
- package/src/{workspace.ts → working-tree.ts} +2 -10
- package/src/session-cmds.ts +0 -114
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.30",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
".": "./src/main.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.30",
|
|
14
|
+
"@cueloop/daemon": "0.1.0-alpha.30",
|
|
15
|
+
"@cueloop/client": "0.1.0-alpha.30"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
@@ -28,5 +28,10 @@
|
|
|
28
28
|
"type": "git",
|
|
29
29
|
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
30
30
|
"directory": "packages/cli"
|
|
31
|
-
}
|
|
32
|
-
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
35
|
+
},
|
|
36
|
+
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back"
|
|
37
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -9,16 +9,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
9
9
|
const positional: string[] = [];
|
|
10
10
|
const flags: Record<string, string | boolean> = {};
|
|
11
11
|
for (let i = 0; i < argv.length; i++) {
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
positional.push(
|
|
12
|
+
const argument = argv[i]!;
|
|
13
|
+
if (!argument.startsWith("--")) {
|
|
14
|
+
positional.push(argument);
|
|
15
15
|
continue;
|
|
16
16
|
}
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
flags[
|
|
17
|
+
const equalsIndex = argument.indexOf("=");
|
|
18
|
+
if (equalsIndex !== -1) {
|
|
19
|
+
flags[argument.slice(2, equalsIndex)] = argument.slice(equalsIndex + 1);
|
|
20
20
|
} else {
|
|
21
|
-
const key =
|
|
21
|
+
const key = argument.slice(2);
|
|
22
22
|
const next = argv[i + 1];
|
|
23
23
|
if (next !== undefined && !next.startsWith("--")) {
|
|
24
24
|
flags[key] = next;
|
|
@@ -31,7 +31,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
31
31
|
return { positional, flags };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function
|
|
35
|
-
const
|
|
36
|
-
return typeof
|
|
34
|
+
export function stringFlag(flags: Record<string, string | boolean>, key: string): string | undefined {
|
|
35
|
+
const value = flags[key];
|
|
36
|
+
return typeof value === "string" ? value : undefined;
|
|
37
37
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* cueloop entry points
|
|
4
|
-
* `cueloop diff`
|
|
5
|
-
*
|
|
6
|
-
* `cueloop
|
|
7
|
-
* `cueloop
|
|
3
|
+
* cueloop entry points: `cueloop` opens the TUI on the inbox, the verb-first
|
|
4
|
+
* openers `cueloop plan|diff|review` open the latest pending review of that
|
|
5
|
+
* type (or address one by id/title), `cueloop diff`/`cueloop review <pr>` also
|
|
6
|
+
* keep their create paths, `cueloop session *` mirrors the daemon API for
|
|
7
|
+
* agents and scripts, `cueloop serve` shares a session over ssh (read-only
|
|
8
|
+
* observers), `cueloop daemon` runs the daemon in the foreground.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { parseArgs,
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import { parseArgs, stringFlag, type ParsedArgs } from "./args";
|
|
12
|
+
import {
|
|
13
|
+
isDiffReview,
|
|
14
|
+
isPlanReview,
|
|
15
|
+
isPrReview,
|
|
16
|
+
isSessionId,
|
|
17
|
+
openTargetMessage,
|
|
18
|
+
resolveOpenTarget,
|
|
19
|
+
} from "./open-target";
|
|
20
|
+
import { sessionCommand } from "./session-commands";
|
|
21
|
+
import { CLI_VERSION } from "./version";
|
|
22
|
+
import { workingTreeDiff } from "./working-tree";
|
|
13
23
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
24
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
25
|
+
import { openReview, resolveWorkspace } from "@cueloop/daemon/review";
|
|
14
26
|
|
|
15
27
|
const argv = process.argv.slice(2);
|
|
16
28
|
const cmd = argv[0];
|
|
@@ -31,29 +43,17 @@ async function main(): Promise<number> {
|
|
|
31
43
|
await new Promise(() => {}); // run until signalled
|
|
32
44
|
return 0;
|
|
33
45
|
}
|
|
34
|
-
case "
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
console.error("working tree is clean - nothing to review");
|
|
39
|
-
return 1;
|
|
40
|
-
}
|
|
41
|
-
const client = await DaemonClient.connect({ autostart: true });
|
|
42
|
-
const session = await client.sessionCreate(workspace, {
|
|
43
|
-
type: "diff",
|
|
44
|
-
content: diff,
|
|
45
|
-
meta: { title: `working tree @ ${workspace.branch}`, cwd: process.cwd() },
|
|
46
|
-
});
|
|
47
|
-
client.close();
|
|
48
|
-
return runTui(session.id);
|
|
49
|
-
}
|
|
46
|
+
case "plan":
|
|
47
|
+
return planCommand(argv.slice(1));
|
|
48
|
+
case "diff":
|
|
49
|
+
return diffCommand(argv.slice(1));
|
|
50
50
|
case "serve": {
|
|
51
51
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
52
|
-
const port =
|
|
52
|
+
const port = stringFlag(flags, "port");
|
|
53
53
|
const { serveClient } = await import("@cueloop/client");
|
|
54
54
|
const handle = await serveClient({
|
|
55
55
|
port: port !== undefined ? Number(port) : undefined,
|
|
56
|
-
host:
|
|
56
|
+
host: stringFlag(flags, "host"),
|
|
57
57
|
sessionId: positional[0],
|
|
58
58
|
});
|
|
59
59
|
console.log(
|
|
@@ -73,14 +73,26 @@ async function main(): Promise<number> {
|
|
|
73
73
|
await new Promise(() => {}); // serve until signalled
|
|
74
74
|
return 0;
|
|
75
75
|
}
|
|
76
|
-
case "
|
|
77
|
-
const {
|
|
78
|
-
|
|
76
|
+
case "share": {
|
|
77
|
+
const { positional, flags } = parseArgs(argv.slice(1));
|
|
78
|
+
const { shareCommand, sharePullCommand } = await import("./share-command");
|
|
79
|
+
const port = stringFlag(flags, "port");
|
|
80
|
+
const target = { host: stringFlag(flags, "host"), port: port !== undefined ? Number(port) : undefined };
|
|
81
|
+
if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
82
|
+
return shareCommand({ ...target, sessionId: positional[0] });
|
|
79
83
|
}
|
|
84
|
+
case "review":
|
|
85
|
+
return reviewEntry(argv.slice(1));
|
|
80
86
|
case "review-post": {
|
|
81
87
|
const { reviewPostCommand } = await import("./pr");
|
|
82
88
|
return reviewPostCommand(argv.slice(1));
|
|
83
89
|
}
|
|
90
|
+
case "-v":
|
|
91
|
+
case "--version":
|
|
92
|
+
case "version":
|
|
93
|
+
console.log(CLI_VERSION);
|
|
94
|
+
return 0;
|
|
95
|
+
case "-h":
|
|
84
96
|
case "--help":
|
|
85
97
|
case "help":
|
|
86
98
|
printHelp();
|
|
@@ -96,6 +108,103 @@ async function main(): Promise<number> {
|
|
|
96
108
|
}
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The id-or-title selector for a verb-first opener: the bare positional, or a
|
|
113
|
+
* value handed to `--open`/`--latest`. A bare `--latest`/`--open` flag carries
|
|
114
|
+
* no value, so the selector stays undefined and the opener defaults to the
|
|
115
|
+
* latest pending review.
|
|
116
|
+
*/
|
|
117
|
+
function openSelector(parsed: ParsedArgs): string | undefined {
|
|
118
|
+
return parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve one review of the verb's scope and open it in the TUI, or print the
|
|
123
|
+
* miss and fail. `emptyMessage` overrides the default no-pending line for the
|
|
124
|
+
* one caller that needs a scope-specific hint (a clean working tree).
|
|
125
|
+
*/
|
|
126
|
+
async function openReviewOfKind(
|
|
127
|
+
match: (session: ReviewSession) => boolean,
|
|
128
|
+
label: string,
|
|
129
|
+
selector: string | undefined,
|
|
130
|
+
emptyMessage?: string,
|
|
131
|
+
): Promise<number> {
|
|
132
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
133
|
+
let sessions: ReviewSession[];
|
|
134
|
+
try {
|
|
135
|
+
sessions = await client.sessionList();
|
|
136
|
+
} finally {
|
|
137
|
+
client.close();
|
|
138
|
+
}
|
|
139
|
+
const target = resolveOpenTarget(sessions, { match, selector });
|
|
140
|
+
if (target.kind === "session") return runTui(target.sessionId);
|
|
141
|
+
if (target.kind === "no-pending" && emptyMessage !== undefined) {
|
|
142
|
+
console.error(emptyMessage);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
console.error(openTargetMessage(label, target));
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `cueloop plan [id|title]` - open the latest pending plan, or address one. */
|
|
150
|
+
async function planCommand(argv: string[]): Promise<number> {
|
|
151
|
+
return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `cueloop diff` disambiguates create from open by intent:
|
|
156
|
+
* - a selector (`cueloop diff <id|title>`) or an explicit `--open`/`--latest`
|
|
157
|
+
* opens a pending diff review;
|
|
158
|
+
* - otherwise a dirty working tree still creates a review as before;
|
|
159
|
+
* - a clean working tree carries no create input, so it opens the latest
|
|
160
|
+
* pending diff review instead of erroring.
|
|
161
|
+
*/
|
|
162
|
+
async function diffCommand(argv: string[]): Promise<number> {
|
|
163
|
+
const parsed = parseArgs(argv);
|
|
164
|
+
const selector = openSelector(parsed);
|
|
165
|
+
const wantsOpen = selector !== undefined || "open" in parsed.flags || "latest" in parsed.flags;
|
|
166
|
+
if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector);
|
|
167
|
+
|
|
168
|
+
const workspace = await resolveWorkspace();
|
|
169
|
+
const diff = await workingTreeDiff();
|
|
170
|
+
if (!diff.trim()) {
|
|
171
|
+
return openReviewOfKind(
|
|
172
|
+
isDiffReview,
|
|
173
|
+
"diff",
|
|
174
|
+
undefined,
|
|
175
|
+
"working tree is clean and no pending diff review - nothing to open",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
179
|
+
const review = await openReview(client, {
|
|
180
|
+
type: "diff",
|
|
181
|
+
content: diff,
|
|
182
|
+
workspace,
|
|
183
|
+
title: `working tree @ ${workspace.branch}`,
|
|
184
|
+
});
|
|
185
|
+
client.close();
|
|
186
|
+
return runTui(review.id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `cueloop review` disambiguates create from open by intent:
|
|
191
|
+
* - bare `cueloop review`, `--open`/`--latest`, or a `ses_*` selector opens a
|
|
192
|
+
* pending PR review (a diff carrying a `pr` reference);
|
|
193
|
+
* - any other positional is a PR reference and takes the create path in pr.ts.
|
|
194
|
+
*/
|
|
195
|
+
async function reviewEntry(argv: string[]): Promise<number> {
|
|
196
|
+
const parsed = parseArgs(argv);
|
|
197
|
+
const explicitOpen = "open" in parsed.flags || "latest" in parsed.flags;
|
|
198
|
+
const selector = openSelector(parsed);
|
|
199
|
+
const looksLikeSessionId = selector !== undefined && isSessionId(selector);
|
|
200
|
+
const wantsCreate = !explicitOpen && !looksLikeSessionId && selector !== undefined;
|
|
201
|
+
if (wantsCreate) {
|
|
202
|
+
const { reviewCommand } = await import("./pr");
|
|
203
|
+
return reviewCommand(argv);
|
|
204
|
+
}
|
|
205
|
+
return openReviewOfKind(isPrReview, "PR", explicitOpen || looksLikeSessionId ? selector : undefined);
|
|
206
|
+
}
|
|
207
|
+
|
|
99
208
|
async function runTui(sessionId?: string): Promise<number> {
|
|
100
209
|
const { runClient } = await import("@cueloop/client");
|
|
101
210
|
return runClient({ sessionId });
|
|
@@ -106,17 +215,35 @@ function printHelp(): void {
|
|
|
106
215
|
[
|
|
107
216
|
"cueloop - review surface for coding agents",
|
|
108
217
|
"",
|
|
109
|
-
"usage:",
|
|
218
|
+
"usage: cueloop [command] [options]",
|
|
219
|
+
"",
|
|
220
|
+
"common commands:",
|
|
110
221
|
" cueloop open the inbox",
|
|
111
|
-
" cueloop
|
|
112
|
-
" cueloop diff
|
|
222
|
+
" cueloop plan [id|title] open the latest pending plan review (or one by id/title)",
|
|
223
|
+
" cueloop diff [id|title] review your working tree (untracked files included);",
|
|
224
|
+
" with a clean tree, open the latest pending diff review",
|
|
113
225
|
" cueloop review <pr> review a pull request (--no-tui prints the session)",
|
|
114
|
-
"
|
|
226
|
+
"",
|
|
227
|
+
"share:",
|
|
115
228
|
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
116
229
|
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
117
230
|
" password-less - share the address deliberately)",
|
|
231
|
+
" cueloop share [session-id] hand a plan to a teammate: copies one",
|
|
232
|
+
" ssh line (--host cueloop.dev, --port 22)",
|
|
233
|
+
" cueloop share pull [session-id] pull a teammate's annotations back into the plan",
|
|
234
|
+
"",
|
|
235
|
+
"open a specific review:",
|
|
236
|
+
" cueloop <session-id> open one session",
|
|
237
|
+
" cueloop review [id|title] open the latest pending PR review (or one by id/title)",
|
|
238
|
+
" cueloop <plan|diff|review> --latest open the latest pending review of that type",
|
|
239
|
+
"",
|
|
240
|
+
"scripting:",
|
|
118
241
|
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
242
|
+
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
119
243
|
" cueloop daemon run the daemon in the foreground",
|
|
244
|
+
"",
|
|
245
|
+
" cueloop -v, --version print the installed version",
|
|
246
|
+
" cueloop -h, --help print this help",
|
|
120
247
|
].join("\n"),
|
|
121
248
|
);
|
|
122
249
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Verb-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { ArtifactType, ReviewSession } from "@cueloop/schema";
|
|
5
|
+
import {
|
|
6
|
+
isDiffReview,
|
|
7
|
+
isPlanReview,
|
|
8
|
+
isPrReview,
|
|
9
|
+
isSessionId,
|
|
10
|
+
openTargetMessage,
|
|
11
|
+
resolveOpenTarget,
|
|
12
|
+
} from "./open-target";
|
|
13
|
+
|
|
14
|
+
function session(overrides: {
|
|
15
|
+
id: string;
|
|
16
|
+
type?: ArtifactType;
|
|
17
|
+
title?: string;
|
|
18
|
+
status?: "pending" | "resolved";
|
|
19
|
+
createdAt?: string;
|
|
20
|
+
pr?: string;
|
|
21
|
+
}): ReviewSession {
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: "1",
|
|
24
|
+
id: overrides.id,
|
|
25
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
26
|
+
artifact: {
|
|
27
|
+
type: overrides.type ?? "plan",
|
|
28
|
+
content: "",
|
|
29
|
+
meta: { title: overrides.title, pr: overrides.pr },
|
|
30
|
+
},
|
|
31
|
+
revisions: [],
|
|
32
|
+
annotations: [],
|
|
33
|
+
verdict: overrides.status === "resolved" ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" } : null,
|
|
34
|
+
status: overrides.status ?? "pending",
|
|
35
|
+
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isPlan = isPlanReview;
|
|
40
|
+
|
|
41
|
+
describe("verb scope predicates are disjoint", () => {
|
|
42
|
+
test("isPlanReview matches only plan artifacts", () => {
|
|
43
|
+
expect(isPlanReview(session({ id: "ses_p", type: "plan" }))).toBe(true);
|
|
44
|
+
expect(isPlanReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("isDiffReview matches a plain diff but NOT a diff carrying meta.pr", () => {
|
|
48
|
+
expect(isDiffReview(session({ id: "ses_d", type: "diff" }))).toBe(true);
|
|
49
|
+
expect(isDiffReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(false);
|
|
50
|
+
expect(isDiffReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("isPrReview matches a diff carrying meta.pr, and only that", () => {
|
|
54
|
+
expect(isPrReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(true);
|
|
55
|
+
expect(isPrReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
56
|
+
expect(isPrReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a PR review resolves under exactly one scope - never both diff and review", () => {
|
|
60
|
+
// Arrange
|
|
61
|
+
const prReview = session({ id: "ses_pr", type: "diff", pr: "42" });
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(isDiffReview(prReview)).toBe(false);
|
|
65
|
+
expect(isPrReview(prReview)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isSessionId", () => {
|
|
70
|
+
test("only the ses_ prefix reads as an id", () => {
|
|
71
|
+
expect(isSessionId("ses_abc")).toBe(true);
|
|
72
|
+
expect(isSessionId("some title")).toBe(false);
|
|
73
|
+
expect(isSessionId("42")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveOpenTarget: latest pending default", () => {
|
|
78
|
+
test("picks the most recent pending session in scope", () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const sessions = [
|
|
81
|
+
session({ id: "ses_old", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
82
|
+
session({ id: "ses_new", createdAt: "2026-03-01T00:00:00.000Z" }),
|
|
83
|
+
session({ id: "ses_mid", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_new" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ignores resolved sessions and other artifact types", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
const sessions = [
|
|
93
|
+
session({ id: "ses_resolved", createdAt: "2026-05-01T00:00:00.000Z", status: "resolved" }),
|
|
94
|
+
session({ id: "ses_diff", type: "diff", createdAt: "2026-04-01T00:00:00.000Z" }),
|
|
95
|
+
session({ id: "ses_plan", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_plan" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("no pending session in scope reports no-pending", () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const sessions = [session({ id: "ses_done", status: "resolved" })];
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "no-pending" });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("resolveOpenTarget: by id", () => {
|
|
112
|
+
test("an exact session id in scope opens that session, pending or not", () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const sessions = [
|
|
115
|
+
session({ id: "ses_a" }),
|
|
116
|
+
session({ id: "ses_b", status: "resolved" }),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
121
|
+
kind: "session",
|
|
122
|
+
sessionId: "ses_b",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("an id outside the scope does not match", () => {
|
|
127
|
+
// Arrange
|
|
128
|
+
const sessions = [session({ id: "ses_diff", type: "diff" })];
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_diff" })).toEqual({
|
|
132
|
+
kind: "no-match",
|
|
133
|
+
selector: "ses_diff",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("resolveOpenTarget: fuzzy by title", () => {
|
|
139
|
+
test("case-insensitive exact title wins over substring rivals", () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const sessions = [
|
|
142
|
+
session({ id: "ses_exact", title: "Rollout" }),
|
|
143
|
+
session({ id: "ses_sub", title: "Rollout plan v2" }),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
148
|
+
kind: "session",
|
|
149
|
+
sessionId: "ses_exact",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a unique case-insensitive substring match wins", () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
const sessions = [
|
|
156
|
+
session({ id: "ses_auth", title: "Auth rewrite" }),
|
|
157
|
+
session({ id: "ses_cache", title: "Cache warmup" }),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "AUTH" })).toEqual({
|
|
162
|
+
kind: "session",
|
|
163
|
+
sessionId: "ses_auth",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("several substring matches are ambiguous and list the candidate titles", () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const sessions = [
|
|
170
|
+
session({ id: "ses_1", title: "Payments rollout" }),
|
|
171
|
+
session({ id: "ses_2", title: "Rollout of search" }),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
175
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
176
|
+
kind: "ambiguous",
|
|
177
|
+
selector: "rollout",
|
|
178
|
+
titles: ["Payments rollout", "Rollout of search"],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("no title match reports no-match", () => {
|
|
183
|
+
// Arrange
|
|
184
|
+
const sessions = [session({ id: "ses_1", title: "Auth rewrite" })];
|
|
185
|
+
|
|
186
|
+
// Assert
|
|
187
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "billing" })).toEqual({
|
|
188
|
+
kind: "no-match",
|
|
189
|
+
selector: "billing",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("openTargetMessage", () => {
|
|
195
|
+
test("no-pending mirrors the clean-tree tone", () => {
|
|
196
|
+
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe("no pending plan review - nothing to open");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("no-match names the selector", () => {
|
|
200
|
+
expect(openTargetMessage("diff", { kind: "no-match", selector: "billing" })).toBe(
|
|
201
|
+
'no diff review matches "billing" - nothing to open',
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("ambiguous lists the candidate titles", () => {
|
|
206
|
+
// Act
|
|
207
|
+
const message = openTargetMessage("plan", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
210
|
+
expect(message).toContain('"rollout" matches several plan reviews');
|
|
211
|
+
expect(message).toContain(" - A");
|
|
212
|
+
expect(message).toContain(" - B");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verb-first review-open resolution: turn a verb (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 verb only
|
|
16
|
+
* ever resolves to its own artifact kind.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The verb scopes, kept disjoint on purpose so a single session never resolves
|
|
23
|
+
* under two verbs. 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: ReviewSession): boolean {
|
|
29
|
+
return session.artifact.type === "plan";
|
|
30
|
+
}
|
|
31
|
+
export function isDiffReview(session: ReviewSession): boolean {
|
|
32
|
+
return session.artifact.type === "diff" && session.artifact.meta.pr === undefined;
|
|
33
|
+
}
|
|
34
|
+
export function isPrReview(session: ReviewSession): boolean {
|
|
35
|
+
return session.artifact.type === "diff" && session.artifact.meta.pr !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Session ids are minted with this prefix; the id-vs-title split lives here. */
|
|
39
|
+
export function isSessionId(value: string): boolean {
|
|
40
|
+
return value.startsWith("ses_");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OpenTargetQuery {
|
|
44
|
+
/** Only sessions this predicate accepts are eligible - the verb's artifact scope. */
|
|
45
|
+
match: (session: ReviewSession) => boolean;
|
|
46
|
+
/** Positional id-or-title selector; absent means "latest pending". */
|
|
47
|
+
selector?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type OpenTarget =
|
|
51
|
+
| { kind: "session"; sessionId: string }
|
|
52
|
+
| { kind: "no-pending" }
|
|
53
|
+
| { kind: "no-match"; selector: string }
|
|
54
|
+
| { kind: "ambiguous"; selector: string; titles: string[] };
|
|
55
|
+
|
|
56
|
+
/** Most-recent-first, so the head of a sorted list is always the latest. */
|
|
57
|
+
function newestFirst(left: ReviewSession, right: ReviewSession): number {
|
|
58
|
+
return right.createdAt.localeCompare(left.createdAt);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQuery): OpenTarget {
|
|
62
|
+
const scoped = sessions.filter(query.match);
|
|
63
|
+
|
|
64
|
+
if (query.selector === undefined) {
|
|
65
|
+
const latestPending = scoped.filter((candidate) => candidate.status === "pending").sort(newestFirst)[0];
|
|
66
|
+
return latestPending ? { kind: "session", sessionId: latestPending.id } : { kind: "no-pending" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const selector = query.selector;
|
|
70
|
+
const byId = scoped.find((candidate) => candidate.id === selector);
|
|
71
|
+
if (byId) return { kind: "session", sessionId: byId.id };
|
|
72
|
+
|
|
73
|
+
const loweredSelector = selector.toLowerCase();
|
|
74
|
+
const titled = scoped.filter((candidate) => (candidate.artifact.meta.title ?? "").length > 0);
|
|
75
|
+
|
|
76
|
+
const exactTitle = titled
|
|
77
|
+
.filter((candidate) => candidate.artifact.meta.title!.toLowerCase() === loweredSelector)
|
|
78
|
+
.sort(newestFirst)[0];
|
|
79
|
+
if (exactTitle) return { kind: "session", sessionId: exactTitle.id };
|
|
80
|
+
|
|
81
|
+
const substringMatches = titled
|
|
82
|
+
.filter((candidate) => candidate.artifact.meta.title!.toLowerCase().includes(loweredSelector))
|
|
83
|
+
.sort(newestFirst);
|
|
84
|
+
if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
|
|
85
|
+
if (substringMatches.length === 0) return { kind: "no-match", selector };
|
|
86
|
+
return { kind: "ambiguous", selector, titles: substringMatches.map((candidate) => candidate.artifact.meta.title!) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The reviewer-facing line for a miss, in the "working tree is clean - nothing
|
|
91
|
+
* to review" tone. `label` is the human word for the verb's scope ("plan",
|
|
92
|
+
* "diff", "PR"). The "session" outcome has no message - the caller opens it.
|
|
93
|
+
*/
|
|
94
|
+
export function openTargetMessage(label: string, target: Exclude<OpenTarget, { kind: "session" }>): string {
|
|
95
|
+
switch (target.kind) {
|
|
96
|
+
case "no-pending":
|
|
97
|
+
return `no pending ${label} review - nothing to open`;
|
|
98
|
+
case "no-match":
|
|
99
|
+
return `no ${label} review matches "${target.selector}" - nothing to open`;
|
|
100
|
+
case "ambiguous":
|
|
101
|
+
return [
|
|
102
|
+
`"${target.selector}" matches several ${label} reviews - name one:`,
|
|
103
|
+
...target.titles.map((title) => ` - ${title}`),
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/pr.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
10
10
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
11
|
+
import { openReview } from "@cueloop/daemon/review";
|
|
11
12
|
import { parseArgs } from "./args";
|
|
12
|
-
import { resolveWorkspace } from "./workspace";
|
|
13
13
|
|
|
14
14
|
/** The gh binary is injectable so tests can stub it. */
|
|
15
15
|
function ghBin(): string {
|
|
@@ -55,13 +55,14 @@ export async function reviewCommand(argv: string[]): Promise<number> {
|
|
|
55
55
|
console.error(`PR ${pr} has an empty diff - nothing to review`);
|
|
56
56
|
return 1;
|
|
57
57
|
}
|
|
58
|
-
const workspace = await resolveWorkspace();
|
|
59
58
|
const client = await DaemonClient.connect({ autostart: true });
|
|
60
|
-
const
|
|
59
|
+
const review = await openReview(client, {
|
|
61
60
|
type: "diff",
|
|
62
61
|
content: diff.stdout,
|
|
63
|
-
|
|
62
|
+
title: `PR ${pr}`,
|
|
63
|
+
pr,
|
|
64
64
|
});
|
|
65
|
+
const session = review.session;
|
|
65
66
|
client.close();
|
|
66
67
|
|
|
67
68
|
if (flags["no-tui"]) {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop session *` - mirrors the daemon socket API 1:1. One surface,
|
|
3
|
+
* three consumers: agent adapters, the dev loop, and integrations. Output is
|
|
4
|
+
* JSON on stdout; exit code 0 unless the daemon returned an error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { newAnnotationId, type ArtifactType, type VerdictKind } from "@cueloop/schema";
|
|
8
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
|
+
import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
|
|
10
|
+
import { openReview, verdictResponse, type ReviewNote } from "@cueloop/daemon/review";
|
|
11
|
+
import { parseArgs, stringFlag } from "./args";
|
|
12
|
+
|
|
13
|
+
async function readStdin(): Promise<string> {
|
|
14
|
+
return await new Response(Bun.stdin.stream()).text();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function out(value: unknown): void {
|
|
18
|
+
console.log(JSON.stringify(value, null, 2));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function sessionCommand(argv: string[]): Promise<number> {
|
|
22
|
+
const { positional, flags } = parseArgs(argv);
|
|
23
|
+
const verb = positional[0];
|
|
24
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
25
|
+
try {
|
|
26
|
+
switch (verb) {
|
|
27
|
+
case "create": {
|
|
28
|
+
const contentFile = stringFlag(flags, "content-file");
|
|
29
|
+
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
30
|
+
// per-file agent notes for diff sessions: a JSON array of { path, body }
|
|
31
|
+
const notesFile = stringFlag(flags, "notes-file");
|
|
32
|
+
const notes = notesFile ? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[]) : undefined;
|
|
33
|
+
const review = await openReview(client, {
|
|
34
|
+
type: (stringFlag(flags, "type") ?? "plan") as ArtifactType,
|
|
35
|
+
content,
|
|
36
|
+
cwd: stringFlag(flags, "cwd"),
|
|
37
|
+
agent: stringFlag(flags, "agent"),
|
|
38
|
+
agentSessionId: stringFlag(flags, "agent-session-id"),
|
|
39
|
+
planPath: stringFlag(flags, "plan-path"),
|
|
40
|
+
title: stringFlag(flags, "title"),
|
|
41
|
+
notes,
|
|
42
|
+
});
|
|
43
|
+
// herdr auto-open: a review created from inside herdr spawns a new tab
|
|
44
|
+
// that renders it. Guarded to genuinely new sessions and no-op outside
|
|
45
|
+
// herdr; a broken binary never blocks the create.
|
|
46
|
+
openHerdrPaneForReview(review.session);
|
|
47
|
+
out(review.session);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
case "get": {
|
|
51
|
+
out(await client.sessionGet(required(positional[1], "session id")));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
case "list": {
|
|
55
|
+
const status = stringFlag(flags, "status") as "pending" | "resolved" | undefined;
|
|
56
|
+
out(await client.sessionList(status ? { status } : undefined));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
case "wait": {
|
|
60
|
+
const id = required(positional[1], "session id");
|
|
61
|
+
const timeoutMs = Number(stringFlag(flags, "timeout-ms") ?? "60000");
|
|
62
|
+
const session = await client.sessionWait(id, timeoutMs);
|
|
63
|
+
if (session === null) {
|
|
64
|
+
out({ status: "pending" });
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const { allow, feedback } = verdictResponse(session);
|
|
68
|
+
out({ status: "resolved", allow, verdict: session.verdict!.kind, feedback });
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
case "annotate": {
|
|
72
|
+
const id = required(positional[1], "session id");
|
|
73
|
+
const body = stringFlag(flags, "body") ?? "";
|
|
74
|
+
const quote = required(stringFlag(flags, "quote"), "--quote");
|
|
75
|
+
out(
|
|
76
|
+
await client.sessionAnnotate(id, {
|
|
77
|
+
id: stringFlag(flags, "annotation-id") ?? newAnnotationId(),
|
|
78
|
+
kind: stringFlag(flags, "kind") ?? "comment",
|
|
79
|
+
anchor: {
|
|
80
|
+
quote,
|
|
81
|
+
prefix: stringFlag(flags, "prefix") ?? "",
|
|
82
|
+
suffix: stringFlag(flags, "suffix") ?? "",
|
|
83
|
+
},
|
|
84
|
+
body,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
case "resolve": {
|
|
90
|
+
const id = required(positional[1], "session id");
|
|
91
|
+
const kind = required(stringFlag(flags, "verdict"), "--verdict") as VerdictKind;
|
|
92
|
+
out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? ""));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
case "submit-revision": {
|
|
96
|
+
const id = required(positional[1], "session id");
|
|
97
|
+
const contentFile = stringFlag(flags, "content-file");
|
|
98
|
+
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
99
|
+
// --addressed a_1,a_2 - the annotation ids this revision acted on
|
|
100
|
+
const addressed = (stringFlag(flags, "addressed") ?? "")
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((annotationId) => annotationId.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
out(await client.sessionSubmitRevision(id, content, addressed));
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
console.error(
|
|
109
|
+
"usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
|
|
110
|
+
);
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
client.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function required<T>(value: T | undefined, description: string): T {
|
|
119
|
+
if (value === undefined) throw new Error(`missing ${description}`);
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
+
import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
|
|
5
|
+
|
|
6
|
+
function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: SCHEMA_VERSION,
|
|
9
|
+
id,
|
|
10
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
11
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
12
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
13
|
+
annotations: [],
|
|
14
|
+
verdict: null,
|
|
15
|
+
status: "pending",
|
|
16
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function annotationFixture(id: string, author?: string): Annotation {
|
|
22
|
+
const base: Annotation = { id, kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "note", createdAt: "2026-01-01T00:00:00.000Z" };
|
|
23
|
+
return author ? { ...base, author } : base;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A SessionClient that answers get/list from a fixed list and records the share/merge verbs. */
|
|
27
|
+
function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
28
|
+
return {
|
|
29
|
+
sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
|
|
30
|
+
sessionList: async () => sessions,
|
|
31
|
+
sessionSetShareId: mock(async (id: string, shareId: string) => {
|
|
32
|
+
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
33
|
+
session.shareId = shareId;
|
|
34
|
+
return session;
|
|
35
|
+
}),
|
|
36
|
+
sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
|
|
37
|
+
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
38
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
39
|
+
for (const annotation of incoming.annotations) if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
40
|
+
return session;
|
|
41
|
+
}),
|
|
42
|
+
} as unknown as SessionClient;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
return {
|
|
48
|
+
publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true })),
|
|
49
|
+
out: (message: string) => void lines.push(message),
|
|
50
|
+
lines,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe(shareSession, () => {
|
|
56
|
+
test("publishes the named session and reports the copied ssh line", async () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
59
|
+
const deps = depsSpy({ publish });
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
const code = await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(code).toBe(0);
|
|
66
|
+
expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("without an id, shares the most recent session", async () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
|
|
72
|
+
const deps = depsSpy({ publish });
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), {}, deps);
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
expect(deps.publish).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("prints the bare line when the clipboard is unavailable", async () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const deps = depsSpy({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
|
|
84
|
+
|
|
85
|
+
// Act
|
|
86
|
+
await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(deps.lines).toEqual(["ssh p_abc123xy@cueloop.dev"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("reports nothing to share when the inbox is empty", async () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const deps = depsSpy();
|
|
95
|
+
|
|
96
|
+
// Act
|
|
97
|
+
const code = await shareSession(fakeClient([]), {}, deps);
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(code).toBe(1);
|
|
101
|
+
expect(deps.lines[0]).toContain("no plan to share");
|
|
102
|
+
expect(deps.publish).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("stamps the share id on the session so a later pull can find it", async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const session = sessionFixture("ses_1");
|
|
108
|
+
const client = fakeClient([session]);
|
|
109
|
+
const deps = depsSpy();
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
await shareSession(client, { sessionId: "ses_1" }, deps);
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
return { pull: mock(async () => remote), out: (message: string) => void lines.push(message), lines };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe(pullSession, () => {
|
|
125
|
+
test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
|
|
126
|
+
// Arrange - the planner's own note carries no author; the collaborator's does
|
|
127
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [annotationFixture("mine")] });
|
|
128
|
+
const client = fakeClient([local]);
|
|
129
|
+
const remote = sessionFixture("ses_1", { annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")] });
|
|
130
|
+
const deps = pullDepsSpy(remote);
|
|
131
|
+
|
|
132
|
+
// Act
|
|
133
|
+
const code = await pullSession(client, { sessionId: "ses_1" }, deps);
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
expect(deps.pull).toHaveBeenCalledWith("p_abc123xy", { host: undefined, port: undefined });
|
|
138
|
+
expect(local.annotations.map((annotation) => annotation.id)).toEqual(["mine", "theirs"]);
|
|
139
|
+
expect(deps.lines).toEqual(["pulled 1 new annotation"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("does not resurrect a note the planner deleted locally after sharing", async () => {
|
|
143
|
+
// Arrange - the planner deleted "mine" locally; the share blob still carries it, unauthored
|
|
144
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [] });
|
|
145
|
+
const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("mine")] }));
|
|
146
|
+
|
|
147
|
+
// Act
|
|
148
|
+
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
151
|
+
expect(local.annotations).toEqual([]);
|
|
152
|
+
expect(deps.lines).toEqual(["no new annotations"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("reports when nothing new came back", async () => {
|
|
156
|
+
// Arrange
|
|
157
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [annotationFixture("theirs", "SHA256:mate")] });
|
|
158
|
+
const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }));
|
|
159
|
+
|
|
160
|
+
// Act
|
|
161
|
+
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(deps.lines).toEqual(["no new annotations"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("refuses to pull a plan that was never shared", async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const deps = pullDepsSpy(sessionFixture("ses_1"));
|
|
170
|
+
|
|
171
|
+
// Act
|
|
172
|
+
const code = await pullSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
175
|
+
expect(code).toBe(1);
|
|
176
|
+
expect(deps.lines[0]).toContain("no shared plan");
|
|
177
|
+
expect(deps.pull).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop share [session-id]` - hand a plan to a teammate as one SSH line.
|
|
3
|
+
* This side just picks the session and delegates to the shared `publishShare`
|
|
4
|
+
* transport (the same one the in-TUI Share button uses); the gateway seals and
|
|
5
|
+
* stores. The transport and output are injected so the orchestration is
|
|
6
|
+
* testable without a live gateway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { collaboratorAnnotations, publishShare, pullShare, shareIdFromLine, type ShareResult, type ShareTarget } from "@cueloop/client";
|
|
11
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
12
|
+
|
|
13
|
+
export interface ShareParams {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
home?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ShareDeps {
|
|
21
|
+
publish: (session: ReviewSession, target: ShareTarget) => Promise<ShareResult>;
|
|
22
|
+
out: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PullDeps {
|
|
26
|
+
pull: (shareId: string, target: ShareTarget) => Promise<ReviewSession>;
|
|
27
|
+
out: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
|
|
31
|
+
const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
|
|
32
|
+
|
|
33
|
+
/** Connect to the local daemon, then share the chosen session. */
|
|
34
|
+
export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
|
|
35
|
+
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
36
|
+
try {
|
|
37
|
+
return await shareSession(client, params, deps);
|
|
38
|
+
} finally {
|
|
39
|
+
client.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Connect to the local daemon, then pull collaborator notes for the plan. */
|
|
44
|
+
export async function sharePullCommand(params: ShareParams, deps: PullDeps = defaultPullDeps): Promise<number> {
|
|
45
|
+
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
46
|
+
try {
|
|
47
|
+
return await pullSession(client, params, deps);
|
|
48
|
+
} finally {
|
|
49
|
+
client.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The share orchestration, over any SessionClient - the seam the tests drive. */
|
|
54
|
+
export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
|
|
55
|
+
const session = await pickSession(client, params.sessionId);
|
|
56
|
+
if (!session) {
|
|
57
|
+
deps.out("no plan to share - open a review first");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
|
|
61
|
+
const shareId = shareIdFromLine(line);
|
|
62
|
+
if (shareId) await client.sessionSetShareId(session.id, shareId);
|
|
63
|
+
deps.out(copied ? `share link copied - ${line}` : line);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pull the share's current session and union its annotations into the local
|
|
69
|
+
* plan. Union-by-id keeps the planner's own notes; only collaborators' new
|
|
70
|
+
* ones land. The gateway lets only the fingerprint that shared it pull.
|
|
71
|
+
*/
|
|
72
|
+
export async function pullSession(client: SessionClient, params: ShareParams, deps: PullDeps): Promise<number> {
|
|
73
|
+
const session = await pickSharedSession(client, params.sessionId);
|
|
74
|
+
if (!session) {
|
|
75
|
+
deps.out("no shared plan to pull - run `cueloop share` first");
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
const remote = await deps.pull(session.shareId!, { host: params.host, port: params.port });
|
|
79
|
+
const before = session.annotations.length;
|
|
80
|
+
const merged = await client.sessionMergeShared(session.id, {
|
|
81
|
+
annotations: collaboratorAnnotations(remote),
|
|
82
|
+
participants: remote.participants,
|
|
83
|
+
});
|
|
84
|
+
const added = merged.annotations.length - before;
|
|
85
|
+
deps.out(added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations");
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The named session, or the most recent one when no id is given. */
|
|
90
|
+
async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
91
|
+
if (sessionId) return client.sessionGet(sessionId);
|
|
92
|
+
const sessions = await client.sessionList();
|
|
93
|
+
return sessions.at(-1) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The named session (if shared), or the most recent shared one. */
|
|
97
|
+
async function pickSharedSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
98
|
+
if (sessionId) {
|
|
99
|
+
const session = await client.sessionGet(sessionId);
|
|
100
|
+
return session.shareId ? session : null;
|
|
101
|
+
}
|
|
102
|
+
const sessions = await client.sessionList();
|
|
103
|
+
return sessions.filter((session) => session.shareId).at(-1) ?? null;
|
|
104
|
+
}
|
package/src/version.ts
ADDED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
import type { WorkspaceKey } from "@cueloop/schema";
|
|
1
|
+
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
4
2
|
|
|
5
3
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
6
4
|
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
@@ -9,15 +7,9 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
9
7
|
return out.trim();
|
|
10
8
|
}
|
|
11
9
|
|
|
12
|
-
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
13
|
-
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
14
|
-
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
15
|
-
return { repoRoot, branch };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
export async function workingTreeDiff(cwd = process.cwd()): Promise<string> {
|
|
19
11
|
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
20
|
-
// untracked files included by default
|
|
12
|
+
// untracked files included by default
|
|
21
13
|
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
22
14
|
let untracked = "";
|
|
23
15
|
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
package/src/session-cmds.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `cueloop session *` - mirrors the daemon socket API 1:1 (#14). One surface,
|
|
3
|
-
* three consumers: agent adapters, the dev loop, and integrations. Output is
|
|
4
|
-
* JSON on stdout; exit code 0 unless the daemon returned an error.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ArtifactType, VerdictKind } from "@cueloop/schema";
|
|
8
|
-
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
|
-
import { parseArgs, flagStr } from "./args";
|
|
10
|
-
import { resolveWorkspace } from "./workspace";
|
|
11
|
-
|
|
12
|
-
async function readStdin(): Promise<string> {
|
|
13
|
-
return await new Response(Bun.stdin.stream()).text();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function out(value: unknown): void {
|
|
17
|
-
console.log(JSON.stringify(value, null, 2));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function sessionCommand(argv: string[]): Promise<number> {
|
|
21
|
-
const { positional, flags } = parseArgs(argv);
|
|
22
|
-
const verb = positional[0];
|
|
23
|
-
const client = await DaemonClient.connect({ autostart: true });
|
|
24
|
-
try {
|
|
25
|
-
switch (verb) {
|
|
26
|
-
case "create": {
|
|
27
|
-
const workspace = await resolveWorkspace(flagStr(flags, "cwd"));
|
|
28
|
-
const contentFile = flagStr(flags, "content-file");
|
|
29
|
-
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
30
|
-
const session = await client.sessionCreate(workspace, {
|
|
31
|
-
type: (flagStr(flags, "type") ?? "plan") as ArtifactType,
|
|
32
|
-
content,
|
|
33
|
-
meta: {
|
|
34
|
-
agent: flagStr(flags, "agent"),
|
|
35
|
-
agentSessionId: flagStr(flags, "agent-session-id"),
|
|
36
|
-
planPath: flagStr(flags, "plan-path"),
|
|
37
|
-
title: flagStr(flags, "title"),
|
|
38
|
-
cwd: flagStr(flags, "cwd") ?? process.cwd(),
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
out(session);
|
|
42
|
-
return 0;
|
|
43
|
-
}
|
|
44
|
-
case "get": {
|
|
45
|
-
out(await client.sessionGet(required(positional[1], "session id")));
|
|
46
|
-
return 0;
|
|
47
|
-
}
|
|
48
|
-
case "list": {
|
|
49
|
-
const status = flagStr(flags, "status") as "pending" | "resolved" | undefined;
|
|
50
|
-
out(await client.sessionList(status ? { status } : undefined));
|
|
51
|
-
return 0;
|
|
52
|
-
}
|
|
53
|
-
case "wait": {
|
|
54
|
-
const id = required(positional[1], "session id");
|
|
55
|
-
const timeoutMs = Number(flagStr(flags, "timeout-ms") ?? "60000");
|
|
56
|
-
const session = await client.sessionWait(id, timeoutMs);
|
|
57
|
-
if (session === null) {
|
|
58
|
-
out({ status: "pending" });
|
|
59
|
-
return 0;
|
|
60
|
-
}
|
|
61
|
-
out({
|
|
62
|
-
status: "resolved",
|
|
63
|
-
allow: session.verdict!.kind === "approve",
|
|
64
|
-
verdict: session.verdict!.kind,
|
|
65
|
-
feedback: session.verdict!.feedback,
|
|
66
|
-
});
|
|
67
|
-
return 0;
|
|
68
|
-
}
|
|
69
|
-
case "annotate": {
|
|
70
|
-
const id = required(positional[1], "session id");
|
|
71
|
-
const body = flagStr(flags, "body") ?? "";
|
|
72
|
-
const quote = required(flagStr(flags, "quote"), "--quote");
|
|
73
|
-
out(
|
|
74
|
-
await client.sessionAnnotate(id, {
|
|
75
|
-
id: flagStr(flags, "annotation-id") ?? `a_${Date.now().toString(36)}`,
|
|
76
|
-
kind: flagStr(flags, "kind") ?? "comment",
|
|
77
|
-
anchor: {
|
|
78
|
-
quote,
|
|
79
|
-
prefix: flagStr(flags, "prefix") ?? "",
|
|
80
|
-
suffix: flagStr(flags, "suffix") ?? "",
|
|
81
|
-
},
|
|
82
|
-
body,
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
return 0;
|
|
86
|
-
}
|
|
87
|
-
case "resolve": {
|
|
88
|
-
const id = required(positional[1], "session id");
|
|
89
|
-
const kind = required(flagStr(flags, "verdict"), "--verdict") as VerdictKind;
|
|
90
|
-
out(await client.sessionResolve(id, kind, flagStr(flags, "summary") ?? ""));
|
|
91
|
-
return 0;
|
|
92
|
-
}
|
|
93
|
-
case "submit-revision": {
|
|
94
|
-
const id = required(positional[1], "session id");
|
|
95
|
-
const contentFile = flagStr(flags, "content-file");
|
|
96
|
-
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
97
|
-
out(await client.sessionSubmitRevision(id, content));
|
|
98
|
-
return 0;
|
|
99
|
-
}
|
|
100
|
-
default:
|
|
101
|
-
console.error(
|
|
102
|
-
"usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
|
|
103
|
-
);
|
|
104
|
-
return 2;
|
|
105
|
-
}
|
|
106
|
-
} finally {
|
|
107
|
-
client.close();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function required<T>(v: T | undefined, what: string): T {
|
|
112
|
-
if (v === undefined) throw new Error(`missing ${what}`);
|
|
113
|
-
return v;
|
|
114
|
-
}
|