cueloop 0.1.0-alpha.33 → 0.1.0-alpha.34

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,37 +1,37 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.34",
4
+ "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
5
- "type": "module",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/cli"
14
+ },
6
15
  "bin": {
7
16
  "cueloop": "./src/main.ts"
8
17
  },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "type": "module",
9
23
  "exports": {
10
24
  ".": "./src/main.ts"
11
25
  },
12
- "dependencies": {
13
- "@cueloop/schema": "0.1.0-alpha.33",
14
- "@cueloop/daemon": "0.1.0-alpha.33",
15
- "@cueloop/client": "0.1.0-alpha.33"
16
- },
17
26
  "publishConfig": {
18
27
  "access": "public"
19
28
  },
20
- "files": [
21
- "src",
22
- "README.md"
23
- ],
29
+ "dependencies": {
30
+ "@cueloop/client": "0.1.0-alpha.34",
31
+ "@cueloop/daemon": "0.1.0-alpha.34",
32
+ "@cueloop/schema": "0.1.0-alpha.34"
33
+ },
24
34
  "engines": {
25
35
  "bun": ">=1.3.0"
26
- },
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/mmurakaru/cueloop.git",
30
- "directory": "packages/cli"
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
- }
36
+ }
37
+ }
package/src/args.ts CHANGED
@@ -31,7 +31,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
31
31
  return { positional, flags };
32
32
  }
33
33
 
34
- export function stringFlag(flags: Record<string, string | boolean>, key: string): string | undefined {
34
+ export function stringFlag(
35
+ flags: Record<string, string | boolean>,
36
+ key: string,
37
+ ): string | undefined {
35
38
  const value = flags[key];
36
39
  return typeof value === "string" ? value : undefined;
37
40
  }
package/src/main.ts CHANGED
@@ -77,8 +77,12 @@ async function main(): Promise<number> {
77
77
  const { positional, flags } = parseArgs(argv.slice(1));
78
78
  const { shareCommand, sharePullCommand } = await import("./share-command");
79
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] });
80
+ const target = {
81
+ host: stringFlag(flags, "host"),
82
+ port: port !== undefined ? Number(port) : undefined,
83
+ };
84
+ if (positional[0] === "pull")
85
+ return sharePullCommand({ ...target, sessionId: positional[1] });
82
86
  return shareCommand({ ...target, sessionId: positional[0] });
83
87
  }
84
88
  case "review":
