@cueloop/schema 0.1.0-alpha.67 → 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 +1 -1
- package/src/feedback.test.ts +61 -0
- package/src/feedback.ts +105 -37
- package/src/types.test.ts +15 -2
- package/src/types.ts +30 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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": {
|
package/src/feedback.test.ts
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
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,
|
|
42
|
-
* share the plan render path, first-heading title derivation, and revision
|
|
43
|
-
* drift-assist. A
|
|
44
|
-
*
|
|
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. */
|