@cueloop/schema 0.1.0-alpha.68 → 0.1.0-alpha.69

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/schema",
3
- "version": "0.1.0-alpha.68",
3
+ "version": "0.1.0-alpha.69",
4
4
  "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -329,3 +329,64 @@ describe("renderFeedback", () => {
329
329
  expect(feedback).not.toContain("orphaned anchor");
330
330
  });
331
331
  });
332
+
333
+ describe("renderFeedback target grouping", () => {
334
+ test("file notes group under their own path section, apart from the artifact", () => {
335
+ const feedback = renderFeedback({
336
+ verdictKind: "request_changes",
337
+ summary: "",
338
+ artifactContent: PLAN,
339
+ artifactPath: "docs/plan.md",
340
+ annotations: [
341
+ makeAnnotation({
342
+ id: "art1",
343
+ anchor: { quote: "Sessions are written", prefix: "", suffix: "" },
344
+ body: "artifact note",
345
+ }),
346
+ makeAnnotation({
347
+ id: "f1",
348
+ anchor: { quote: "const a = 1", prefix: "", suffix: "" },
349
+ target: { kind: "file", path: "src/a.ts", rev: "worktree" },
350
+ body: "file a note",
351
+ }),
352
+ makeAnnotation({
353
+ id: "f2",
354
+ anchor: { quote: "const b = 2", prefix: "", suffix: "" },
355
+ target: { kind: "file", path: "src/b.ts", rev: "worktree" },
356
+ body: "file b note",
357
+ }),
358
+ ],
359
+ });
360
+
361
+ // Assert - the artifact keeps its section; each file gets its own, with its note
362
+ expect(feedback).toContain("## Annotations (1)");
363
+ expect(feedback).toContain("artifact note");
364
+ expect(feedback).toContain("## src/a.ts (1)");
365
+ expect(feedback).toContain("file a note");
366
+ expect(feedback).toContain("## src/b.ts (1)");
367
+ expect(feedback).toContain("file b note");
368
+ // the file note's path is the heading, not an inline "(in ...)" locator on the artifact list
369
+ expect(feedback).not.toContain("(in src/a.ts)");
370
+ });
371
+
372
+ test("a quick-action reference expands to its body; an unknown skill reference passes through", () => {
373
+ const feedback = renderFeedback({
374
+ verdictKind: "comment",
375
+ summary: "",
376
+ artifactContent: PLAN,
377
+ annotations: [
378
+ makeAnnotation({
379
+ anchor: { quote: "Storage", prefix: "", suffix: "" },
380
+ body: "/restate-simplified and also /vitest-patterns here",
381
+ }),
382
+ ],
383
+ artifactPath: "docs/plan.md",
384
+ actionBodies: { "restate-simplified": "Restate this as the simplest thing that works." },
385
+ });
386
+
387
+ expect(feedback).toContain("Restate this as the simplest thing that works.");
388
+ // the unknown reference (a user skill) is left verbatim for the harness to resolve
389
+ expect(feedback).toContain("/vitest-patterns");
390
+ expect(feedback).not.toContain("/restate-simplified");
391
+ });
392
+ });
package/src/feedback.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ annotationTarget,
9
10
  isAddressed,
10
11
  isAgentNote,
11
12
  type Annotation,
@@ -13,7 +14,7 @@ import {
13
14
  type ReviewSession,
14
15
  type VerdictKind,
15
16
  } from "./types";
16
- import { parseBlocks, sectionOf } from "./markdown";
17
+ import { parseBlocks, sectionOf, type Block } from "./markdown";
17
18
  import { resolveAnchor } from "./anchor";
18
19
  import { unifiedDiffText } from "./diff";
19
20
 
@@ -32,10 +33,20 @@ export interface FeedbackInput {
32
33
  artifactPath?: string;
33
34
  /** Session id, so the document can teach the addressed-ids resubmit call. */
34
35
  sessionId?: string;
36
+ /** cueloop quick-action name -> body; a `/name` reference in a comment expands to it for the agent. */
37
+ actionBodies?: Record<string, string>;
35
38
  }
36
39
 
37
40
  const quoteLines = (text: string) => "> " + text.replace(/\n/g, "\n> ");
38
41
 
