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
package/src/main.ts
CHANGED
|
@@ -1,105 +1,319 @@
|
|
|
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 primitive-first
|
|
4
|
+
* openers `cueloop plan|reply|diff|review` open the latest pending review of
|
|
5
|
+
* that 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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { basename, resolve } from "node:path";
|
|
12
|
+
import { parseArgs, stringFlag, type ParsedArgs } from "./args";
|
|
13
|
+
import {
|
|
14
|
+
isDiffReview,
|
|
15
|
+
isPlanReview,
|
|
16
|
+
isPrototypeReview,
|
|
17
|
+
isPrReview,
|
|
18
|
+
isReplyReview,
|
|
19
|
+
isSessionId,
|
|
20
|
+
openTargetMessage,
|
|
21
|
+
resolveOpenTarget,
|
|
22
|
+
} from "./open-target";
|
|
23
|
+
import { sessionCommand } from "./session-commands";
|
|
24
|
+
import { CLI_VERSION } from "./version";
|
|
13
25
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
26
|
+
import { workingTreeDiff } from "@cueloop/daemon/working-tree";
|
|
27
|
+
import type { Thread } from "@cueloop/schema";
|
|
14
28
|
import { openReview, resolveWorkspace } from "@cueloop/daemon/review";
|
|
15
29
|
|
|
16
30
|
const argv = process.argv.slice(2);
|
|
17
31
|
const cmd = argv[0];
|
|
18
32
|
|
|
33
|
+
async function daemonCommand(argv: string[]): Promise<number> {
|
|
34
|
+
const { DaemonServer } = await import("@cueloop/daemon");
|
|
35
|
+
// Explicit foreground never idle-exits; --autostart (a re-exec from the client)
|
|
36
|
+
// takes the normal idle-exit, matching the source main.ts entry.
|
|
37
|
+
const envIdle = process.env.CUELOOP_IDLE_EXIT_MS;
|
|
38
|
+
let idleExitMs: number | undefined = 0;
|
|
39
|
+
|
|
40
|
+
if (argv.includes("--autostart")) idleExitMs = envIdle ? Number(envIdle) : undefined;
|
|
41
|
+
const server = new DaemonServer({ idleExitMs });
|
|
42
|
+
const path = server.start();
|
|
43
|
+
|
|
44
|
+
if (path === null) {
|
|
45
|
+
console.error("a cueloop daemon already owns this home - nothing to do");
|
|
46
|
+
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
console.log(`cueloop daemon (foreground) on ${path}`);
|
|
50
|
+
await new Promise(() => {}); // run until signalled
|
|
51
|
+
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function serveEntry(rest: string[]): Promise<number> {
|
|
56
|
+
const { positional, flags } = parseArgs(rest);
|
|
57
|
+
const port = stringFlag(flags, "port");
|
|
58
|
+
const { serveClient } = await import("@cueloop/client");
|
|
59
|
+
const handle = await serveClient({
|
|
60
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
61
|
+
host: stringFlag(flags, "host"),
|
|
62
|
+
sessionId: positional[0],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.log(
|
|
66
|
+
[
|
|
67
|
+
"",
|
|
68
|
+
`observers join with: ssh -p ${handle.port} ${handle.host === "0.0.0.0" || handle.host === "::" ? "<this-host>" : handle.host}`,
|
|
69
|
+
"",
|
|
70
|
+
"no passwords, no keys: anyone who can reach this address can watch.",
|
|
71
|
+
"share the address deliberately (SSH tunnel, tailnet). observers are",
|
|
72
|
+
"read-only; you stay the one writable controller via `cueloop` locally.",
|
|
73
|
+
"ctrl-c stops serving.",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
);
|
|
76
|
+
const stop = () => void handle.close().finally(() => process.exit(0));
|
|
77
|
+
|
|
78
|
+
process.on("SIGINT", stop);
|
|
79
|
+
process.on("SIGTERM", stop);
|
|
80
|
+
await new Promise(() => {}); // serve until signalled
|
|
81
|
+
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function shareEntry(rest: string[]): Promise<number> {
|
|
86
|
+
const { positional, flags } = parseArgs(rest);
|
|
87
|
+
const { shareCommand, sharePullCommand } = await import("./share-command");
|
|
88
|
+
const port = stringFlag(flags, "port");
|
|
89
|
+
const target = {
|
|
90
|
+
host: stringFlag(flags, "host"),
|
|
91
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
95
|
+
|
|
96
|
+
return shareCommand({ ...target, sessionId: positional[0], fork: flags.fork === true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type CommandHandler = (rest: string[]) => number | Promise<number>;
|
|
100
|
+
|
|
101
|
+
interface CommandHandlers {
|
|
102
|
+
[command: string]: CommandHandler;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const commandHandlers: CommandHandlers = {
|
|
106
|
+
session: (rest) => sessionCommand(rest),
|
|
107
|
+
daemon: (rest) => daemonCommand(rest),
|
|
108
|
+
plan: (rest) => planCommand(rest),
|
|
109
|
+
reply: (rest) => replyCommand(rest),
|
|
110
|
+
diff: (rest) => diffCommand(rest),
|
|
111
|
+
prototype: (rest) => prototypeCommand(rest),
|
|
112
|
+
serve: (rest) => serveEntry(rest),
|
|
113
|
+
share: (rest) => shareEntry(rest),
|
|
114
|
+
wake: async (rest) => (await import("./wake-command")).wakeCommand(rest),
|
|
115
|
+
actions: async (rest) => (await import("./actions-command")).actionsCommand(rest),
|
|
116
|
+
refine: async (rest) => (await import("./refine-command")).refineCommand(rest),
|
|
117
|
+
update: async (rest) => (await import("./update-command")).updateCommand(rest),
|
|
118
|
+
review: (rest) => reviewEntry(rest),
|
|
119
|
+
"review-post": async (rest) => (await import("./pr")).reviewPostCommand(rest),
|
|
120
|
+
dev: async () => (await import("./dev-command")).devCommand(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const versionAliases = new Set(["-v", "--version", "version"]);
|
|
124
|
+
const helpAliases = new Set(["-h", "--help", "help"]);
|
|
125
|
+
|
|
19
126
|
async function main(): Promise<number> {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return 0;
|
|
34
|
-
}
|
|
35
|
-
case "diff": {
|
|
36
|
-
const workspace = await resolveWorkspace();
|
|
37
|
-
const diff = await workingTreeDiff();
|
|
38
|
-
if (!diff.trim()) {
|
|
39
|
-
console.error("working tree is clean - nothing to review");
|
|
40
|
-
return 1;
|
|
41
|
-
}
|
|
42
|
-
const client = await DaemonClient.connect({ autostart: true });
|
|
43
|
-
const review = await openReview(client, {
|
|
44
|
-
type: "diff",
|
|
45
|
-
content: diff,
|
|
46
|
-
workspace,
|
|
47
|
-
title: `working tree @ ${workspace.branch}`,
|
|
48
|
-
});
|
|
49
|
-
client.close();
|
|
50
|
-
return runTui(review.id);
|
|
51
|
-
}
|
|
52
|
-
case "serve": {
|
|
53
|
-
const { positional, flags } = parseArgs(argv.slice(1));
|
|
54
|
-
const port = flagStr(flags, "port");
|
|
55
|
-
const { serveClient } = await import("@cueloop/client");
|
|
56
|
-
const handle = await serveClient({
|
|
57
|
-
port: port !== undefined ? Number(port) : undefined,
|
|
58
|
-
host: flagStr(flags, "host"),
|
|
59
|
-
sessionId: positional[0],
|
|
60
|
-
});
|
|
61
|
-
console.log(
|
|
62
|
-
[
|
|
63
|
-
"",
|
|
64
|
-
`observers join with: ssh -p ${handle.port} ${handle.host === "0.0.0.0" || handle.host === "::" ? "<this-host>" : handle.host}`,
|
|
65
|
-
"",
|
|
66
|
-
"no passwords, no keys: anyone who can reach this address can watch.",
|
|
67
|
-
"share the address deliberately (SSH tunnel, tailnet). observers are",
|
|
68
|
-
"read-only; you stay the one writable controller via `cueloop` locally.",
|
|
69
|
-
"ctrl-c stops serving.",
|
|
70
|
-
].join("\n"),
|
|
71
|
-
);
|
|
72
|
-
const stop = () => void handle.close().finally(() => process.exit(0));
|
|
73
|
-
process.on("SIGINT", stop);
|
|
74
|
-
process.on("SIGTERM", stop);
|
|
75
|
-
await new Promise(() => {}); // serve until signalled
|
|
76
|
-
return 0;
|
|
77
|
-
}
|
|
78
|
-
case "review": {
|
|
79
|
-
const { reviewCommand } = await import("./pr");
|
|
80
|
-
return reviewCommand(argv.slice(1));
|
|
81
|
-
}
|
|
82
|
-
case "review-post": {
|
|
83
|
-
const { reviewPostCommand } = await import("./pr");
|
|
84
|
-
return reviewPostCommand(argv.slice(1));
|
|
85
|
-
}
|
|
86
|
-
case "--help":
|
|
87
|
-
case "help":
|
|
88
|
-
printHelp();
|
|
89
|
-
return 0;
|
|
90
|
-
case undefined:
|
|
91
|
-
return runTui();
|
|
92
|
-
default: {
|
|
93
|
-
// `cueloop <session-id>` opens the TUI on that session
|
|
94
|
-
if (cmd.startsWith("ses_")) return runTui(cmd);
|
|
95
|
-
printHelp();
|
|
96
|
-
return 2;
|
|
97
|
-
}
|
|
127
|
+
if (cmd === undefined) return runTui();
|
|
128
|
+
const handler = commandHandlers[cmd];
|
|
129
|
+
|
|
130
|
+
if (handler !== undefined) return handler(argv.slice(1));
|
|
131
|
+
if (versionAliases.has(cmd)) {
|
|
132
|
+
console.log(CLI_VERSION);
|
|
133
|
+
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (helpAliases.has(cmd)) {
|
|
137
|
+
printHelp();
|
|
138
|
+
|
|
139
|
+
return 0;
|
|
98
140
|
}
|
|
141
|
+
if (cmd.startsWith("ses_")) return runTui(cmd);
|
|
142
|
+
printHelp();
|
|
143
|
+
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The id-or-title selector for a primitive-first opener: the bare positional, or a
|
|
149
|
+
* value handed to `--open`/`--latest`. A bare `--latest`/`--open` flag carries
|
|
150
|
+
* no value, so the selector stays undefined and the opener defaults to the
|
|
151
|
+
* latest pending review.
|
|
152
|
+
*/
|
|
153
|
+
function openSelector(parsed: ParsedArgs): string | undefined {
|
|
154
|
+
return (
|
|
155
|
+
parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest")
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve one review of the primitive's scope and open it in the TUI, or print the
|
|
161
|
+
* miss and fail. `emptyMessage` overrides the default no-pending line for the
|
|
162
|
+
* one caller that needs a scope-specific hint (a clean working tree).
|
|
163
|
+
*/
|
|
164
|
+
async function openReviewOfKind(
|
|
165
|
+
match: (session: Thread) => boolean,
|
|
166
|
+
label: string,
|
|
167
|
+
selector: string | undefined,
|
|
168
|
+
emptyMessage?: string,
|
|
169
|
+
): Promise<number> {
|
|
170
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
171
|
+
let sessions: Thread[];
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
sessions = await client.sessionList();
|
|
175
|
+
} finally {
|
|
176
|
+
client.close();
|
|
177
|
+
}
|
|
178
|
+
const target = resolveOpenTarget(sessions, { match, selector });
|
|
179
|
+
|
|
180
|
+
if (target.kind === "session") return runTui(target.sessionId);
|
|
181
|
+
if (target.kind === "no-pending" && emptyMessage !== undefined) {
|
|
182
|
+
console.error(emptyMessage);
|
|
183
|
+
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
console.error(openTargetMessage(label, target));
|
|
187
|
+
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `cueloop plan [id|title]` - open the latest pending plan, or address one. */
|
|
192
|
+
async function planCommand(argv: string[]): Promise<number> {
|
|
193
|
+
return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `cueloop reply [id|title]` - open the latest pending reply review, or address
|
|
198
|
+
* one. A reply artifact is the agent's previous message, submitted for review
|
|
199
|
+
* by the /cueloop:reply skill; the opener is scope-only, like plan.
|
|
200
|
+
*/
|
|
201
|
+
async function replyCommand(argv: string[]): Promise<number> {
|
|
202
|
+
return openReviewOfKind(isReplyReview, "reply", openSelector(parseArgs(argv)));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `cueloop prototype <file.md>` creates a review of a component design doc
|
|
207
|
+
* (API / Composition / Callstack); an `.html` file instead creates the opt-in
|
|
208
|
+
* experimental pixel mockup. A selector or `--open`/`--latest` (or a bare call
|
|
209
|
+
* with no file) opens the latest pending prototype review instead.
|
|
210
|
+
*/
|
|
211
|
+
async function prototypeCommand(argv: string[]): Promise<number> {
|
|
212
|
+
const parsed = parseArgs(argv);
|
|
213
|
+
const selector = openSelector(parsed);
|
|
214
|
+
const wantsOpen = "open" in parsed.flags || "latest" in parsed.flags;
|
|
215
|
+
// a markdown design doc is the default; an .html entry is the opt-in pixel mockup
|
|
216
|
+
const isHtmlPrototype = selector?.endsWith(".html") ?? false;
|
|
217
|
+
const isMarkdownPrototype = selector?.endsWith(".md") || selector?.endsWith(".markdown") || false;
|
|
218
|
+
const looksLikeFile =
|
|
219
|
+
selector !== undefined && !isSessionId(selector) && (isHtmlPrototype || isMarkdownPrototype);
|
|
220
|
+
|
|
221
|
+
if (wantsOpen || !looksLikeFile)
|
|
222
|
+
return openReviewOfKind(isPrototypeReview, "prototype", selector);
|
|
223
|
+
|
|
224
|
+
const path = resolve(selector);
|
|
225
|
+
const content = await Bun.file(path)
|
|
226
|
+
.text()
|
|
227
|
+
.catch(() => undefined);
|
|
228
|
+
|
|
229
|
+
if (content === undefined) {
|
|
230
|
+
console.error(`prototype: cannot read ${path}`);
|
|
231
|
+
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
235
|
+
const review = await openReview(client, {
|
|
236
|
+
type: "prototype",
|
|
237
|
+
content,
|
|
238
|
+
// only an HTML entry carries a prototypePath, the signal for the experimental pixel mode
|
|
239
|
+
prototypePath: isHtmlPrototype ? path : undefined,
|
|
240
|
+
title: basename(path),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
client.close();
|
|
244
|
+
|
|
245
|
+
return runTui(review.id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `cueloop diff` disambiguates create from open by intent:
|
|
250
|
+
* - a selector (`cueloop diff <id|title>`) or an explicit `--open`/`--latest`
|
|
251
|
+
* opens a pending diff review;
|
|
252
|
+
* - otherwise a dirty working tree still creates a review as before;
|
|
253
|
+
* - a clean working tree carries no create input, so it opens the latest
|
|
254
|
+
* pending diff review instead of erroring.
|
|
255
|
+
*/
|
|
256
|
+
async function diffCommand(argv: string[]): Promise<number> {
|
|
257
|
+
const parsed = parseArgs(argv);
|
|
258
|
+
const selector = openSelector(parsed);
|
|
259
|
+
const wantsOpen = selector !== undefined || "open" in parsed.flags || "latest" in parsed.flags;
|
|
260
|
+
|
|
261
|
+
if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector);
|
|
262
|
+
|
|
263
|
+
const workspace = await resolveWorkspace();
|
|
264
|
+
const diff = await workingTreeDiff();
|
|
265
|
+
|
|
266
|
+
if (!diff.patch.trim()) {
|
|
267
|
+
return openReviewOfKind(
|
|
268
|
+
isDiffReview,
|
|
269
|
+
"diff",
|
|
270
|
+
undefined,
|
|
271
|
+
"working tree is clean and no pending diff review - nothing to open",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
275
|
+
const review = await openReview(client, {
|
|
276
|
+
type: "diff",
|
|
277
|
+
content: diff.patch,
|
|
278
|
+
files: diff.files,
|
|
279
|
+
workspace,
|
|
280
|
+
title: `working tree @ ${workspace.branch}`,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
client.close();
|
|
284
|
+
|
|
285
|
+
return runTui(review.id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `cueloop review` disambiguates create from open by intent:
|
|
290
|
+
* - bare `cueloop review`, `--open`/`--latest`, or a `ses_*` selector opens a
|
|
291
|
+
* pending PR review (a diff carrying a `pr` reference);
|
|
292
|
+
* - any other positional is a PR reference and takes the create path in pr.ts.
|
|
293
|
+
*/
|
|
294
|
+
async function reviewEntry(argv: string[]): Promise<number> {
|
|
295
|
+
const parsed = parseArgs(argv);
|
|
296
|
+
const explicitOpen = "open" in parsed.flags || "latest" in parsed.flags;
|
|
297
|
+
const selector = openSelector(parsed);
|
|
298
|
+
const looksLikeSessionId = selector !== undefined && isSessionId(selector);
|
|
299
|
+
const wantsCreate = !explicitOpen && !looksLikeSessionId && selector !== undefined;
|
|
300
|
+
|
|
301
|
+
if (wantsCreate) {
|
|
302
|
+
const { reviewCommand } = await import("./pr");
|
|
303
|
+
|
|
304
|
+
return reviewCommand(argv);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return openReviewOfKind(
|
|
308
|
+
isPrReview,
|
|
309
|
+
"PR",
|
|
310
|
+
explicitOpen || looksLikeSessionId ? selector : undefined,
|
|
311
|
+
);
|
|
99
312
|
}
|
|
100
313
|
|
|
101
314
|
async function runTui(sessionId?: string): Promise<number> {
|
|
102
315
|
const { runClient } = await import("@cueloop/client");
|
|
316
|
+
|
|
103
317
|
return runClient({ sessionId });
|
|
104
318
|
}
|
|
105
319
|
|
|
@@ -108,17 +322,42 @@ function printHelp(): void {
|
|
|
108
322
|
[
|
|
109
323
|
"cueloop - review surface for coding agents",
|
|
110
324
|
"",
|
|
111
|
-
"usage:",
|
|
325
|
+
"usage: cueloop [command] [options]",
|
|
326
|
+
"",
|
|
327
|
+
"common commands:",
|
|
112
328
|
" cueloop open the inbox",
|
|
113
|
-
" cueloop
|
|
114
|
-
" cueloop
|
|
329
|
+
" cueloop plan [id|title] open the latest pending plan review (or one by id/title)",
|
|
330
|
+
" cueloop reply [id|title] open the latest pending reply review (the agent's previous message)",
|
|
331
|
+
" cueloop diff [id|title] review your working tree (untracked files included);",
|
|
332
|
+
" with a clean tree, open the latest pending diff review",
|
|
115
333
|
" cueloop review <pr> review a pull request (--no-tui prints the session)",
|
|
116
|
-
" cueloop
|
|
334
|
+
" cueloop prototype <file.md> review a component design doc (or open the latest by id/title)",
|
|
335
|
+
"",
|
|
336
|
+
"share:",
|
|
117
337
|
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
118
338
|
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
119
339
|
" password-less - share the address deliberately)",
|
|
120
|
-
" cueloop
|
|
340
|
+
" cueloop share [session-id] hand a plan to a teammate: copies one",
|
|
341
|
+
" ssh line (--host cueloop.dev, --port 22)",
|
|
342
|
+
" cueloop share pull [session-id] pull a teammate's annotations back into the plan",
|
|
343
|
+
"",
|
|
344
|
+
"open a specific review:",
|
|
345
|
+
" cueloop <session-id> open one session",
|
|
346
|
+
" cueloop review [id|title] open the latest pending PR review (or one by id/title)",
|
|
347
|
+
" cueloop <plan|diff|review> --latest open the latest pending review of that type",
|
|
348
|
+
"",
|
|
349
|
+
"scripting:",
|
|
350
|
+
" cueloop session <primitive> [flags] script the daemon (create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision)",
|
|
351
|
+
" cueloop actions list list the quick-action vocabulary (for annotate --action)",
|
|
352
|
+
" cueloop refine mine past reviews into a markdown report + writeback proposals",
|
|
353
|
+
" cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
|
|
354
|
+
" cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
|
|
355
|
+
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
121
356
|
" cueloop daemon run the daemon in the foreground",
|
|
357
|
+
" cueloop dev open the TUI on an isolated home seeded with example threads",
|
|
358
|
+
"",
|
|
359
|
+
" cueloop -v, --version print the installed version",
|
|
360
|
+
" cueloop -h, --help print this help",
|
|
122
361
|
].join("\n"),
|
|
123
362
|
);
|
|
124
363
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/** Primitive-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, Thread } from "@cueloop/schema";
|
|
5
|
+
import {
|
|
6
|
+
isDiffReview,
|
|
7
|
+
isPlanReview,
|
|
8
|
+
isPrototypeReview,
|
|
9
|
+
isPrReview,
|
|
10
|
+
isReplyReview,
|
|
11
|
+
isSessionId,
|
|
12
|
+
openTargetMessage,
|
|
13
|
+
resolveOpenTarget,
|
|
14
|
+
} from "./open-target";
|
|
15
|
+
|
|
16
|
+
function session(overrides: {
|
|
17
|
+
id: string;
|
|
18
|
+
type?: ArtifactType;
|
|
19
|
+
title?: string;
|
|
20
|
+
status?: "pending" | "resolved";
|
|
21
|
+
createdAt?: string;
|
|
22
|
+
pr?: string;
|
|
23
|
+
}): Thread {
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: "1",
|
|
26
|
+
id: overrides.id,
|
|
27
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
|
+
artifact: {
|
|
29
|
+
type: overrides.type ?? "plan",
|
|
30
|
+
content: "",
|
|
31
|
+
meta: { title: overrides.title, pr: overrides.pr },
|
|
32
|
+
},
|
|
33
|
+
revisions: [],
|
|
34
|
+
annotations: [],
|
|
35
|
+
verdict:
|
|
36
|
+
overrides.status === "resolved"
|
|
37
|
+
? { kind: "approve", summary: "", feedback: "", resolvedAt: "" }
|
|
38
|
+
: null,
|
|
39
|
+
status: overrides.status ?? "pending",
|
|
40
|
+
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isPlan = isPlanReview;
|
|
45
|
+
|
|
46
|
+
describe("primitive scope predicates are disjoint", () => {
|
|
47
|
+
test("isPlanReview matches only plan artifacts", () => {
|
|
48
|
+
expect(isPlanReview(session({ id: "ses_p", type: "plan" }))).toBe(true);
|
|
49
|
+
expect(isPlanReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("isDiffReview matches a plain diff but NOT a diff carrying meta.pr", () => {
|
|
53
|
+
expect(isDiffReview(session({ id: "ses_d", type: "diff" }))).toBe(true);
|
|
54
|
+
expect(isDiffReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(false);
|
|
55
|
+
expect(isDiffReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("isPrReview matches a diff carrying meta.pr, and only that", () => {
|
|
59
|
+
expect(isPrReview(session({ id: "ses_pr", type: "diff", pr: "42" }))).toBe(true);
|
|
60
|
+
expect(isPrReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
61
|
+
expect(isPrReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("isPrototypeReview matches only prototype artifacts", () => {
|
|
65
|
+
expect(isPrototypeReview(session({ id: "ses_x", type: "prototype" }))).toBe(true);
|
|
66
|
+
expect(isPrototypeReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
67
|
+
expect(isPrototypeReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("isReplyReview matches only reply artifacts, and never a plan", () => {
|
|
71
|
+
expect(isReplyReview(session({ id: "ses_r", type: "reply" }))).toBe(true);
|
|
72
|
+
expect(isReplyReview(session({ id: "ses_p", type: "plan" }))).toBe(false);
|
|
73
|
+
expect(isReplyReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
|
74
|
+
expect(isPlanReview(session({ id: "ses_r", type: "reply" }))).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a PR review resolves under exactly one scope - never both diff and review", () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const prReview = session({ id: "ses_pr", type: "diff", pr: "42" });
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
expect(isDiffReview(prReview)).toBe(false);
|
|
83
|
+
expect(isPrReview(prReview)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("isSessionId", () => {
|
|
88
|
+
test("only the ses_ prefix reads as an id", () => {
|
|
89
|
+
expect(isSessionId("ses_abc")).toBe(true);
|
|
90
|
+
expect(isSessionId("some title")).toBe(false);
|
|
91
|
+
expect(isSessionId("42")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("resolveOpenTarget: latest pending default", () => {
|
|
96
|
+
test("picks the most recent pending session in scope", () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const sessions = [
|
|
99
|
+
session({ id: "ses_old", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
100
|
+
session({ id: "ses_new", createdAt: "2026-03-01T00:00:00.000Z" }),
|
|
101
|
+
session({ id: "ses_mid", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
105
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
106
|
+
kind: "session",
|
|
107
|
+
sessionId: "ses_new",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("ignores resolved sessions and other artifact types", () => {
|
|
112
|
+
// Arrange
|
|
113
|
+
const sessions = [
|
|
114
|
+
session({ id: "ses_resolved", createdAt: "2026-05-01T00:00:00.000Z", status: "resolved" }),
|
|
115
|
+
session({ id: "ses_diff", type: "diff", createdAt: "2026-04-01T00:00:00.000Z" }),
|
|
116
|
+
session({ id: "ses_plan", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
121
|
+
kind: "session",
|
|
122
|
+
sessionId: "ses_plan",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("no pending session in scope reports no-pending", () => {
|
|
127
|
+
// Arrange
|
|
128
|
+
const sessions = [session({ id: "ses_done", status: "resolved" })];
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "no-pending" });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("resolveOpenTarget: by id", () => {
|
|
136
|
+
test("an exact session id in scope opens that session, pending or not", () => {
|
|
137
|
+
// Arrange
|
|
138
|
+
const sessions = [session({ id: "ses_a" }), session({ id: "ses_b", status: "resolved" })];
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
141
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
142
|
+
kind: "session",
|
|
143
|
+
sessionId: "ses_b",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an id outside the scope does not match", () => {
|
|
148
|
+
// Arrange
|
|
149
|
+
const sessions = [session({ id: "ses_diff", type: "diff" })];
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_diff" })).toEqual({
|
|
153
|
+
kind: "no-match",
|
|
154
|
+
selector: "ses_diff",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("resolveOpenTarget: fuzzy by title", () => {
|
|
160
|
+
test("case-insensitive exact title wins over substring rivals", () => {
|
|
161
|
+
// Arrange
|
|
162
|
+
const sessions = [
|
|
163
|
+
session({ id: "ses_exact", title: "Rollout" }),
|
|
164
|
+
session({ id: "ses_sub", title: "Rollout plan v2" }),
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
// Assert
|
|
168
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
169
|
+
kind: "session",
|
|
170
|
+
sessionId: "ses_exact",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a unique case-insensitive substring match wins", () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const sessions = [
|
|
177
|
+
session({ id: "ses_auth", title: "Auth rewrite" }),
|
|
178
|
+
session({ id: "ses_cache", title: "Cache warmup" }),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "AUTH" })).toEqual({
|
|
183
|
+
kind: "session",
|
|
184
|
+
sessionId: "ses_auth",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("several substring matches are ambiguous and list the candidate titles", () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const sessions = [
|
|
191
|
+
session({ id: "ses_1", title: "Payments rollout" }),
|
|
192
|
+
session({ id: "ses_2", title: "Rollout of search" }),
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
197
|
+
kind: "ambiguous",
|
|
198
|
+
selector: "rollout",
|
|
199
|
+
titles: ["Payments rollout", "Rollout of search"],
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("no title match reports no-match", () => {
|
|
204
|
+
// Arrange
|
|
205
|
+
const sessions = [session({ id: "ses_1", title: "Auth rewrite" })];
|
|
206
|
+
|
|
207
|
+
// Assert
|
|
208
|
+
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "billing" })).toEqual({
|
|
209
|
+
kind: "no-match",
|
|
210
|
+
selector: "billing",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("openTargetMessage", () => {
|
|
216
|
+
test("no-pending mirrors the clean-tree tone", () => {
|
|
217
|
+
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
|
|
218
|
+
"no pending plan review - nothing to open",
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("no-match names the selector", () => {
|
|
223
|
+
expect(openTargetMessage("diff", { kind: "no-match", selector: "billing" })).toBe(
|
|
224
|
+
'no diff review matches "billing" - nothing to open',
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("ambiguous lists the candidate titles", () => {
|
|
229
|
+
// Act
|
|
230
|
+
const message = openTargetMessage("plan", {
|
|
231
|
+
kind: "ambiguous",
|
|
232
|
+
selector: "rollout",
|
|
233
|
+
titles: ["A", "B"],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
237
|
+
expect(message).toContain('"rollout" matches several plan reviews');
|
|
238
|
+
expect(message).toContain(" - A");
|
|
239
|
+
expect(message).toContain(" - B");
|
|
240
|
+
});
|
|
241
|
+
});
|