cueloop 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +6 -5
- package/src/actions-command.ts +2 -2
- package/src/args.ts +4 -2
- package/src/main.ts +10 -6
- package/src/open-target.test.ts +2 -2
- package/src/open-target.ts +6 -6
- package/src/session-commands.ts +263 -25
- package/src/share-command.test.ts +105 -12
- package/src/share-command.ts +11 -9
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.65",
|
|
4
4
|
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
"build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@cueloop/adapters": "0.1.0-alpha.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.65",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.65",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.65",
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.65",
|
|
37
|
+
"valibot": "^1.4.2"
|
|
37
38
|
},
|
|
38
39
|
"engines": {
|
|
39
40
|
"bun": ">=1.3.0"
|
package/src/actions-command.ts
CHANGED
|
@@ -12,9 +12,9 @@ import { parseArgs, stringFlag } from "./args";
|
|
|
12
12
|
|
|
13
13
|
export async function actionsCommand(argv: string[]): Promise<number> {
|
|
14
14
|
const { positional, flags } = parseArgs(argv);
|
|
15
|
-
const
|
|
15
|
+
const primitive = positional[0] ?? "list";
|
|
16
16
|
|
|
17
|
-
if (
|
|
17
|
+
if (primitive !== "list") {
|
|
18
18
|
console.error("usage: cueloop actions list [--session <id>]");
|
|
19
19
|
|
|
20
20
|
return 2;
|
package/src/args.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Tiny flag parser: --key value, --key=value, boolean --flag, positionals. */
|
|
2
2
|
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
3
5
|
export interface ParsedArgs {
|
|
4
6
|
positional: string[];
|
|
5
7
|
flags: Record<string, string | boolean>;
|
|
@@ -40,7 +42,7 @@ export function stringFlag(
|
|
|
40
42
|
flags: Record<string, string | boolean>,
|
|
41
43
|
key: string,
|
|
42
44
|
): string | undefined {
|
|
43
|
-
const
|
|
45
|
+
const result = v.safeParse(v.string(), flags[key]);
|
|
44
46
|
|
|
45
|
-
return
|
|
47
|
+
return result.success ? result.output : undefined;
|
|
46
48
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* cueloop entry points: `cueloop` opens the TUI on the inbox, the
|
|
3
|
+
* cueloop entry points: `cueloop` opens the TUI on the inbox, the primitive-first
|
|
4
4
|
* openers `cueloop plan|reply|diff|review` open the latest pending review of
|
|
5
5
|
* that type (or address one by id/title), `cueloop diff`/`cueloop review <pr>` also
|
|
6
6
|
* keep their create paths, `cueloop session *` mirrors the daemon API for
|
|
@@ -93,12 +93,16 @@ async function shareEntry(rest: string[]): Promise<number> {
|
|
|
93
93
|
|
|
94
94
|
if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
95
95
|
|
|
96
|
-
return shareCommand({ ...target, sessionId: positional[0] });
|
|
96
|
+
return shareCommand({ ...target, sessionId: positional[0], fork: flags.fork === true });
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
type CommandHandler = (rest: string[]) => number | Promise<number>;
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
interface CommandHandlers {
|
|
102
|
+
[command: string]: CommandHandler;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const commandHandlers: CommandHandlers = {
|
|
102
106
|
session: (rest) => sessionCommand(rest),
|
|
103
107
|
daemon: (rest) => daemonCommand(rest),
|
|
104
108
|
plan: (rest) => planCommand(rest),
|
|
@@ -139,7 +143,7 @@ async function main(): Promise<number> {
|
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
142
|
-
* The id-or-title selector for a
|
|
146
|
+
* The id-or-title selector for a primitive-first opener: the bare positional, or a
|
|
143
147
|
* value handed to `--open`/`--latest`. A bare `--latest`/`--open` flag carries
|
|
144
148
|
* no value, so the selector stays undefined and the opener defaults to the
|
|
145
149
|
* latest pending review.
|
|
@@ -151,7 +155,7 @@ function openSelector(parsed: ParsedArgs): string | undefined {
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
/**
|
|
154
|
-
* Resolve one review of the
|
|
158
|
+
* Resolve one review of the primitive's scope and open it in the TUI, or print the
|
|
155
159
|
* miss and fail. `emptyMessage` overrides the default no-pending line for the
|
|
156
160
|
* one caller that needs a scope-specific hint (a clean working tree).
|
|
157
161
|
*/
|
|
@@ -336,7 +340,7 @@ function printHelp(): void {
|
|
|
336
340
|
" cueloop <plan|diff|review> --latest open the latest pending review of that type",
|
|
337
341
|
"",
|
|
338
342
|
"scripting:",
|
|
339
|
-
" cueloop session <
|
|
343
|
+
" 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)",
|
|
340
344
|
" cueloop actions list list the quick-action vocabulary (for annotate --action)",
|
|
341
345
|
" cueloop refine mine past reviews into a markdown report + writeback proposals",
|
|
342
346
|
" cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
|
package/src/open-target.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
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
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
4
|
import type { ArtifactType, ReviewSession } from "@cueloop/schema";
|
|
@@ -43,7 +43,7 @@ function session(overrides: {
|
|
|
43
43
|
|
|
44
44
|
const isPlan = isPlanReview;
|
|
45
45
|
|
|
46
|
-
describe("
|
|
46
|
+
describe("primitive scope predicates are disjoint", () => {
|
|
47
47
|
test("isPlanReview matches only plan artifacts", () => {
|
|
48
48
|
expect(isPlanReview(session({ id: "ses_p", type: "plan" }))).toBe(true);
|
|
49
49
|
expect(isPlanReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
|
package/src/open-target.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Primitive-first review-open resolution: turn a primitive (plan/diff/review) plus an
|
|
3
3
|
* optional id-or-title selector into a single session to open, or a clear
|
|
4
4
|
* miss. Pure over a session list so it unit-tests without a daemon or a TUI -
|
|
5
5
|
* main.ts fetches the sessions and prints the miss messages.
|
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
* - otherwise the selector matches session titles, case-insensitive: an
|
|
13
13
|
* exact title wins, else a unique substring match wins, else the substring
|
|
14
14
|
* candidates are reported as ambiguous.
|
|
15
|
-
* Every lookup is scoped by the caller's `match` predicate, so a
|
|
15
|
+
* Every lookup is scoped by the caller's `match` predicate, so a primitive only
|
|
16
16
|
* ever resolves to its own artifact kind.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { ReviewSession } from "@cueloop/schema";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* The
|
|
23
|
-
* under two
|
|
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
24
|
* reference (openReview maps a top-level `pr` into `meta.pr`), so the plain
|
|
25
25
|
* diff scope must exclude those - otherwise `cueloop diff` and `cueloop
|
|
26
26
|
* review` would both open the same pending PR review.
|
|
@@ -47,7 +47,7 @@ export function isSessionId(value: string): boolean {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface OpenTargetQuery {
|
|
50
|
-
/** Only sessions this predicate accepts are eligible - the
|
|
50
|
+
/** Only sessions this predicate accepts are eligible - the primitive's artifact scope. */
|
|
51
51
|
match: (session: ReviewSession) => boolean;
|
|
52
52
|
/** Positional id-or-title selector; absent means "latest pending". */
|
|
53
53
|
selector?: string;
|
|
@@ -107,7 +107,7 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
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
|
|
110
|
+
* to review" tone. `label` is the human word for the primitive's scope ("plan",
|
|
111
111
|
* "diff", "PR"). The "session" outcome has no message - the caller opens it.
|
|
112
112
|
*/
|
|
113
113
|
export function openTargetMessage(
|
package/src/session-commands.ts
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
* JSON on stdout; exit code 0 unless the daemon returned an error.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ARTIFACT_TYPES,
|
|
9
|
+
isArtifactType,
|
|
10
|
+
newAnnotationId,
|
|
11
|
+
type Anchor,
|
|
12
|
+
type Annotation,
|
|
13
|
+
} from "@cueloop/schema";
|
|
14
|
+
import * as v from "valibot";
|
|
8
15
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
16
|
import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
|
|
10
|
-
import { openReview, verdictResponse
|
|
17
|
+
import { openReview, verdictResponse } from "@cueloop/daemon/review";
|
|
11
18
|
import { loadConfig, quickActionBody, resolveQuickAction } from "@cueloop/client/config";
|
|
12
19
|
import { parseArgs, stringFlag } from "./args";
|
|
13
20
|
|
|
@@ -15,10 +22,14 @@ async function readStdin(): Promise<string> {
|
|
|
15
22
|
return await new Response(Bun.stdin.stream()).text();
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
function out(value:
|
|
25
|
+
function out(value: Parameters<typeof JSON.stringify>[0]): void {
|
|
19
26
|
console.log(JSON.stringify(value, null, 2));
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
const ReviewNotesSchema = v.array(v.object({ path: v.string(), body: v.string() }));
|
|
30
|
+
const SessionStatusSchema = v.picklist(["pending", "resolved"]);
|
|
31
|
+
const VerdictKindSchema = v.picklist(["comment", "approve", "request_changes"]);
|
|
32
|
+
|
|
22
33
|
type SessionFlags = Record<string, string | boolean>;
|
|
23
34
|
type SessionContext = { client: DaemonClient; positional: string[]; flags: SessionFlags };
|
|
24
35
|
type SessionVerbHandler = (context: SessionContext) => Promise<number>;
|
|
@@ -35,7 +46,7 @@ async function sessionCreate({ client, flags }: SessionContext): Promise<number>
|
|
|
35
46
|
// per-file agent notes for diff sessions: a JSON array of { path, body }
|
|
36
47
|
const notesFile = stringFlag(flags, "notes-file");
|
|
37
48
|
const notes = notesFile
|
|
38
|
-
? (JSON.parse(await Bun.file(notesFile).text())
|
|
49
|
+
? v.parse(ReviewNotesSchema, JSON.parse(await Bun.file(notesFile).text()))
|
|
39
50
|
: undefined;
|
|
40
51
|
const review = await openReview(client, {
|
|
41
52
|
type,
|
|
@@ -63,7 +74,8 @@ async function sessionGetCommand({ client, positional }: SessionContext): Promis
|
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
async function sessionListCommand({ client, flags }: SessionContext): Promise<number> {
|
|
66
|
-
const
|
|
77
|
+
const rawStatus = stringFlag(flags, "status");
|
|
78
|
+
const status = rawStatus ? v.parse(SessionStatusSchema, rawStatus) : undefined;
|
|
67
79
|
|
|
68
80
|
out(await client.sessionList(status ? { status } : undefined));
|
|
69
81
|
|
|
@@ -87,44 +99,249 @@ async function sessionWaitCommand({ client, positional, flags }: SessionContext)
|
|
|
87
99
|
return 0;
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The anchor an annotate call means: a reply borrows its root comment's
|
|
104
|
+
* anchor, a prototype comment names an element by selector, and anything
|
|
105
|
+
* else quotes the passage.
|
|
106
|
+
*/
|
|
107
|
+
async function annotateAnchor(
|
|
108
|
+
flags: Record<string, string | boolean>,
|
|
109
|
+
client: DaemonClient,
|
|
110
|
+
id: string,
|
|
111
|
+
): Promise<{ anchor: Anchor; replyTo?: string }> {
|
|
112
|
+
const replyTo = stringFlag(flags, "reply-to");
|
|
113
|
+
|
|
114
|
+
if (replyTo !== undefined) {
|
|
115
|
+
const session = await client.sessionGet(id);
|
|
116
|
+
const root = session.annotations.find((annotation) => annotation.id === replyTo);
|
|
117
|
+
|
|
118
|
+
if (!root) throw new Error(`no comment ${replyTo} to reply to`);
|
|
119
|
+
|
|
120
|
+
// a reply to a reply still hangs off the discussion's root comment
|
|
121
|
+
return { anchor: root.anchor, replyTo: root.replyTo ?? root.id };
|
|
122
|
+
}
|
|
123
|
+
const selector = stringFlag(flags, "selector");
|
|
124
|
+
const quote =
|
|
125
|
+
selector === undefined
|
|
126
|
+
? required(stringFlag(flags, "quote"), "--quote")
|
|
127
|
+
: (stringFlag(flags, "quote") ?? "");
|
|
128
|
+
const anchor: Anchor = {
|
|
129
|
+
quote,
|
|
130
|
+
prefix: stringFlag(flags, "prefix") ?? "",
|
|
131
|
+
suffix: stringFlag(flags, "suffix") ?? "",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (selector !== undefined) anchor.selector = selector;
|
|
135
|
+
|
|
136
|
+
return { anchor };
|
|
137
|
+
}
|
|
138
|
+
|
|
90
139
|
async function sessionAnnotateCommand({
|
|
91
140
|
client,
|
|
92
141
|
positional,
|
|
93
142
|
flags,
|
|
94
143
|
}: SessionContext): Promise<number> {
|
|
95
144
|
const id = required(positional[1], "session id");
|
|
96
|
-
const quote = required(stringFlag(flags, "quote"), "--quote");
|
|
97
145
|
const author = stringFlag(flags, "author");
|
|
98
146
|
const body = await annotateBody(flags, client, id);
|
|
147
|
+
const { anchor, replyTo } = await annotateAnchor(flags, client, id);
|
|
148
|
+
const annotation: Omit<Annotation, "createdAt"> = {
|
|
149
|
+
id: stringFlag(flags, "annotation-id") ?? newAnnotationId(),
|
|
150
|
+
kind: stringFlag(flags, "kind") ?? "comment",
|
|
151
|
+
anchor,
|
|
152
|
+
body,
|
|
153
|
+
author,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (replyTo !== undefined) annotation.replyTo = replyTo;
|
|
157
|
+
out(await client.sessionAnnotate(id, annotation, stringFlag(flags, "author-name")));
|
|
158
|
+
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
|
|
163
|
+
async function sessionRemoveCommand({ client, positional }: SessionContext): Promise<number> {
|
|
164
|
+
const id = required(positional[1], "session id");
|
|
165
|
+
const annotationId = required(positional[2], "annotation id");
|
|
166
|
+
|
|
167
|
+
out(await client.sessionRemoveAnnotation(id, annotationId));
|
|
168
|
+
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A 0-based block or line index as the command line carries it. */
|
|
173
|
+
const BlockIndexSchema = v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0));
|
|
174
|
+
|
|
175
|
+
/** The reject decisions of a diff review as `--rejections` carries them. */
|
|
176
|
+
const RejectionsSchema = v.array(
|
|
177
|
+
v.object({
|
|
178
|
+
path: v.pipe(v.string(), v.minLength(1)),
|
|
179
|
+
hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
|
180
|
+
changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
/** Cut the given block of the working copy (a 0-based index into its blocks). */
|
|
185
|
+
async function sessionCutCommand({ client, positional }: SessionContext): Promise<number> {
|
|
186
|
+
const id = required(positional[1], "session id");
|
|
187
|
+
const blockIndex = v.parse(BlockIndexSchema, required(positional[2], "block index"));
|
|
188
|
+
|
|
189
|
+
out(await client.sessionCutBlock(id, blockIndex));
|
|
190
|
+
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Re-insert a cut block of the submitted revision, before --line (default: the end). */
|
|
195
|
+
async function sessionRestoreCommand({
|
|
196
|
+
client,
|
|
197
|
+
positional,
|
|
198
|
+
flags,
|
|
199
|
+
}: SessionContext): Promise<number> {
|
|
200
|
+
const id = required(positional[1], "session id");
|
|
201
|
+
const baseBlockIndex = v.parse(BlockIndexSchema, required(positional[2], "block index"));
|
|
202
|
+
const line = stringFlag(flags, "line");
|
|
99
203
|
|
|
100
204
|
out(
|
|
101
|
-
await client.
|
|
205
|
+
await client.sessionRestoreBlock(
|
|
102
206
|
id,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
kind: stringFlag(flags, "kind") ?? "comment",
|
|
106
|
-
anchor: {
|
|
107
|
-
quote,
|
|
108
|
-
prefix: stringFlag(flags, "prefix") ?? "",
|
|
109
|
-
suffix: stringFlag(flags, "suffix") ?? "",
|
|
110
|
-
},
|
|
111
|
-
body,
|
|
112
|
-
...(author ? { author } : {}),
|
|
113
|
-
},
|
|
114
|
-
stringFlag(flags, "author-name"),
|
|
207
|
+
baseBlockIndex,
|
|
208
|
+
line === undefined ? undefined : v.parse(BlockIndexSchema, line),
|
|
115
209
|
),
|
|
116
210
|
);
|
|
117
211
|
|
|
118
212
|
return 0;
|
|
119
213
|
}
|
|
120
214
|
|
|
215
|
+
/** Replace a diff review's reject decisions with the JSON list given. */
|
|
216
|
+
async function sessionCurateCommand({
|
|
217
|
+
client,
|
|
218
|
+
positional,
|
|
219
|
+
flags,
|
|
220
|
+
}: SessionContext): Promise<number> {
|
|
221
|
+
const id = required(positional[1], "session id");
|
|
222
|
+
const rejections = v.parse(
|
|
223
|
+
RejectionsSchema,
|
|
224
|
+
JSON.parse(required(stringFlag(flags, "rejections"), "--rejections")),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
out(await client.sessionCurate(id, rejections));
|
|
228
|
+
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Move a branch's tip back to an entry on its path; --summary records what was left behind, --branch stands on that branch first. */
|
|
233
|
+
async function sessionNavigateCommand({
|
|
234
|
+
client,
|
|
235
|
+
positional,
|
|
236
|
+
flags,
|
|
237
|
+
}: SessionContext): Promise<number> {
|
|
238
|
+
const id = required(positional[1], "session id");
|
|
239
|
+
const entryId = required(positional[2], "entry id");
|
|
240
|
+
|
|
241
|
+
out(
|
|
242
|
+
await client.sessionNavigate(
|
|
243
|
+
id,
|
|
244
|
+
entryId,
|
|
245
|
+
stringFlag(flags, "summary"),
|
|
246
|
+
stringFlag(flags, "branch"),
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Start a branch at the current tip and switch to it. */
|
|
254
|
+
async function sessionBranchCommand({ client, positional }: SessionContext): Promise<number> {
|
|
255
|
+
const id = required(positional[1], "session id");
|
|
256
|
+
|
|
257
|
+
out(await client.sessionBranch(id, required(positional[2], "branch name")));
|
|
258
|
+
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Show another branch's path. */
|
|
263
|
+
async function sessionSwitchCommand({ client, positional }: SessionContext): Promise<number> {
|
|
264
|
+
const id = required(positional[1], "session id");
|
|
265
|
+
|
|
266
|
+
out(await client.sessionSwitch(id, required(positional[2], "branch name")));
|
|
267
|
+
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Name the current tip as a checkpoint. */
|
|
272
|
+
async function sessionLabelCommand({ client, positional }: SessionContext): Promise<number> {
|
|
273
|
+
const id = required(positional[1], "session id");
|
|
274
|
+
|
|
275
|
+
out(await client.sessionLabel(id, required(positional[2], "label")));
|
|
276
|
+
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Copy the current path into a new session and print the fork. */
|
|
281
|
+
async function sessionForkCommand({ client, positional }: SessionContext): Promise<number> {
|
|
282
|
+
out(await client.sessionFork(required(positional[1], "session id")));
|
|
283
|
+
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Mark files of a diff review as viewed - the guided walk's state, from a script. */
|
|
288
|
+
async function sessionSetViewedCommand({ client, positional }: SessionContext): Promise<number> {
|
|
289
|
+
const id = required(positional[1], "session id");
|
|
290
|
+
|
|
291
|
+
out(await client.sessionSetViewed(id, positional.slice(2)));
|
|
292
|
+
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Register the display name of a participant: how a collaborator or an agent names itself. */
|
|
297
|
+
async function sessionNameSelfCommand({
|
|
298
|
+
client,
|
|
299
|
+
positional,
|
|
300
|
+
flags,
|
|
301
|
+
}: SessionContext): Promise<number> {
|
|
302
|
+
const id = required(positional[1], "session id");
|
|
303
|
+
const name = required(positional[2], "name");
|
|
304
|
+
const author = required(stringFlag(flags, "author"), "--author");
|
|
305
|
+
|
|
306
|
+
out(await client.sessionSetParticipantName(id, author, name));
|
|
307
|
+
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Follow a session live: one JSON line per event, with the entry the change
|
|
313
|
+
* appended, until the process ends. `--once` prints the first event and exits.
|
|
314
|
+
* The subscription is in place before the session is read, so nothing that
|
|
315
|
+
* lands after the read can slip between the two.
|
|
316
|
+
*/
|
|
317
|
+
async function sessionEventsCommand({
|
|
318
|
+
client,
|
|
319
|
+
positional,
|
|
320
|
+
flags,
|
|
321
|
+
}: SessionContext): Promise<number> {
|
|
322
|
+
const id = required(positional[1], "session id");
|
|
323
|
+
const once = flags.once === true;
|
|
324
|
+
const stream = new Promise<number>((resolve) => {
|
|
325
|
+
client.onEvent((event) => {
|
|
326
|
+
if (event.sessionId !== id) return;
|
|
327
|
+
out(event);
|
|
328
|
+
if (once) resolve(0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await client.subscribe();
|
|
333
|
+
await client.sessionGet(id);
|
|
334
|
+
|
|
335
|
+
return stream;
|
|
336
|
+
}
|
|
337
|
+
|
|
121
338
|
async function sessionResolveCommand({
|
|
122
339
|
client,
|
|
123
340
|
positional,
|
|
124
341
|
flags,
|
|
125
342
|
}: SessionContext): Promise<number> {
|
|
126
343
|
const id = required(positional[1], "session id");
|
|
127
|
-
const kind = required(stringFlag(flags, "verdict"), "--verdict")
|
|
344
|
+
const kind = v.parse(VerdictKindSchema, required(stringFlag(flags, "verdict"), "--verdict"));
|
|
128
345
|
|
|
129
346
|
out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? ""));
|
|
130
347
|
|
|
@@ -150,30 +367,51 @@ async function sessionSubmitRevisionCommand({
|
|
|
150
367
|
return 0;
|
|
151
368
|
}
|
|
152
369
|
|
|
153
|
-
|
|
370
|
+
interface SessionVerbHandlers {
|
|
371
|
+
[primitive: string]: SessionVerbHandler;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const sessionVerbHandlers: SessionVerbHandlers = {
|
|
154
375
|
create: sessionCreate,
|
|
155
376
|
get: sessionGetCommand,
|
|
156
377
|
list: sessionListCommand,
|
|
157
378
|
wait: sessionWaitCommand,
|
|
158
379
|
annotate: sessionAnnotateCommand,
|
|
380
|
+
remove: sessionRemoveCommand,
|
|
381
|
+
cut: sessionCutCommand,
|
|
382
|
+
restore: sessionRestoreCommand,
|
|
383
|
+
curate: sessionCurateCommand,
|
|
384
|
+
"set-viewed": sessionSetViewedCommand,
|
|
385
|
+
navigate: sessionNavigateCommand,
|
|
386
|
+
branch: sessionBranchCommand,
|
|
387
|
+
switch: sessionSwitchCommand,
|
|
388
|
+
label: sessionLabelCommand,
|
|
389
|
+
fork: sessionForkCommand,
|
|
390
|
+
"name-self": sessionNameSelfCommand,
|
|
391
|
+
events: sessionEventsCommand,
|
|
159
392
|
resolve: sessionResolveCommand,
|
|
160
393
|
"submit-revision": sessionSubmitRevisionCommand,
|
|
161
394
|
};
|
|
162
395
|
|
|
163
396
|
export async function sessionCommand(argv: string[]): Promise<number> {
|
|
164
397
|
const { positional, flags } = parseArgs(argv);
|
|
165
|
-
const
|
|
398
|
+
const primitive = positional[0];
|
|
166
399
|
// --role agent (or collaborator) caps this connection to read + annotate; a
|
|
167
400
|
// review-side agent passes it so the daemon rejects any escalation attempt.
|
|
401
|
+
// and --author binds the identity its comments, removals, and name carry
|
|
168
402
|
const role = stringFlag(flags, "role") === "agent" ? "agent" : undefined;
|
|
169
|
-
const client = await DaemonClient.connect({
|
|
403
|
+
const client = await DaemonClient.connect({
|
|
404
|
+
autostart: true,
|
|
405
|
+
role,
|
|
406
|
+
author: role === undefined ? undefined : stringFlag(flags, "author"),
|
|
407
|
+
});
|
|
170
408
|
|
|
171
409
|
try {
|
|
172
|
-
const handler =
|
|
410
|
+
const handler = primitive !== undefined ? sessionVerbHandlers[primitive] : undefined;
|
|
173
411
|
|
|
174
412
|
if (handler === undefined) {
|
|
175
413
|
console.error(
|
|
176
|
-
"usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
|
|
414
|
+
"usage: cueloop session <create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision> [flags]",
|
|
177
415
|
);
|
|
178
416
|
|
|
179
417
|
return 2;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
appendEntry,
|
|
4
|
+
historyFromLinear,
|
|
5
|
+
SCHEMA_VERSION,
|
|
6
|
+
type Annotation,
|
|
7
|
+
type ReviewSession,
|
|
8
|
+
} from "@cueloop/schema";
|
|
3
9
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
10
|
import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
|
|
5
11
|
|
|
@@ -30,11 +36,34 @@ function annotationFixture(id: string, author?: string): Annotation {
|
|
|
30
36
|
return author ? { ...base, author } : base;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
const unimplemented = (member: string) => () =>
|
|
40
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
41
|
+
|
|
42
|
+
/** A SessionClient that answers get/list from a fixed list and records the share/merge primitives. */
|
|
34
43
|
function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
35
44
|
return {
|
|
45
|
+
onEvent: () => () => {},
|
|
46
|
+
subscribe: async () => {},
|
|
36
47
|
sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
|
|
37
48
|
sessionList: async () => sessions,
|
|
49
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
50
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
51
|
+
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
52
|
+
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
53
|
+
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
54
|
+
sessionCurate: unimplemented("sessionCurate"),
|
|
55
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
56
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
57
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
58
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
59
|
+
sessionFork: mock(async (id: string) => {
|
|
60
|
+
const fork = { ...sessionFixture(`${id}_fork`), parentSessionId: id };
|
|
61
|
+
|
|
62
|
+
sessions.push(fork);
|
|
63
|
+
|
|
64
|
+
return fork;
|
|
65
|
+
}),
|
|
66
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
38
67
|
sessionSetShareId: mock(async (id: string, shareId: string) => {
|
|
39
68
|
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
40
69
|
|
|
@@ -42,16 +71,30 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
|
42
71
|
|
|
43
72
|
return session;
|
|
44
73
|
}),
|
|
45
|
-
sessionMergeShared: mock(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
sessionMergeShared: mock(
|
|
75
|
+
async (
|
|
76
|
+
id: string,
|
|
77
|
+
incoming: { annotations: Annotation[]; removals?: Array<{ annotationId: string }> },
|
|
78
|
+
) => {
|
|
79
|
+
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
80
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
81
|
+
|
|
82
|
+
for (const annotation of incoming.annotations)
|
|
83
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
84
|
+
const shelved = new Set((incoming.removals ?? []).map((removal) => removal.annotationId));
|
|
85
|
+
|
|
86
|
+
session.annotations = session.annotations.filter(
|
|
87
|
+
(annotation) => !shelved.has(annotation.id),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return session;
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
94
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
95
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
96
|
+
close: () => {},
|
|
97
|
+
};
|
|
55
98
|
}
|
|
56
99
|
|
|
57
100
|
function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
|
|
@@ -143,6 +186,28 @@ describe(shareSession, () => {
|
|
|
143
186
|
// Assert
|
|
144
187
|
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
145
188
|
});
|
|
189
|
+
|
|
190
|
+
test("--fork shares a fork of the session and leaves the original unshared", async () => {
|
|
191
|
+
// Arrange
|
|
192
|
+
const session = sessionFixture("ses_1");
|
|
193
|
+
const client = fakeClient([session]);
|
|
194
|
+
const deps = depsSpy();
|
|
195
|
+
|
|
196
|
+
// Act
|
|
197
|
+
const code = await shareSession(client, { sessionId: "ses_1", fork: true }, deps);
|
|
198
|
+
|
|
199
|
+
// Assert
|
|
200
|
+
expect(code).toBe(0);
|
|
201
|
+
expect(client.sessionFork).toHaveBeenCalledWith("ses_1");
|
|
202
|
+
expect(deps.publish).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(deps.publish).toHaveBeenCalledWith(
|
|
204
|
+
expect.objectContaining({ id: "ses_1_fork", parentSessionId: "ses_1" }),
|
|
205
|
+
expect.anything(),
|
|
206
|
+
);
|
|
207
|
+
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1_fork", "p_abc123xy");
|
|
208
|
+
expect(session.shareId).toBeUndefined();
|
|
209
|
+
expect(deps.lines[0]).toBe("forked ses_1 as ses_1_fork");
|
|
210
|
+
});
|
|
146
211
|
});
|
|
147
212
|
|
|
148
213
|
function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
|
|
@@ -208,6 +273,34 @@ describe(pullSession, () => {
|
|
|
208
273
|
expect(deps.lines).toEqual(["no new annotations"]);
|
|
209
274
|
});
|
|
210
275
|
|
|
276
|
+
test("counts only the new notes when a pull also carries a removal", async () => {
|
|
277
|
+
// Arrange: the owner holds one collaborator note; the share brings a new note and removes the old one
|
|
278
|
+
const local = sessionFixture("ses_1", {
|
|
279
|
+
shareId: "p_abc123xy",
|
|
280
|
+
annotations: [annotationFixture("gone", "SHA256:mate")],
|
|
281
|
+
});
|
|
282
|
+
const remote = sessionFixture("ses_1", {
|
|
283
|
+
annotations: [
|
|
284
|
+
annotationFixture("gone", "SHA256:mate"),
|
|
285
|
+
annotationFixture("fresh", "SHA256:mate"),
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
remote.history = appendEntry(historyFromLinear(remote), {
|
|
290
|
+
id: "e_rm",
|
|
291
|
+
type: "comment-removed",
|
|
292
|
+
annotationId: "gone",
|
|
293
|
+
createdAt: "2026-02-02T00:00:00.000Z",
|
|
294
|
+
}).history;
|
|
295
|
+
const deps = pullDepsSpy(remote);
|
|
296
|
+
|
|
297
|
+
// Act
|
|
298
|
+
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
299
|
+
|
|
300
|
+
// Assert: one note is genuinely new, so a plain length difference (0) would be wrong
|
|
301
|
+
expect(deps.lines).toEqual(["pulled 1 new annotation"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
211
304
|
test("refuses to pull a plan that was never shared", async () => {
|
|
212
305
|
// Arrange
|
|
213
306
|
const deps = pullDepsSpy(sessionFixture("ses_1"));
|
package/src/share-command.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
mergeFromShare,
|
|
12
12
|
publishShare,
|
|
13
13
|
pullShare,
|
|
14
14
|
shareIdFromLine,
|
|
@@ -19,6 +19,8 @@ import type { ReviewSession } from "@cueloop/schema";
|
|
|
19
19
|
|
|
20
20
|
export interface ShareParams {
|
|
21
21
|
sessionId?: string;
|
|
22
|
+
/** Fork the session first and share the fork: one artifact to two people, with separate discussions. */
|
|
23
|
+
fork?: boolean;
|
|
22
24
|
host?: string;
|
|
23
25
|
port?: number;
|
|
24
26
|
home?: string;
|
|
@@ -71,17 +73,19 @@ export async function shareSession(
|
|
|
71
73
|
params: ShareParams,
|
|
72
74
|
deps: ShareDeps,
|
|
73
75
|
): Promise<number> {
|
|
74
|
-
const
|
|
76
|
+
const picked = await pickSession(client, params.sessionId);
|
|
75
77
|
|
|
76
|
-
if (!
|
|
78
|
+
if (!picked) {
|
|
77
79
|
deps.out("no plan to share - open a review first");
|
|
78
80
|
|
|
79
81
|
return 1;
|
|
80
82
|
}
|
|
83
|
+
const session = params.fork ? await client.sessionFork(picked.id) : picked;
|
|
81
84
|
const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
|
|
82
85
|
const shareId = shareIdFromLine(line);
|
|
83
86
|
|
|
84
87
|
if (shareId) await client.sessionSetShareId(session.id, shareId);
|
|
88
|
+
if (params.fork) deps.out(`forked ${picked.id} as ${session.id}`);
|
|
85
89
|
deps.out(copied ? `share link copied - ${line}` : line);
|
|
86
90
|
|
|
87
91
|
return 0;
|
|
@@ -105,12 +109,10 @@ export async function pullSession(
|
|
|
105
109
|
return 1;
|
|
106
110
|
}
|
|
107
111
|
const remote = await deps.pull(session.shareId!, { host: params.host, port: params.port });
|
|
108
|
-
const before = session.annotations.
|
|
109
|
-
const merged = await client.sessionMergeShared(session.id,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
});
|
|
113
|
-
const added = merged.annotations.length - before;
|
|
112
|
+
const before = new Set(session.annotations.map((annotation) => annotation.id));
|
|
113
|
+
const merged = await client.sessionMergeShared(session.id, mergeFromShare(remote));
|
|
114
|
+
// count notes that were not here before, so a pull carrying removals never miscounts
|
|
115
|
+
const added = merged.annotations.filter((annotation) => !before.has(annotation.id)).length;
|
|
114
116
|
|
|
115
117
|
deps.out(
|
|
116
118
|
added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",
|