42
+ function expandActionRefs(body: string, actionBodies: Record<string, string> | undefined): string {
43
+ if (!actionBodies) return body;
44
+
45
+ return body.replace(/(^|\s)\/([A-Za-z0-9-]+)/g, (whole, lead: string, name: string) =>
46
+ actionBodies[name] !== undefined ? `${lead}${actionBodies[name]}` : whole,
47
+ );
48
+ }
49
+
39
50
  export function renderFeedback(input: FeedbackInput): string {
40
51
  // The document the agent revises: a reply's feedback references reply.md so
41
52
  // the resubmit instruction never points a reply review at plan.md.
@@ -118,44 +129,16 @@ export function renderFeedback(input: FeedbackInput): string {
118
129
  }
119
130
 
120
131
  if (annotations.length) {
121
- lines.push(`## Annotations (${annotations.length})`);
122
- lines.push("");
123
132
  lines.push(
124
- isPrototype
125
- ? `Address every item. Locate each one in ${path} by its CSS selector.`
126
- : `Address every item. Locate each one in ${path} by its quoted text.`,
133
+ ...annotationSectionLines(
134
+ annotations,
135
+ blocks,
136
+ path,
137
+ isPrototype,
138
+ repliesTo,
139
+ input.actionBodies,
140
+ ),
127
141
  );
128
- lines.push("");
129
- annotations.forEach((annotation, annotationIndex) => {
130
- // a prototype selector anchor is never resolved or orphaned against blocks
131
- const selector = annotation.anchor.selector;
132
- const resolved = selector ? null : resolveAnchor(annotation.anchor, blocks);
133
- const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
134
- const location = selector ? ` (${selector})` : sectionTitle ? ` (§ ${sectionTitle})` : "";
135
- const orphanFlag =
136
- !selector && resolved === null
137
- ? " [orphaned anchor: the quoted text is no longer present]"
138
- : "";
139
-
140
- lines.push(
141
- `### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
142
- );
143
- lines.push("");
144
- lines.push(quoteLines(annotation.anchor.quote));
145
- lines.push("");
146
- lines.push(annotation.body);
147
- lines.push("");
148
- const replies = repliesTo(annotation);
149
-
150
- if (replies.length > 0) {
151
- lines.push("Replies:");
152
- lines.push("");
153
- for (const reply of replies) lines.push(`- ${reply.body.replace(/\n/g, "\n ")}`);
154
- lines.push("");
155
- }
156
- lines.push(`annotation id: \`${annotation.id}\``);
157
- lines.push("");
158
- });
159
142
  if (input.sessionId) {
160
143
  lines.push("## Reporting what you addressed");
161
144
  lines.push("");
@@ -179,10 +162,94 @@ export function renderFeedback(input: FeedbackInput): string {
179
162
  return lines.join("\n");
180
163
  }
181
164
 
165
+ /**
166
+ * The annotation body of the feedback, grouped by the surface each note was made on: the reviewed
167
+ * artifact first (resolved against its blocks for a section locator), then one section per file, so a
168
+ * note left on a file comes back beside that file rather than mixed into the plan's list.
169
+ */
170
+ function annotationSectionLines(
171
+ annotations: Annotation[],
172
+ blocks: Block[],
173
+ path: string,
174
+ isPrototype: boolean,
175
+ repliesTo: (root: Annotation) => Annotation[],
176
+ actionBodies: Record<string, string> | undefined,
177
+ ): string[] {
178
+ const lines: string[] = [];
179
+ const artifactNotes = annotations.filter(
180
+ (annotation) => annotationTarget(annotation).kind === "artifact",
181
+ );
182
+ const fileGroups = new Map<string, Annotation[]>();
183
+
184
+ for (const annotation of annotations) {
185
+ const target = annotationTarget(annotation);
186
+
187
+ if (target.kind !== "file") continue;
188
+ const group = fileGroups.get(target.path) ?? [];
189
+
190
+ group.push(annotation);
191
+ fileGroups.set(target.path, group);
192
+ }
193
+
194
+ const pushNote = (annotation: Annotation, index: number, location: string, orphan: string) => {
195
+ lines.push(`### ${index + 1}. ${capitalize(annotation.kind)}${location}${orphan}`);
196
+ lines.push("");
197
+ lines.push(quoteLines(annotation.anchor.quote));
198
+ lines.push("");
199
+ lines.push(expandActionRefs(annotation.body, actionBodies));
200
+ lines.push("");
201
+ const replies = repliesTo(annotation);
202
+
203
+ if (replies.length > 0) {
204
+ lines.push("Replies:");
205
+ lines.push("");
206
+ for (const reply of replies)
207
+ lines.push(`- ${expandActionRefs(reply.body, actionBodies).replace(/\n/g, "\n ")}`);
208
+ lines.push("");
209
+ }
210
+ lines.push(`annotation id: \`${annotation.id}\``);
211
+ lines.push("");
212
+ };
213
+
214
+ if (artifactNotes.length) {
215
+ lines.push(`## Annotations (${artifactNotes.length})`);
216
+ lines.push("");
217
+ lines.push(
218
+ isPrototype
219
+ ? `Address every item. Locate each one in ${path} by its CSS selector.`
220
+ : `Address every item. Locate each one in ${path} by its quoted text.`,
221
+ );
222
+ lines.push("");
223
+ artifactNotes.forEach((annotation, index) => {
224
+ // a prototype selector anchor never resolves or orphans against blocks; a quote anchor does
225
+ const selector = annotation.anchor.selector;
226
+ const resolved = selector ? null : resolveAnchor(annotation.anchor, blocks);
227
+ const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
228
+ const location = selector ? ` (${selector})` : sectionTitle ? ` (§ ${sectionTitle})` : "";
229
+ const orphan =
230
+ !selector && resolved === null
231
+ ? " [orphaned anchor: the quoted text is no longer present]"
232
+ : "";
233
+
234
+ pushNote(annotation, index, location, orphan);
235
+ });
236
+ }
237
+ for (const [filePath, notes] of fileGroups) {
238
+ lines.push(`## ${filePath} (${notes.length})`);
239
+ lines.push("");
240
+ lines.push(`Address every item. Locate each one in ${filePath} by its quoted text.`);
241
+ lines.push("");
242
+ notes.forEach((annotation, index) => pushNote(annotation, index, "", ""));
243
+ }
244
+
245
+ return lines;
246
+ }
247
+
182
248
  export function feedbackForSession(
183
249
  session: ReviewSession,
184
250
  verdictKind: VerdictKind,
185
251
  summary: string,
252
+ actionBodies?: Record<string, string>,
186
253
  ): string {
187
254
  return renderFeedback({
188
255
  verdictKind,
@@ -193,6 +260,7 @@ export function feedbackForSession(
193
260
  annotations: session.annotations,
194
261
  artifactPath: session.artifact.meta.prototypePath ?? session.artifact.meta.planPath,
195
262
  sessionId: session.id,
263
+ actionBodies,
196
264
  });
197
265
  }
198
266
 
package/src/types.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ annotationTarget,
3
4
  ARTIFACT_TYPES,
4
5
  isArtifactType,
5
6
  isMarkdownArtifact,
@@ -7,6 +8,18 @@ import {
7
8
  verdictAllows,
8
9
  } from "./types";
9
10
 
11
+ describe("annotationTarget", () => {
12
+ test("an annotation with no target means the reviewed artifact", () => {
13
+ expect(annotationTarget({ target: undefined })).toEqual({ kind: "artifact" });
14
+ });
15
+
16
+ test("a stamped target is returned as-is", () => {
17
+ const target = { kind: "file", path: "src/x.ts", rev: "worktree" } as const;
18
+
19
+ expect(annotationTarget({ target })).toBe(target);
20
+ });
21
+ });
22
+
10
23
  describe("ARTIFACT_TYPES", () => {
11
24
  test("names every primitive exactly once", () => {
12
25
  expect([...ARTIFACT_TYPES].sort()).toEqual(["diff", "plan", "prototype", "reply"]);
@@ -29,11 +42,11 @@ describe("verdictAllows", () => {
29
42
  });
30
43
 
31
44
  describe("isMarkdownArtifact", () => {
32
- test("plan and reply are markdown; diff and prototype are not", () => {
45
+ test("plan, reply, and prototype are markdown; diff is not", () => {
33
46
  expect(isMarkdownArtifact("plan")).toBe(true);
34
47
  expect(isMarkdownArtifact("reply")).toBe(true);
48
+ expect(isMarkdownArtifact("prototype")).toBe(true);
35
49
  expect(isMarkdownArtifact("diff")).toBe(false);
36
- expect(isMarkdownArtifact("prototype")).toBe(false);
37
50
  });
38
51
  });
39
52
 
package/src/types.ts CHANGED
@@ -22,7 +22,8 @@ export interface WorkspaceKey {
22
22
  * What kind of artifact a review session holds. `plan` and `reply` are both
23
23
  * markdown documents (see isMarkdownArtifact) - a plan is a proposal written
24
24
  * forward, a reply is the agent's previous message pulled back for review.
25
- * `diff` is a unified-diff patch; `prototype` is a rendered HTML page.
25
+ * `diff` is a unified-diff patch; `prototype` is a component design doc (API /
26
+ * Composition / Callstack) in markdown, with an opt-in experimental pixel mode.
26
27
  *
27
28
  * One runtime union: every consumer that names the supported set - daemon
28
29
  * wire validation, CLI flag parsing, adapter tool schemas - derives from this
@@ -38,13 +39,15 @@ export function isArtifactType(value: string): value is ArtifactType {
38
39
  }
39
40
 
40
41
  /**
41
- * Markdown artifacts (plan, reply) are block-parsed and quote-anchored, so they
42
- * share the plan render path, first-heading title derivation, and revision
43
- * drift-assist. A diff (a patch) and a prototype (HTML/DOM) do not - keep this
44
- * the one place that names the set, so a new markdown primitive joins here once.
42
+ * Markdown artifacts (plan, reply, prototype) are block-parsed and quote-anchored,
43
+ * so they share the plan render path, first-heading title derivation, and revision
44
+ * drift-assist. A prototype is a component design doc (API / Composition / Callstack)
45
+ * by default; its opt-in experimental pixel mode is a client render override, not a
46
+ * different artifact. A diff (a patch) does not - keep this the one place that names
47
+ * the set, so a new markdown primitive joins here once.
45
48
  */
46
49
  export function isMarkdownArtifact(type: ArtifactType): boolean {
47
- return type === "plan" || type === "reply";
50
+ return type === "plan" || type === "reply" || type === "prototype";
48
51
  }
49
52
 
50
53
  export interface ArtifactMeta {
@@ -61,6 +64,8 @@ export interface ArtifactMeta {
61
64
  /** herdr pane the submitting agent runs in - the review returns focus there. */
62
65
  herdrPane?: string;
63
66
  title?: string;
67
+ /** A self-initiated per-repo workbench thread (a bare launch's first comment), not an agent submission. */
68
+ workbench?: boolean;
64
69
  }
65
70
 
66
71
  /** Full old/new contents of one changed file, keyed by its repo-relative path. */
@@ -122,6 +127,23 @@ export interface Anchor {
122
127
  /** The annotation kind set is open; these are the built-ins. */
123
128
  export type AnnotationKind = "comment" | (string & {});
124
129
 
130
+ /**
131
+ * The surface an annotation was made on. Name-only: the ref names the surface and the
132
+ * client loads that surface's text to resolve the quote anchor. Absent means the session's
133
+ * reviewed artifact, so every existing annotation keeps its meaning with no data change.
134
+ * A `file` target's `rev` doubles as the diff side: worktree for an added or context row,
135
+ * head for a deletion.
136
+ */
137
+ export type AnnotationTarget =
138
+ | { kind: "artifact" }
139
+ | { kind: "file"; path: string; rev: "worktree" | "head" }
140
+ | { kind: "welcome" };
141
+
142
+ /** The target an annotation resolves against; absent records mean the reviewed artifact. */
143
+ export function annotationTarget(annotation: Pick<Annotation, "target">): AnnotationTarget {
144
+ return annotation.target ?? { kind: "artifact" };
145
+ }
146
+
125
147
  /**
126
148
  * Agent-authored context, not reviewer feedback: the guided walk's per-file
127
149
  * notes (kind "note", anchored by the file path). Excluded from the feedback
@@ -136,6 +158,8 @@ export interface Annotation {
136
158
  id: string;
137
159
  kind: AnnotationKind;
138
160
  anchor: Anchor;
161
+ /** The surface this note was made on; absent means the session's reviewed artifact. */
162
+ target?: AnnotationTarget;
139
163
  /** Comment body. */
140
164
  body: string;
141
165
  /** Set by resolution when the quote can no longer be found. */