cueloop 0.1.0-alpha.23 → 0.1.0-alpha.26

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.26",
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.23",
14
- "@cueloop/daemon": "0.1.0-alpha.23",
15
- "@cueloop/client": "0.1.0-alpha.23"
13
+ "@cueloop/schema": "0.1.0-alpha.26",
14
+ "@cueloop/daemon": "0.1.0-alpha.26",
15
+ "@cueloop/client": "0.1.0-alpha.26"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
package/src/main.ts CHANGED
@@ -74,13 +74,11 @@ async function main(): Promise<number> {
74
74
  }
75
75
  case "share": {
76
76
  const { positional, flags } = parseArgs(argv.slice(1));
77
- const { shareCommand } = await import("./share-command");
77
+ const { shareCommand, sharePullCommand } = await import("./share-command");
78
78
  const port = stringFlag(flags, "port");
79
- return shareCommand({
80
- sessionId: positional[0],
81
- host: stringFlag(flags, "host"),
82
- port: port !== undefined ? Number(port) : undefined,
83
- });
79
+ const target = { host: stringFlag(flags, "host"), port: port !== undefined ? Number(port) : undefined };
80
+ if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
81
+ return shareCommand({ ...target, sessionId: positional[0] });
84
82
  }
85
83
  case "review":
86
84
  return reviewEntry(argv.slice(1));
@@ -210,22 +208,31 @@ function printHelp(): void {
210
208
  [
211
209
  "cueloop - review surface for coding agents",
212
210
  "",
213
- "usage:",
211
+ "usage: cueloop [command] [options]",
212
+ "",
213
+ "common commands:",
214
214
  " cueloop open the inbox",
215
- " cueloop <session-id> open one session",
216
215
  " cueloop plan [id|title] open the latest pending plan review (or one by id/title)",
217
- " cueloop diff [id|title] open the latest pending diff review (or one by id/title);",
218
- " with a dirty working tree and no argument, create one instead",
219
- " cueloop review [id|title] open the latest pending PR review (or one by id/title)",
216
+ " cueloop diff [id|title] review your working tree (untracked files included);",
217
+ " with a clean tree, open the latest pending diff review",
220
218
  " cueloop review <pr> review a pull request (--no-tui prints the session)",
221
- " cueloop <plan|diff|review> --latest open the latest pending review of that type",
222
- " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
219
+ "",
220
+ "share:",
223
221
  " cueloop serve [session-id] share over ssh: observers are read-only,",
224
222
  " you stay the controller (--port 2222, --host 127.0.0.1;",
225
223
  " password-less - share the address deliberately)",
226
224
  " cueloop share [session-id] hand a plan to a teammate: copies one",
227
225
  " ssh line (--host cueloop.dev, --port 22)",
226
+ " cueloop share pull [session-id] pull a teammate's annotations back into the plan",
227
+ "",
228
+ "open a specific review:",
229
+ " cueloop <session-id> open one session",
230
+ " cueloop review [id|title] open the latest pending PR review (or one by id/title)",
231
+ " cueloop <plan|diff|review> --latest open the latest pending review of that type",
232
+ "",
233
+ "scripting:",
228
234
  " cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
235
+ " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
229
236
  " cueloop daemon run the daemon in the foreground",
230
237
  ].join("\n"),
231
238
  );
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
2
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
3
3
  import type { SessionClient } from "@cueloop/daemon/client";
4
- import { shareSession, type ShareDeps } from "./share-command";
4
+ import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
5
5
 
6
- function sessionFixture(id: string): ReviewSession {
6
+ function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): ReviewSession {
7
7
  return {
8
8
  schemaVersion: SCHEMA_VERSION,
9
9
  id,
@@ -14,14 +14,31 @@ function sessionFixture(id: string): ReviewSession {
14
14
  verdict: null,
15
15
  status: "pending",
16
16
  createdAt: "2026-01-01T00:00:00.000Z",
17
+ ...overrides,
17
18
  };
18
19
  }
19
20
 
20
- /** A SessionClient that answers get/list from a fixed list. */
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. */
21
27
  function fakeClient(sessions: ReviewSession[]): SessionClient {
22
28
  return {
23
29
  sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
24
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
+ sessionMergeAnnotations: mock(async (id: string, incoming: 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) if (!known.has(annotation.id)) session.annotations.push(annotation);
40
+ return session;
41
+ }),
25
42
  } as unknown as SessionClient;
26
43
  }
27
44
 
@@ -84,4 +101,79 @@ describe(shareSession, () => {
84
101
  expect(deps.lines[0]).toContain("no plan to share");
85
102
  expect(deps.publish).not.toHaveBeenCalled();
86
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
+ });
87
179
  });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
10
- import { publishShare, type ShareResult, type ShareTarget } from "@cueloop/client";
10
+ import { collaboratorAnnotations, publishShare, pullShare, shareIdFromLine, type ShareResult, type ShareTarget } from "@cueloop/client";
11
11
  import type { ReviewSession } from "@cueloop/schema";
12
12
 
13
13
  export interface ShareParams {
@@ -22,7 +22,13 @@ export interface ShareDeps {
22
22
  out: (message: string) => void;
23
23
  }
24
24
 
25
+ export interface PullDeps {
26
+ pull: (shareId: string, target: ShareTarget) => Promise<ReviewSession>;
27
+ out: (message: string) => void;
28
+ }
29
+
25
30
  const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
31
+ const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
26
32
 
27
33
  /** Connect to the local daemon, then share the chosen session. */
28
34
  export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
@@ -34,7 +40,17 @@ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaul
34
40
  }
35
41
  }
36
42
 
37
- /** The orchestration, over any SessionClient - the seam the tests drive. */
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. */
38
54
  export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
39
55
  const session = await pickSession(client, params.sessionId);
40
56
  if (!session) {
@@ -42,13 +58,44 @@ export async function shareSession(client: SessionClient, params: ShareParams, d
42
58
  return 1;
43
59
  }
44
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);
45
63
  deps.out(copied ? `share link copied - ${line}` : line);
46
64
  return 0;
47
65
  }
48
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.sessionMergeAnnotations(session.id, collaboratorAnnotations(remote));
81
+ const added = merged.annotations.length - before;
82
+ deps.out(added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations");
83
+ return 0;
84
+ }
85
+
49
86
  /** The named session, or the most recent one when no id is given. */
50
87
  async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
51
88
  if (sessionId) return client.sessionGet(sessionId);
52
89
  const sessions = await client.sessionList();
53
90
  return sessions.at(-1) ?? null;
54
91
  }
92
+
93
+ /** The named session (if shared), or the most recent shared one. */
94
+ async function pickSharedSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
95
+ if (sessionId) {
96
+ const session = await client.sessionGet(sessionId);
97
+ return session.shareId ? session : null;
98
+ }
99
+ const sessions = await client.sessionList();
100
+ return sessions.filter((session) => session.shareId).at(-1) ?? null;
101
+ }