@cueloop/schema 0.1.0-alpha.32 → 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 +17 -17
- package/src/anchor.test.ts +23 -6
- package/src/anchor.ts +22 -4
- package/src/diff.test.ts +6 -15
- package/src/diff.ts +18 -11
- package/src/feedback.test.ts +68 -24
- package/src/feedback.ts +73 -32
- package/src/herdr.test.ts +3 -1
- package/src/herdr.ts +4 -1
- package/src/markdown.test.ts +3 -1
- package/src/markdown.ts +21 -6
- package/src/types.ts +20 -2
- package/src/working-copy.test.ts +15 -4
package/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.34",
|
|
4
|
+
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
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",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/schema"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts"
|
|
@@ -9,21 +23,7 @@
|
|
|
9
23
|
"publishConfig": {
|
|
10
24
|
"access": "public"
|
|
11
25
|
},
|
|
12
|
-
"files": [
|
|
13
|
-
"src",
|
|
14
|
-
"README.md"
|
|
15
|
-
],
|
|
16
26
|
"engines": {
|
|
17
27
|
"bun": ">=1.3.0"
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
22
|
-
"directory": "packages/schema"
|
|
23
|
-
},
|
|
24
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
25
|
-
"bugs": {
|
|
26
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
27
|
-
},
|
|
28
|
-
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization"
|
|
29
|
-
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/anchor.test.ts
CHANGED
|
@@ -54,7 +54,9 @@ describe("anchor cascade", () => {
|
|
|
54
54
|
const blocks = blocksOf(DOC);
|
|
55
55
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
56
56
|
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
57
|
-
const edited = blocksOf(
|
|
57
|
+
const edited = blocksOf(
|
|
58
|
+
DOC.replace("Review sessions currently", "PREFIX. Review sessions now"),
|
|
59
|
+
);
|
|
58
60
|
|
|
59
61
|
// Act
|
|
60
62
|
const resolved = resolveAnchor(anchor, edited)!;
|
|
@@ -62,7 +64,9 @@ describe("anchor cascade", () => {
|
|
|
62
64
|
// Assert
|
|
63
65
|
const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
|
|
64
66
|
expect(resolved.blockIndex).toBe(editedBlockIndex);
|
|
65
|
-
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
67
|
+
expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
|
|
68
|
+
"Review sessions",
|
|
69
|
+
);
|
|
66
70
|
});
|
|
67
71
|
|
|
68
72
|
test("prefix/suffix selectors disambiguate repeated quotes", () => {
|
|
@@ -72,7 +76,12 @@ describe("anchor cascade", () => {
|
|
|
72
76
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
73
77
|
const text = blocks[storageBlockIndex]!.text;
|
|
74
78
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
75
|
-
const anchor = makeAnchor(
|
|
79
|
+
const anchor = makeAnchor(
|
|
80
|
+
blocks,
|
|
81
|
+
storageBlockIndex,
|
|
82
|
+
second,
|
|
83
|
+
second + "Sessions are written".length,
|
|
84
|
+
);
|
|
76
85
|
|
|
77
86
|
// Act
|
|
78
87
|
const resolved = resolveAnchor(anchor, blocks)!;
|
|
@@ -96,8 +105,14 @@ describe("anchor cascade", () => {
|
|
|
96
105
|
|
|
97
106
|
test("trimmed-quote fallback marks the resolution approximate", () => {
|
|
98
107
|
// Arrange
|
|
99
|
-
const
|
|
100
|
-
|
|
108
|
+
const anchor = {
|
|
109
|
+
quote: " text ",
|
|
110
|
+
prefix: "some",
|
|
111
|
+
suffix: "here",
|
|
112
|
+
blockIndex: 0,
|
|
113
|
+
start: 4,
|
|
114
|
+
end: 10,
|
|
115
|
+
};
|
|
101
116
|
const edited = blocksOf("some\ntext\nhere");
|
|
102
117
|
// " text " (with spaces) is gone, "text" survives
|
|
103
118
|
|
|
@@ -114,7 +129,9 @@ describe("anchor cascade", () => {
|
|
|
114
129
|
const blocks = blocksOf(DOC);
|
|
115
130
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
116
131
|
const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
|
|
117
|
-
const edited = blocksOf(
|
|
132
|
+
const edited = blocksOf(
|
|
133
|
+
DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
|
|
134
|
+
);
|
|
118
135
|
|
|
119
136
|
// Assert
|
|
120
137
|
expect(resolveAnchor(anchor, edited)).toBeNull();
|
package/src/anchor.ts
CHANGED
|
@@ -22,7 +22,12 @@ export interface ResolvedAnchor {
|
|
|
22
22
|
approximate: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function makeAnchor(
|
|
25
|
+
export function makeAnchor(
|
|
26
|
+
blocks: Block[],
|
|
27
|
+
blockIndex: number,
|
|
28
|
+
start: number,
|
|
29
|
+
end: number,
|
|
30
|
+
): Anchor {
|
|
26
31
|
const blockText = blocks[blockIndex]?.text ?? "";
|
|
27
32
|
return {
|
|
28
33
|
quote: blockText.slice(start, end),
|
|
@@ -42,15 +47,28 @@ function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candida
|
|
|
42
47
|
const candidates: Candidate[] = [];
|
|
43
48
|
blocks.forEach((block, blockIndex) => {
|
|
44
49
|
const text = block.text;
|
|
45
|
-
for (
|
|
50
|
+
for (
|
|
51
|
+
let matchStart = text.indexOf(quote);
|
|
52
|
+
matchStart !== -1;
|
|
53
|
+
matchStart = text.indexOf(quote, matchStart + 1)
|
|
54
|
+
) {
|
|
46
55
|
const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
|
|
47
|
-
const suffix = text.slice(
|
|
56
|
+
const suffix = text.slice(
|
|
57
|
+
matchStart + quote.length,
|
|
58
|
+
matchStart + quote.length + ANCHOR_CONTEXT_CHARS,
|
|
59
|
+
);
|
|
48
60
|
let score = 0;
|
|
49
61
|
if (prefix === anchor.prefix) score += 2;
|
|
50
62
|
if (suffix === anchor.suffix) score += 2;
|
|
51
63
|
if (anchor.blockIndex === blockIndex) score += 1;
|
|
52
64
|
if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
|
|
53
|
-
candidates.push({
|
|
65
|
+
candidates.push({
|
|
66
|
+
blockIndex,
|
|
67
|
+
start: matchStart,
|
|
68
|
+
end: matchStart + quote.length,
|
|
69
|
+
approximate: false,
|
|
70
|
+
score,
|
|
71
|
+
});
|
|
54
72
|
}
|
|
55
73
|
});
|
|
56
74
|
return candidates;
|
package/src/diff.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { lcsDiff, unifiedDiff, unifiedDiffText, editStats
|
|
2
|
+
import { lcsDiff, unifiedDiff, unifiedDiffText, editStats } from "./diff";
|
|
3
3
|
|
|
4
4
|
describe("unifiedDiff", () => {
|
|
5
5
|
test("equal inputs return null, not an empty diff", () => {
|
|
@@ -72,23 +72,14 @@ describe("editStats", () => {
|
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
describe("wordDiff", () => {
|
|
76
|
-
test("preserves whitespace tokens and reconstructs both sides", () => {
|
|
77
|
-
// Act
|
|
78
|
-
const ops = wordDiff("the quick brown fox", "the slow brown foxes");
|
|
79
|
-
|
|
80
|
-
// Assert
|
|
81
|
-
const oldSide = ops.filter((op) => op.kind !== "add").map((op) => op.oldValue).join("");
|
|
82
|
-
const newSide = ops.filter((op) => op.kind !== "del").map((op) => op.newValue).join("");
|
|
83
|
-
expect(oldSide).toBe("the quick brown fox");
|
|
84
|
-
expect(newSide).toBe("the slow brown foxes");
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
75
|
describe("lcsDiff", () => {
|
|
89
76
|
test("custom equality", () => {
|
|
90
77
|
// Act
|
|
91
|
-
const ops = lcsDiff(
|
|
78
|
+
const ops = lcsDiff(
|
|
79
|
+
[{ v: 1 }, { v: 2 }],
|
|
80
|
+
[{ v: 2 }, { v: 3 }],
|
|
81
|
+
(oldItem, newItem) => oldItem.v === newItem.v,
|
|
82
|
+
);
|
|
92
83
|
|
|
93
84
|
// Assert
|
|
94
85
|
expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
|
package/src/diff.ts
CHANGED
|
@@ -22,12 +22,17 @@ export function lcsDiff<T>(
|
|
|
22
22
|
): DiffOp<T>[] {
|
|
23
23
|
const oldCount = oldItems.length;
|
|
24
24
|
const newCount = newItems.length;
|
|
25
|
-
const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
|
|
25
|
+
const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
|
|
26
|
+
Array.from({ length: newCount + 1 }, () => 0),
|
|
27
|
+
);
|
|
26
28
|
for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
|
|
27
29
|
for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
|
|
28
30
|
longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
|
|
29
31
|
? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
|
|
30
|
-
: Math.max(
|
|
32
|
+
: Math.max(
|
|
33
|
+
longestCommon[oldIndex + 1]![newIndex]!,
|
|
34
|
+
longestCommon[oldIndex]![newIndex + 1]!,
|
|
35
|
+
);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
const ops: DiffOp<T>[] = [];
|
|
@@ -76,7 +81,7 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
76
81
|
if (op.kind !== "del") newLineNumber++;
|
|
77
82
|
return row;
|
|
78
83
|
});
|
|
79
|
-
const keep =
|
|
84
|
+
const keep = Array.from({ length: rows.length }, () => false);
|
|
80
85
|
rows.forEach((row, rowIndex) => {
|
|
81
86
|
if (row.kind !== "ctx") {
|
|
82
87
|
for (
|
|
@@ -109,14 +114,22 @@ export function unifiedDiff(oldText: string, newText: string, context = 3): Unif
|
|
|
109
114
|
const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
|
|
110
115
|
lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
|
|
111
116
|
for (const row of hunk.rows) {
|
|
112
|
-
lines.push({
|
|
117
|
+
lines.push({
|
|
118
|
+
kind: row.kind,
|
|
119
|
+
text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text,
|
|
120
|
+
});
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
return lines;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
/** Render a unified diff as text with file headers. */
|
|
119
|
-
export function unifiedDiffText(
|
|
127
|
+
export function unifiedDiffText(
|
|
128
|
+
oldText: string,
|
|
129
|
+
newText: string,
|
|
130
|
+
path = "plan.md",
|
|
131
|
+
context = 3,
|
|
132
|
+
): string | null {
|
|
120
133
|
const lines = unifiedDiff(oldText, newText, context);
|
|
121
134
|
if (!lines) return null;
|
|
122
135
|
return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
|
|
@@ -135,9 +148,3 @@ export function editStats(oldText: string, newText: string): EditStats {
|
|
|
135
148
|
removed: lines.filter((line) => line.kind === "del").length,
|
|
136
149
|
};
|
|
137
150
|
}
|
|
138
|
-
|
|
139
|
-
/** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
|
|
140
|
-
export function wordDiff(oldText: string, newText: string): DiffOp[] {
|
|
141
|
-
const tokenize = (text: string) => text.split(/(\s+)/).filter((token) => token !== "");
|
|
142
|
-
return lcsDiff(tokenize(oldText), tokenize(newText));
|
|
143
|
-
}
|
package/src/feedback.test.ts
CHANGED
|
@@ -13,7 +13,9 @@ Review sessions live only in daemon memory today.
|
|
|
13
13
|
Sessions are written as one JSON document per session.
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
|
-
function makeAnnotation(
|
|
16
|
+
function makeAnnotation(
|
|
17
|
+
partial: Partial<Annotation> & { anchor: Annotation["anchor"] },
|
|
18
|
+
): Annotation {
|
|
17
19
|
return {
|
|
18
20
|
id: "a1",
|
|
19
21
|
kind: "comment",
|
|
@@ -45,45 +47,73 @@ describe("renderFeedback", () => {
|
|
|
45
47
|
expect(feedback).toContain("+Sessions are written as one JSON record per session.");
|
|
46
48
|
});
|
|
47
49
|
|
|
48
|
-
test("
|
|
50
|
+
test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const submittedPatch = `--- a/src/x.ts
|
|
53
|
+
+++ b/src/x.ts
|
|
54
|
+
@@ -1,1 +1,1 @@
|
|
55
|
+
-const limit = 100;
|
|
56
|
+
+const limit = 250;
|
|
57
|
+
`;
|
|
58
|
+
const curatedPatch = `--- a/src/x.ts
|
|
59
|
+
+++ b/src/x.ts
|
|
60
|
+
@@ -1,1 +1,1 @@
|
|
61
|
+
-const limit = 100;
|
|
62
|
+
+const limit = 175;
|
|
63
|
+
`;
|
|
64
|
+
|
|
49
65
|
// Act
|
|
50
66
|
const feedback = renderFeedback({
|
|
51
|
-
verdictKind: "
|
|
67
|
+
verdictKind: "request_changes",
|
|
52
68
|
summary: "",
|
|
53
|
-
artifactContent:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}),
|
|
58
|
-
],
|
|
69
|
+
artifactContent: submittedPatch,
|
|
70
|
+
workingCopy: curatedPatch,
|
|
71
|
+
artifactType: "diff",
|
|
72
|
+
annotations: [],
|
|
59
73
|
});
|
|
60
74
|
|
|
61
75
|
// Assert
|
|
62
|
-
expect(feedback).toContain("##
|
|
63
|
-
expect(feedback).toContain("
|
|
64
|
-
|
|
65
|
-
expect(feedback).toContain("
|
|
76
|
+
expect(feedback).toContain("## Curated changes");
|
|
77
|
+
expect(feedback).toContain("+const limit = 175;");
|
|
78
|
+
// the submitted patch is not re-diffed against the curated one
|
|
79
|
+
expect(feedback).not.toContain("Plan edits");
|
|
80
|
+
expect(feedback).not.toContain("++const");
|
|
66
81
|
});
|
|
67
82
|
|
|
68
|
-
test("
|
|
83
|
+
test("a diff working copy that rejected everything says so", () => {
|
|
69
84
|
// Act
|
|
70
85
|
const feedback = renderFeedback({
|
|
71
86
|
verdictKind: "request_changes",
|
|
72
87
|
summary: "",
|
|
88
|
+
artifactContent: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
|
|
89
|
+
workingCopy: "",
|
|
90
|
+
artifactType: "diff",
|
|
91
|
+
annotations: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
expect(feedback).toContain("## Curated changes");
|
|
96
|
+
expect(feedback).toContain("rejected all of your proposed changes");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("annotations are located by quote and section", () => {
|
|
100
|
+
// Act
|
|
101
|
+
const feedback = renderFeedback({
|
|
102
|
+
verdictKind: "comment",
|
|
103
|
+
summary: "",
|
|
73
104
|
artifactContent: PLAN,
|
|
74
105
|
annotations: [
|
|
75
106
|
makeAnnotation({
|
|
76
|
-
|
|
77
|
-
body: "one durable JSON record",
|
|
78
|
-
anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
|
|
107
|
+
anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
|
|
79
108
|
}),
|
|
80
109
|
],
|
|
81
110
|
});
|
|
82
111
|
|
|
83
112
|
// Assert
|
|
84
|
-
expect(feedback).toContain("
|
|
85
|
-
expect(feedback).toContain("
|
|
86
|
-
expect(feedback).toContain("
|
|
113
|
+
expect(feedback).toContain("## Annotations (1)");
|
|
114
|
+
expect(feedback).toContain("### 1. Comment (§ Context)");
|
|
115
|
+
expect(feedback).toContain("> daemon memory");
|
|
116
|
+
expect(feedback).toContain("Please clarify.");
|
|
87
117
|
});
|
|
88
118
|
|
|
89
119
|
test("orphaned anchors are flagged, never dropped", () => {
|
|
@@ -92,7 +122,9 @@ describe("renderFeedback", () => {
|
|
|
92
122
|
verdictKind: "comment",
|
|
93
123
|
summary: "",
|
|
94
124
|
artifactContent: PLAN,
|
|
95
|
-
annotations: [
|
|
125
|
+
annotations: [
|
|
126
|
+
makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } }),
|
|
127
|
+
],
|
|
96
128
|
});
|
|
97
129
|
|
|
98
130
|
// Assert
|
|
@@ -122,7 +154,12 @@ describe("renderFeedback", () => {
|
|
|
122
154
|
});
|
|
123
155
|
|
|
124
156
|
// Act
|
|
125
|
-
const noteOnly = renderFeedback({
|
|
157
|
+
const noteOnly = renderFeedback({
|
|
158
|
+
verdictKind: "approve",
|
|
159
|
+
summary: "",
|
|
160
|
+
artifactContent: PLAN,
|
|
161
|
+
annotations: [note],
|
|
162
|
+
});
|
|
126
163
|
|
|
127
164
|
// Assert
|
|
128
165
|
expect(noteOnly).toContain("_No edits or annotations._");
|
|
@@ -131,7 +168,12 @@ describe("renderFeedback", () => {
|
|
|
131
168
|
verdictKind: "request_changes",
|
|
132
169
|
summary: "",
|
|
133
170
|
artifactContent: PLAN,
|
|
134
|
-
annotations: [
|
|
171
|
+
annotations: [
|
|
172
|
+
note,
|
|
173
|
+
makeAnnotation({
|
|
174
|
+
anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
135
177
|
});
|
|
136
178
|
expect(mixed).toContain("## Annotations (1)");
|
|
137
179
|
expect(mixed).not.toContain("The agent's own explanation of the change.");
|
|
@@ -149,7 +191,9 @@ describe("renderFeedback", () => {
|
|
|
149
191
|
artifactContent: PLAN,
|
|
150
192
|
workingCopy: working,
|
|
151
193
|
annotations: [
|
|
152
|
-
makeAnnotation({
|
|
194
|
+
makeAnnotation({
|
|
195
|
+
anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
|
|
196
|
+
}),
|
|
153
197
|
],
|
|
154
198
|
});
|
|
155
199
|
|
package/src/feedback.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* by quoted text.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isAddressed,
|
|
10
|
+
isAgentNote,
|
|
11
|
+
type Annotation,
|
|
12
|
+
type ArtifactType,
|
|
13
|
+
type ReviewSession,
|
|
14
|
+
type VerdictKind,
|
|
15
|
+
} from "./types";
|
|
9
16
|
import { parseBlocks, sectionOf } from "./markdown";
|
|
10
17
|
import { resolveAnchor } from "./anchor";
|
|
11
18
|
import { unifiedDiffText } from "./diff";
|
|
@@ -15,8 +22,11 @@ export interface FeedbackInput {
|
|
|
15
22
|
summary: string;
|
|
16
23
|
/** The submitted artifact content (latest revision). */
|
|
17
24
|
artifactContent: string;
|
|
18
|
-
/** The reviewer's working copy; undefined = no direct edits.
|
|
25
|
+
/** The reviewer's working copy; undefined = no direct edits. For a diff it is
|
|
26
|
+
* the curated patch (accepted hunks); for a plan it is the edited source. */
|
|
19
27
|
workingCopy?: string;
|
|
28
|
+
/** Artifact kind; defaults to "plan". A diff working copy is already a patch. */
|
|
29
|
+
artifactType?: ArtifactType;
|
|
20
30
|
annotations: Annotation[];
|
|
21
31
|
/** Path the agent knows the plan by, for direct reference. */
|
|
22
32
|
planPath?: string;
|
|
@@ -31,8 +41,15 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
31
41
|
// agent notes are the submitter's own context - never echoed back as
|
|
32
42
|
// feedback - and annotations a previous revision already addressed stay out
|
|
33
43
|
// of the next document, so the agent only ever sees the open items
|
|
34
|
-
const annotations = input.annotations.filter(
|
|
35
|
-
|
|
44
|
+
const annotations = input.annotations.filter(
|
|
45
|
+
(annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
|
|
46
|
+
);
|
|
47
|
+
const isDiff = input.artifactType === "diff";
|
|
48
|
+
// A diff's annotations anchor to the submitted patch rows; resolve against it,
|
|
49
|
+
// not the curated working copy (which may drop the rejected rows).
|
|
50
|
+
const blocks = parseBlocks(
|
|
51
|
+
isDiff ? input.artifactContent : (input.workingCopy ?? input.artifactContent),
|
|
52
|
+
);
|
|
36
53
|
const lines: string[] = [];
|
|
37
54
|
lines.push("# Review: " + input.verdictKind.replace("_", " "));
|
|
38
55
|
lines.push("");
|
|
@@ -41,18 +58,41 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
41
58
|
lines.push("");
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
let hasEdits = false;
|
|
62
|
+
if (isDiff && input.workingCopy !== undefined) {
|
|
63
|
+
// The diff working copy is already the curated patch - the accepted subset
|
|
64
|
+
// of the submitted changes - so hand it back verbatim, not a diff of diffs.
|
|
65
|
+
hasEdits = true;
|
|
66
|
+
lines.push("## Curated changes");
|
|
48
67
|
lines.push("");
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
if (input.workingCopy.trim()) {
|
|
69
|
+
lines.push("The reviewer accepted a subset of your proposed changes. Apply this");
|
|
70
|
+
lines.push("exact unified diff; it replaces what you submitted.");
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push("```diff");
|
|
73
|
+
lines.push(input.workingCopy);
|
|
74
|
+
lines.push("```");
|
|
75
|
+
} else {
|
|
76
|
+
lines.push("The reviewer rejected all of your proposed changes.");
|
|
77
|
+
}
|
|
55
78
|
lines.push("");
|
|
79
|
+
} else {
|
|
80
|
+
const diff =
|
|
81
|
+
input.workingCopy !== undefined
|
|
82
|
+
? unifiedDiffText(input.artifactContent, input.workingCopy, path)
|
|
83
|
+
: null;
|
|
84
|
+
if (diff) {
|
|
85
|
+
hasEdits = true;
|
|
86
|
+
lines.push("## Plan edits");
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
|
|
89
|
+
lines.push("it is a unified diff against the version you submitted.");
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push("```diff");
|
|
92
|
+
lines.push(diff);
|
|
93
|
+
lines.push("```");
|
|
94
|
+
lines.push("");
|
|
95
|
+
}
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
if (annotations.length) {
|
|
@@ -64,21 +104,15 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
64
104
|
const resolved = resolveAnchor(annotation.anchor, blocks);
|
|
65
105
|
const sectionTitle = resolved ? sectionOf(blocks, resolved.blockIndex) : "";
|
|
66
106
|
const location = sectionTitle ? ` (§ ${sectionTitle})` : "";
|
|
67
|
-
const orphanFlag =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
lines.push(`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`);
|
|
77
|
-
lines.push("");
|
|
78
|
-
lines.push(quoteLines(annotation.anchor.quote));
|
|
79
|
-
lines.push("");
|
|
80
|
-
lines.push(annotation.body);
|
|
81
|
-
}
|
|
107
|
+
const orphanFlag =
|
|
108
|
+
resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
|
|
109
|
+
lines.push(
|
|
110
|
+
`### ${annotationIndex + 1}. ${capitalize(annotation.kind)}${location}${orphanFlag}`,
|
|
111
|
+
);
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push(quoteLines(annotation.anchor.quote));
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(annotation.body);
|
|
82
116
|
lines.push("");
|
|
83
117
|
lines.push(`annotation id: \`${annotation.id}\``);
|
|
84
118
|
lines.push("");
|
|
@@ -90,25 +124,32 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
90
124
|
lines.push("they are marked addressed for the reviewer and leave the open list:");
|
|
91
125
|
lines.push("");
|
|
92
126
|
lines.push("```sh");
|
|
93
|
-
lines.push(
|
|
127
|
+
lines.push(
|
|
128
|
+
`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`,
|
|
129
|
+
);
|
|
94
130
|
lines.push("```");
|
|
95
131
|
lines.push("");
|
|
96
132
|
}
|
|
97
133
|
}
|
|
98
134
|
|
|
99
|
-
if (!
|
|
135
|
+
if (!hasEdits && !annotations.length) {
|
|
100
136
|
lines.push("_No edits or annotations._");
|
|
101
137
|
lines.push("");
|
|
102
138
|
}
|
|
103
139
|
return lines.join("\n");
|
|
104
140
|
}
|
|
105
141
|
|
|
106
|
-
export function feedbackForSession(
|
|
142
|
+
export function feedbackForSession(
|
|
143
|
+
session: ReviewSession,
|
|
144
|
+
verdictKind: VerdictKind,
|
|
145
|
+
summary: string,
|
|
146
|
+
): string {
|
|
107
147
|
return renderFeedback({
|
|
108
148
|
verdictKind,
|
|
109
149
|
summary,
|
|
110
150
|
artifactContent: session.artifact.content,
|
|
111
151
|
workingCopy: session.workingCopy,
|
|
152
|
+
artifactType: session.artifact.type,
|
|
112
153
|
annotations: session.annotations,
|
|
113
154
|
planPath: session.artifact.meta.planPath,
|
|
114
155
|
sessionId: session.id,
|
package/src/herdr.test.ts
CHANGED
|
@@ -5,7 +5,9 @@ describe("detectHerdr", () => {
|
|
|
5
5
|
test("null outside herdr, context inside", () => {
|
|
6
6
|
expect(detectHerdr({})).toBeNull();
|
|
7
7
|
expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toBeNull();
|
|
8
|
-
expect(
|
|
8
|
+
expect(
|
|
9
|
+
detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" }),
|
|
10
|
+
).toEqual({
|
|
9
11
|
paneId: "p1",
|
|
10
12
|
binPath: "/x/herdr",
|
|
11
13
|
});
|
package/src/herdr.ts
CHANGED
|
@@ -36,7 +36,10 @@ export function insideHerdr(env: HerdrEnv = process.env): boolean {
|
|
|
36
36
|
* CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
|
|
37
37
|
* itself. Undefined outside herdr or when the only candidate is our own pane.
|
|
38
38
|
*/
|
|
39
|
-
export function returnPaneFor(
|
|
39
|
+
export function returnPaneFor(
|
|
40
|
+
sessionHerdrPane?: string,
|
|
41
|
+
env: HerdrEnv = process.env,
|
|
42
|
+
): string | undefined {
|
|
40
43
|
if (!insideHerdr(env)) return undefined;
|
|
41
44
|
const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
|
|
42
45
|
return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
|
package/src/markdown.test.ts
CHANGED
|
@@ -93,7 +93,9 @@ describe("parseBlocks", () => {
|
|
|
93
93
|
const again = parseBlocks(rebuilt);
|
|
94
94
|
|
|
95
95
|
// Assert
|
|
96
|
-
expect(again.map((block) => [block.kind, block.text])).toEqual(
|
|
96
|
+
expect(again.map((block) => [block.kind, block.text])).toEqual(
|
|
97
|
+
blocks.map((block) => [block.kind, block.text]),
|
|
98
|
+
);
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
test("unknown constructs degrade to paragraphs, no content lost", () => {
|
package/src/markdown.ts
CHANGED
|
@@ -26,7 +26,7 @@ function isMarkerLine(line: string): boolean {
|
|
|
26
26
|
line.startsWith("## ") ||
|
|
27
27
|
line.startsWith("### ") ||
|
|
28
28
|
line.startsWith("> ") ||
|
|
29
|
-
|
|
29
|
+
line.startsWith("- ") ||
|
|
30
30
|
/^\d+\. /.test(line) ||
|
|
31
31
|
/^(---|\*\*\*|___)\s*$/.test(line)
|
|
32
32
|
);
|
|
@@ -73,17 +73,31 @@ export function parseBlocks(markdown: string): Block[] {
|
|
|
73
73
|
body.push(lines[lineIndex]!.slice(2));
|
|
74
74
|
lineIndex++;
|
|
75
75
|
}
|
|
76
|
-
blocks.push({
|
|
77
|
-
|
|
76
|
+
blocks.push({
|
|
77
|
+
kind: "quote",
|
|
78
|
+
text: body.join("\n"),
|
|
79
|
+
lineStart: start,
|
|
80
|
+
lineEnd: lineIndex - 1,
|
|
81
|
+
});
|
|
82
|
+
} else if (line.startsWith("- ")) {
|
|
78
83
|
blocks.push({ kind: "li", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
|
|
79
84
|
lineIndex++;
|
|
80
85
|
} else if (/^\d+\. /.test(line)) {
|
|
81
|
-
blocks.push({
|
|
86
|
+
blocks.push({
|
|
87
|
+
kind: "oli",
|
|
88
|
+
text: line.replace(/^\d+\. /, ""),
|
|
89
|
+
lineStart: lineIndex,
|
|
90
|
+
lineEnd: lineIndex,
|
|
91
|
+
});
|
|
82
92
|
lineIndex++;
|
|
83
93
|
} else {
|
|
84
94
|
const start = lineIndex;
|
|
85
95
|
const body: string[] = [];
|
|
86
|
-
while (
|
|
96
|
+
while (
|
|
97
|
+
lineIndex < lines.length &&
|
|
98
|
+
lines[lineIndex]!.trim() !== "" &&
|
|
99
|
+
!isMarkerLine(lines[lineIndex]!)
|
|
100
|
+
) {
|
|
87
101
|
body.push(lines[lineIndex]!);
|
|
88
102
|
lineIndex++;
|
|
89
103
|
}
|
|
@@ -125,7 +139,8 @@ export function sectionOf(blocks: Block[], index: number): string {
|
|
|
125
139
|
let sectionTitle = "";
|
|
126
140
|
for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
|
|
127
141
|
const block = blocks[blockIndex]!;
|
|
128
|
-
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
|
|
142
|
+
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
|
|
143
|
+
sectionTitle = block.text;
|
|
129
144
|
}
|
|
130
145
|
return sectionTitle;
|
|
131
146
|
}
|
package/src/types.ts
CHANGED
|
@@ -28,11 +28,29 @@ export interface ArtifactMeta {
|
|
|
28
28
|
title?: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Full old/new contents of one changed file, keyed by its repo-relative path. */
|
|
32
|
+
/** How a file changed, so curation emits the right create/delete headers. */
|
|
33
|
+
export type DiffFileStatus = "added" | "modified" | "deleted";
|
|
34
|
+
|
|
35
|
+
export interface DiffFileContents {
|
|
36
|
+
path: string;
|
|
37
|
+
oldContents: string;
|
|
38
|
+
newContents: string;
|
|
39
|
+
/** git's own classification - not inferred from empty contents, so an
|
|
40
|
+
* existing-empty-file edit is a modify, not a create/delete. */
|
|
41
|
+
status: DiffFileStatus;
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export interface Artifact {
|
|
32
45
|
type: ArtifactType;
|
|
33
46
|
/** Markdown source for plans; unified-diff text for diffs. */
|
|
34
47
|
content: string;
|
|
35
48
|
meta: ArtifactMeta;
|
|
49
|
+
/**
|
|
50
|
+
* Full file contents for a diff artifact, so hunk curation produces an
|
|
51
|
+
* exactly applyable patch; absent for legacy or partial (PR) diffs.
|
|
52
|
+
*/
|
|
53
|
+
files?: DiffFileContents[];
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
/**
|
|
@@ -52,7 +70,7 @@ export interface Anchor {
|
|
|
52
70
|
}
|
|
53
71
|
|
|
54
72
|
/** The annotation kind set is open; these are the built-ins. */
|
|
55
|
-
export type AnnotationKind = "comment" |
|
|
73
|
+
export type AnnotationKind = "comment" | (string & {});
|
|
56
74
|
|
|
57
75
|
/**
|
|
58
76
|
* Agent-authored context, not reviewer feedback: the guided walk's per-file
|
|
@@ -68,7 +86,7 @@ export interface Annotation {
|
|
|
68
86
|
id: string;
|
|
69
87
|
kind: AnnotationKind;
|
|
70
88
|
anchor: Anchor;
|
|
71
|
-
/** Comment body
|
|
89
|
+
/** Comment body. */
|
|
72
90
|
body: string;
|
|
73
91
|
/** Set by resolution when the quote can no longer be found. */
|
|
74
92
|
orphan?: boolean;
|
package/src/working-copy.test.ts
CHANGED
|
@@ -25,11 +25,15 @@ function block(markdown: string, text: string): Block {
|
|
|
25
25
|
describe("sourceChunk", () => {
|
|
26
26
|
test("returns the exact source lines a block occupies", () => {
|
|
27
27
|
expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
|
|
28
|
-
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
28
|
+
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
29
|
+
"A paragraph that\nspans two lines.",
|
|
30
|
+
);
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
test("code block chunk includes both fence lines", () => {
|
|
32
|
-
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
34
|
+
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
35
|
+
"```ts\nconst x = 1;\nconst y = 2;\n```",
|
|
36
|
+
);
|
|
33
37
|
});
|
|
34
38
|
});
|
|
35
39
|
|
|
@@ -84,7 +88,12 @@ describe("restoreBlock", () => {
|
|
|
84
88
|
const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
|
|
85
89
|
|
|
86
90
|
// Act
|
|
87
|
-
const restored = restoreBlock(
|
|
91
|
+
const restored = restoreBlock(
|
|
92
|
+
BASE,
|
|
93
|
+
edited,
|
|
94
|
+
listItem,
|
|
95
|
+
restoreLine(next, edited.split("\n").length),
|
|
96
|
+
);
|
|
88
97
|
|
|
89
98
|
// Assert
|
|
90
99
|
expect(restored).toContain("- second item");
|
|
@@ -125,7 +134,9 @@ describe("restoreBlock", () => {
|
|
|
125
134
|
|
|
126
135
|
// Assert
|
|
127
136
|
// extra blank lines elsewhere do not block pristine detection
|
|
128
|
-
expect(
|
|
137
|
+
expect(
|
|
138
|
+
restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
|
|
139
|
+
).toBeUndefined();
|
|
129
140
|
// a real text change keeps the working copy alive
|
|
130
141
|
const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
|
|
131
142
|
expect(changed).toContain("1st item");
|