@@ -115,7 +119,9 @@ async function main(): Promise<number> {
115
119
  * latest pending review.
116
120
  */
117
121
  function openSelector(parsed: ParsedArgs): string | undefined {
118
- return parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest");
122
+ return (
123
+ parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest")
124
+ );
119
125
  }
120
126
 
121
127
  /**
@@ -167,7 +173,7 @@ async function diffCommand(argv: string[]): Promise<number> {
167
173
 
168
174
  const workspace = await resolveWorkspace();
169
175
  const diff = await workingTreeDiff();
170
- if (!diff.trim()) {
176
+ if (!diff.patch.trim()) {
171
177
  return openReviewOfKind(
172
178
  isDiffReview,
173
179
  "diff",
@@ -178,7 +184,8 @@ async function diffCommand(argv: string[]): Promise<number> {
178
184
  const client = await DaemonClient.connect({ autostart: true });
179
185
  const review = await openReview(client, {
180
186
  type: "diff",
181
- content: diff,
187
+ content: diff.patch,
188
+ files: diff.files,
182
189
  workspace,
183
190
  title: `working tree @ ${workspace.branch}`,
184
191
  });
@@ -202,7 +209,11 @@ async function reviewEntry(argv: string[]): Promise<number> {
202
209
  const { reviewCommand } = await import("./pr");
203
210
  return reviewCommand(argv);
204
211
  }
205
- return openReviewOfKind(isPrReview, "PR", explicitOpen || looksLikeSessionId ? selector : undefined);
212
+ return openReviewOfKind(
213
+ isPrReview,
214
+ "PR",
215
+ explicitOpen || looksLikeSessionId ? selector : undefined,
216
+ );
206
217
  }
207
218
 
208
219
  async function runTui(sessionId?: string): Promise<number> {
@@ -30,7 +30,10 @@ function session(overrides: {
30
30
  },
31
31
  revisions: [],
32
32
  annotations: [],
33
- verdict: overrides.status === "resolved" ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" } : null,
33
+ verdict:
34
+ overrides.status === "resolved"
35
+ ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" }
36
+ : null,
34
37
  status: overrides.status ?? "pending",
35
38
  createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
36
39
  };
@@ -84,7 +87,10 @@ describe("resolveOpenTarget: latest pending default", () => {
84
87
  ];
85
88
 
86
89
  // Assert
87
- expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_new" });
90
+ expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
91
+ kind: "session",
92
+ sessionId: "ses_new",
93
+ });
88
94
  });
89
95
 
90
96
  test("ignores resolved sessions and other artifact types", () => {
@@ -96,7 +102,10 @@ describe("resolveOpenTarget: latest pending default", () => {
96
102
  ];
97
103
 
98
104
  // Assert
99
- expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_plan" });
105
+ expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
106
+ kind: "session",
107
+ sessionId: "ses_plan",
108
+ });
100
109
  });
101
110
 
102
111
  test("no pending session in scope reports no-pending", () => {
@@ -111,10 +120,7 @@ describe("resolveOpenTarget: latest pending default", () => {
111
120
  describe("resolveOpenTarget: by id", () => {
112
121
  test("an exact session id in scope opens that session, pending or not", () => {
113
122
  // Arrange
114
- const sessions = [
115
- session({ id: "ses_a" }),
116
- session({ id: "ses_b", status: "resolved" }),
117
- ];
123
+ const sessions = [session({ id: "ses_a" }), session({ id: "ses_b", status: "resolved" })];
118
124
 
119
125
  // Assert
120
126
  expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
@@ -193,7 +199,9 @@ describe("resolveOpenTarget: fuzzy by title", () => {
193
199
 
194
200
  describe("openTargetMessage", () => {
195
201
  test("no-pending mirrors the clean-tree tone", () => {
196
- expect(openTargetMessage("plan", { kind: "no-pending" })).toBe("no pending plan review - nothing to open");
202
+ expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
203
+ "no pending plan review - nothing to open",
204
+ );
197
205
  });
198
206
 
199
207
  test("no-match names the selector", () => {
@@ -204,7 +212,11 @@ describe("openTargetMessage", () => {
204
212
 
205
213
  test("ambiguous lists the candidate titles", () => {
206
214
  // Act
207
- const message = openTargetMessage("plan", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
215
+ const message = openTargetMessage("plan", {
216
+ kind: "ambiguous",
217
+ selector: "rollout",
218
+ titles: ["A", "B"],
219
+ });
208
220
 
209
221
  // Assert
210
222
  expect(message).toContain('"rollout" matches several plan reviews');
@@ -62,8 +62,12 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
62
62
  const scoped = sessions.filter(query.match);
63
63
 
64
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" };
65
+ const latestPending = scoped
66
+ .filter((candidate) => candidate.status === "pending")
67
+ .sort(newestFirst)[0];
68
+ return latestPending
69
+ ? { kind: "session", sessionId: latestPending.id }
70
+ : { kind: "no-pending" };
67
71
  }
68
72
 
69
73
  const selector = query.selector;
@@ -83,7 +87,11 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
83
87
  .sort(newestFirst);
84
88
  if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
85
89
  if (substringMatches.length === 0) return { kind: "no-match", selector };
86
- return { kind: "ambiguous", selector, titles: substringMatches.map((candidate) => candidate.artifact.meta.title!) };
90
+ return {
91
+ kind: "ambiguous",
92
+ selector,
93
+ titles: substringMatches.map((candidate) => candidate.artifact.meta.title!),
94
+ };
87
95
  }
88
96
 
89
97
  /**
@@ -91,7 +99,10 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
91
99
  * to review" tone. `label` is the human word for the verb's scope ("plan",
92
100
  * "diff", "PR"). The "session" outcome has no message - the caller opens it.
93
101
  */
94
- export function openTargetMessage(label: string, target: Exclude<OpenTarget, { kind: "session" }>): string {
102
+ export function openTargetMessage(
103
+ label: string,
104
+ target: Exclude<OpenTarget, { kind: "session" }>,
105
+ ): string {
95
106
  switch (target.kind) {
96
107
  case "no-pending":
97
108
  return `no pending ${label} review - nothing to open`;
package/src/pr.ts CHANGED
@@ -56,6 +56,8 @@ export async function reviewCommand(argv: string[]): Promise<number> {
56
56
  return 1;
57
57
  }
58
58
  const client = await DaemonClient.connect({ autostart: true });
59
+ // A PR diff is a partial patch with no full file contents, so no `files` here;
60
+ // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
59
61
  const review = await openReview(client, {
60
62
  type: "diff",
61
63
  content: diff.stdout,
@@ -109,7 +111,14 @@ async function getSession(id: string): Promise<ReviewSession> {
109
111
  /** Post the resolved session's verdict to the PR: feedback.md is the review body. */
110
112
  async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
111
113
  const verdict = session.verdict!;
112
- const result = await gh(["pr", "review", pr, VERDICT_FLAG[verdict.kind], "--body", verdict.feedback]);
114
+ const result = await gh([
115
+ "pr",
116
+ "review",
117
+ pr,
118
+ VERDICT_FLAG[verdict.kind],
119
+ "--body",
120
+ verdict.feedback,
121
+ ]);
113
122
  if (result.code !== 0) {
114
123
  console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
115
124
  return 1;
@@ -29,7 +29,9 @@ export async function sessionCommand(argv: string[]): Promise<number> {
29
29
  const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
30
30
  // per-file agent notes for diff sessions: a JSON array of { path, body }
31
31
  const notesFile = stringFlag(flags, "notes-file");
32
- const notes = notesFile ? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[]) : undefined;
32
+ const notes = notesFile
33
+ ? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[])
34
+ : undefined;
33
35
  const review = await openReview(client, {
34
36
  type: (stringFlag(flags, "type") ?? "plan") as ArtifactType,
35
37
  content,
@@ -19,7 +19,13 @@ function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): Rev
19
19
  }
20
20
 
21
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" };
22
+ const base: Annotation = {
23
+ id,
24
+ kind: "comment",
25
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
26
+ body: "note",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
23
29
  return author ? { ...base, author } : base;
24
30
  }
25
31
 
@@ -36,7 +42,8 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
36
42
  sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
37
43
  const session = sessions.find((candidate) => candidate.id === id)!;
38
44
  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);
45
+ for (const annotation of incoming.annotations)
46
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
40
47
  return session;
41
48
  }),
42
49
  } as unknown as SessionClient;
@@ -55,11 +62,20 @@ function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: strin
55
62
  describe(shareSession, () => {
56
63
  test("publishes the named session and reports the copied ssh line", async () => {
57
64
  // Arrange
58
- const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
65
+ const publish = mock(
66
+ async (session: ReviewSession) => (
67
+ expect(session.id).toBe("ses_2"),
68
+ { line: "ssh p_abc123xy@cueloop.dev", copied: true }
69
+ ),
70
+ );
59
71
  const deps = depsSpy({ publish });
60
72
 
61
73
  // Act
62
- const code = await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
74
+ const code = await shareSession(
75
+ fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]),
76
+ { sessionId: "ses_2" },
77
+ deps,
78
+ );
63
79
 
64
80
  // Assert
65
81
  expect(code).toBe(0);
@@ -68,7 +84,12 @@ describe(shareSession, () => {
68
84
 
69
85
  test("without an id, shares the most recent session", async () => {
70
86
  // Arrange
71
- const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
87
+ const publish = mock(
88
+ async (session: ReviewSession) => (
89
+ expect(session.id).toBe("ses_2"),
90
+ { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }
91
+ ),
92
+ );
72
93
  const deps = depsSpy({ publish });
73
94
 
74
95
  // Act
@@ -80,7 +101,9 @@ describe(shareSession, () => {
80
101
 
81
102
  test("prints the bare line when the clipboard is unavailable", async () => {
82
103
  // Arrange
83
- const deps = depsSpy({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
104
+ const deps = depsSpy({
105
+ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })),
106
+ });
84
107
 
85
108
  // Act
86
109
  await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
@@ -118,15 +141,24 @@ describe(shareSession, () => {
118
141
 
119
142
  function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
120
143
  const lines: string[] = [];
121
- return { pull: mock(async () => remote), out: (message: string) => void lines.push(message), lines };
144
+ return {
145
+ pull: mock(async () => remote),
146
+ out: (message: string) => void lines.push(message),
147
+ lines,
148
+ };
122
149
  }
123
150
 
124
151
  describe(pullSession, () => {
125
152
  test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
126
153
  // 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")] });
154
+ const local = sessionFixture("ses_1", {
155
+ shareId: "p_abc123xy",
156
+ annotations: [annotationFixture("mine")],
157
+ });
128
158
  const client = fakeClient([local]);
129
- const remote = sessionFixture("ses_1", { annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")] });
159
+ const remote = sessionFixture("ses_1", {
160
+ annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")],
161
+ });
130
162
  const deps = pullDepsSpy(remote);
131
163
 
132
164
  // Act
@@ -154,8 +186,13 @@ describe(pullSession, () => {
154
186
 
155
187
  test("reports when nothing new came back", async () => {
156
188
  // 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")] }));
189
+ const local = sessionFixture("ses_1", {
190
+ shareId: "p_abc123xy",
191
+ annotations: [annotationFixture("theirs", "SHA256:mate")],
192
+ });
193
+ const deps = pullDepsSpy(
194
+ sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }),
195
+ );
159
196
 
160
197
  // Act
161
198
  await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
@@ -169,7 +206,11 @@ describe(pullSession, () => {
169
206
  const deps = pullDepsSpy(sessionFixture("ses_1"));
170
207
 
171
208
  // Act
172
- const code = await pullSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
209
+ const code = await pullSession(
210
+ fakeClient([sessionFixture("ses_1")]),
211
+ { sessionId: "ses_1" },
212
+ deps,
213
+ );
173
214
 
174
215
  // Assert
175
216
  expect(code).toBe(1);
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
10
- import { collaboratorAnnotations, publishShare, pullShare, shareIdFromLine, type ShareResult, type ShareTarget } from "@cueloop/client";
10
+ import {
11
+ collaboratorAnnotations,
12
+ publishShare,
13
+ pullShare,
14
+ shareIdFromLine,
15
+ type ShareResult,
16
+ type ShareTarget,
17
+ } from "@cueloop/client";
11
18
  import type { ReviewSession } from "@cueloop/schema";
12
19
 
13
20
  export interface ShareParams {
@@ -31,7 +38,10 @@ const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => consol
31
38
  const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
32
39
 
33
40
  /** Connect to the local daemon, then share the chosen session. */
34
- export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
41
+ export async function shareCommand(
42
+ params: ShareParams,
43
+ deps: ShareDeps = defaultDeps,
44
+ ): Promise<number> {
35
45
  const client = await DaemonClient.connect({ home: params.home, autostart: true });
36
46
  try {
37
47
  return await shareSession(client, params, deps);
@@ -41,7 +51,10 @@ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaul
41
51
  }
42
52
 
43
53
  /** Connect to the local daemon, then pull collaborator notes for the plan. */
44
- export async function sharePullCommand(params: ShareParams, deps: PullDeps = defaultPullDeps): Promise<number> {
54
+ export async function sharePullCommand(
55
+ params: ShareParams,
56
+ deps: PullDeps = defaultPullDeps,
57
+ ): Promise<number> {
45
58
  const client = await DaemonClient.connect({ home: params.home, autostart: true });
46
59
  try {
47
60
  return await pullSession(client, params, deps);
@@ -51,7 +64,11 @@ export async function sharePullCommand(params: ShareParams, deps: PullDeps = def
51
64
  }
52
65
 
53
66
  /** The share orchestration, over any SessionClient - the seam the tests drive. */
54
- export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
67
+ export async function shareSession(
68
+ client: SessionClient,
69
+ params: ShareParams,
70
+ deps: ShareDeps,
71
+ ): Promise<number> {
55
72
  const session = await pickSession(client, params.sessionId);
56
73
  if (!session) {
57
74
  deps.out("no plan to share - open a review first");
@@ -69,7 +86,11 @@ export async function shareSession(client: SessionClient, params: ShareParams, d
69
86
  * plan. Union-by-id keeps the planner's own notes; only collaborators' new
70
87
  * ones land. The gateway lets only the fingerprint that shared it pull.
71
88
  */
72
- export async function pullSession(client: SessionClient, params: ShareParams, deps: PullDeps): Promise<number> {
89
+ export async function pullSession(
90
+ client: SessionClient,
91
+ params: ShareParams,
92
+ deps: PullDeps,
93
+ ): Promise<number> {
73
94
  const session = await pickSharedSession(client, params.sessionId);
74
95
  if (!session) {
75
96
  deps.out("no shared plan to pull - run `cueloop share` first");
@@ -82,19 +103,27 @@ export async function pullSession(client: SessionClient, params: ShareParams, de
82
103
  participants: remote.participants,
83
104
  });
84
105
  const added = merged.annotations.length - before;
85
- deps.out(added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations");
106
+ deps.out(
107
+ added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",
108
+ );
86
109
  return 0;
87
110
  }
88
111
 
89
112
  /** The named session, or the most recent one when no id is given. */
90
- async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
113
+ async function pickSession(
114
+ client: SessionClient,
115
+ sessionId?: string,
116
+ ): Promise<ReviewSession | null> {
91
117
  if (sessionId) return client.sessionGet(sessionId);
92
118
  const sessions = await client.sessionList();
93
119
  return sessions.at(-1) ?? null;
94
120
  }
95
121
 
96
122
  /** The named session (if shared), or the most recent shared one. */
97
- async function pickSharedSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
123
+ async function pickSharedSession(
124
+ client: SessionClient,
125
+ sessionId?: string,
126
+ ): Promise<ReviewSession | null> {
98
127
  if (sessionId) {
99
128
  const session = await client.sessionGet(sessionId);
100
129
  return session.shareId ? session : null;
@@ -1,27 +1,100 @@
1
1
  /** Working-tree diff capture for `cueloop diff`, untracked files included. */
2
2
 
3
+ import { join } from "node:path";
4
+ import type { DiffFileContents } from "@cueloop/schema";
5
+
3
6
  async function git(args: string[], cwd: string): Promise<string | null> {
4
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
5
- const out = await new Response(proc.stdout).text();
6
- if ((await proc.exited) !== 0) return null;
7
- return out.trim();
7
+ const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
8
+ const stdout = await new Response(gitProcess.stdout).text();
9
+ if ((await gitProcess.exited) !== 0) return null;
10
+ return stdout.trim();
11
+ }
12
+
13
+ /** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
14
+ async function headContents(path: string, cwd: string): Promise<string> {
15
+ const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
16
+ cwd,
17
+ stdout: "pipe",
18
+ stderr: "ignore",
19
+ });
20
+ const stdout = await new Response(gitProcess.stdout).text();
21
+ return (await gitProcess.exited) === 0 ? stdout : "";
22
+ }
23
+
24
+ /** The working-tree file, or "" when it is gone (deleted). */
25
+ async function workingContents(path: string, cwd: string): Promise<string> {
26
+ try {
27
+ return await Bun.file(join(cwd, path)).text();
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+
33
+ /** A NUL byte marks content git would treat as binary - not text-curatable. */
34
+ function looksBinary(text: string): boolean {
35
+ for (let index = 0; index < text.length; index++) {
36
+ if (text.charCodeAt(index) === 0) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Full old/new contents for every tracked change against HEAD, classified by
43
+ * git's own status letter. Renames/copies and binary files are omitted: a
44
+ * path+text pair cannot express a rename header or a binary blob, so curation
45
+ * would emit a patch that drops the old path or corrupts the file - leaving them
46
+ * out keeps those changes in the patch but simply not curatable. The
47
+ * NUL-delimited stream survives paths with spaces or unicode.
48
+ */
49
+ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
50
+ const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
51
+ const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
52
+ const files: DiffFileContents[] = [];
53
+ let cursor = 0;
54
+ while (cursor < tokens.length) {
55
+ const code = tokens[cursor++]![0];
56
+ if (code === "R" || code === "C") {
57
+ cursor += 2;
58
+ continue;
59
+ }
60
+ const path = tokens[cursor++]!;
61
+ const oldContents = code === "A" ? "" : await headContents(path, cwd);
62
+ const newContents = code === "D" ? "" : await workingContents(path, cwd);
63
+ if (looksBinary(oldContents) || looksBinary(newContents)) continue;
64
+ const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
65
+ files.push({ path, oldContents, newContents, status });
66
+ }
67
+ return files;
68
+ }
69
+
70
+ export interface WorkingTreeDiff {
71
+ patch: string;
72
+ /** Full file contents per curatable changed file, keeping curation applyable. */
73
+ files: DiffFileContents[];
8
74
  }
9
75
 
10
- export async function workingTreeDiff(cwd = process.cwd()): Promise<string> {
76
+ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
11
77
  const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
78
+ const files = await trackedFileContents(cwd);
12
79
  // untracked files included by default
13
80
  const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
14
81
  let untracked = "";
15
82
  for (const file of untrackedList.split("\n").filter(Boolean)) {
16
83
  // `git diff --no-index` exits 1 when the files differ - that is success here
17
- const proc = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
84
+ const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
18
85
  cwd,
19
86
  stdout: "pipe",
20
87
  stderr: "ignore",
21
88
  });
22
- const out = await new Response(proc.stdout).text();
23
- const code = await proc.exited;
24
- if ((code === 0 || code === 1) && out.trim()) untracked += out.trim() + "\n";
89
+ const stdout = await new Response(gitProcess.stdout).text();
90
+ const code = await gitProcess.exited;
91
+ if ((code === 0 || code === 1) && stdout.trim()) {
92
+ untracked += stdout.trim() + "\n";
93
+ const newContents = await workingContents(file, cwd);
94
+ if (!looksBinary(newContents)) {
95
+ files.push({ path: file, oldContents: "", newContents, status: "added" });
96
+ }
97
+ }
25
98
  }
26
- return [tracked, untracked].filter(Boolean).join("\n");
99
+ return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
27
100
  }