@cueloop/schema 0.1.0-alpha.33 → 0.1.0-alpha.35

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,7 +1,21 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.35",
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
- "repository": {
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
+ }
@@ -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(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
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("Review sessions");
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(blocks, storageBlockIndex, second, second + "Sessions are written".length);
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 blocks = blocksOf("some text here");
100
- const anchor = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
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(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
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(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
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 (let matchStart = text.indexOf(quote); matchStart !== -1; matchStart = text.indexOf(quote, matchStart + 1)) {
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(matchStart + quote.length, matchStart + quote.length + ANCHOR_CONTEXT_CHARS);
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({ blockIndex, start: matchStart, end: matchStart + quote.length, approximate: false, score });
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, wordDiff } from "./diff";
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([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (oldItem, newItem) => oldItem.v === newItem.v);
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 }, () => new Array<number>(newCount + 1).fill(0));
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(longestCommon[oldIndex + 1]![newIndex]!, longestCommon[oldIndex]![newIndex + 1]!);
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 = new Array<boolean>(rows.length).fill(false);
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({ kind: row.kind, text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text });
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(oldText: string, newText: string, path = "plan.md", context = 3): string | null {
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
- }
@@ -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(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
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("annotations are located by quote and section", () => {
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: "comment",
67
+ verdictKind: "request_changes",
52
68
  summary: "",
53
- artifactContent: PLAN,
54
- annotations: [
55
- makeAnnotation({
56
- anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
57
- }),
58
- ],
69
+ artifactContent: submittedPatch,
70
+ workingCopy: curatedPatch,
71
+ artifactType: "diff",
72
+ annotations: [],
59
73
  });
60
74
 
61
75
  // Assert
62
- expect(feedback).toContain("## Annotations (1)");
63
- expect(feedback).toContain("### 1. Comment (§ Context)");
64
- expect(feedback).toContain("> daemon memory");
65
- expect(feedback).toContain("Please clarify.");
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("suggestions render as replace/with pairs", () => {
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
- kind: "suggestion",
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("### 1. Suggested change (§ Storage)");
85
- expect(feedback).toContain("Replace:\n> one JSON document");
86
- expect(feedback).toContain("With:\n> one durable JSON record");
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: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
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({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
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: [note, makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } })],
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({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
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 { isAddressed, isAgentNote, type Annotation, type ReviewSession, type VerdictKind } from "./types";
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((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
35
- const blocks = parseBlocks(input.workingCopy ?? input.artifactContent);
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
- const diff =
45
- input.workingCopy !== undefined ? unifiedDiffText(input.artifactContent, input.workingCopy, path) : null;
46
- if (diff) {
47
- lines.push("## Plan edits");
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
- lines.push(`The reviewer edited ${path} directly. Apply this exact diff first;`);
50
- lines.push("it is a unified diff against the version you submitted.");
51
- lines.push("");
52
- lines.push("```diff");
53
- lines.push(diff);
54
- lines.push("```");
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 = resolved === null ? " [orphaned anchor: the quoted text is no longer present]" : "";
68
- if (annotation.kind === "suggestion") {
69
- lines.push(`### ${annotationIndex + 1}. Suggested change${location}${orphanFlag}`);
70
- lines.push("");
71
- lines.push("Replace:");
72
- lines.push(quoteLines(annotation.anchor.quote));
73
- lines.push("With:");
74
- lines.push(quoteLines(annotation.body));
75
- } else {
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(`cueloop session submit-revision ${input.sessionId} --content-file ${path} --addressed <id,id,...>`);
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 (!diff && !annotations.length) {
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(session: ReviewSession, verdictKind: VerdictKind, summary: string): string {
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
@@ -4,8 +4,18 @@ import { detectHerdr, insideHerdr, returnPaneFor } from "./herdr";
4
4
  describe("detectHerdr", () => {
5
5
  test("null outside herdr, context inside", () => {
6
6
  expect(detectHerdr({})).toBeNull();
7
- expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toBeNull();
8
- expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" })).toEqual({
7
+ // no HERDR_ENV or no pane id means we are not in a usable herdr pane
8
+ expect(detectHerdr({ HERDR_PANE_ID: "p1" })).toBeNull();
9
+ expect(detectHerdr({ HERDR_ENV: "1" })).toBeNull();
10
+ // herdr 0.8.0 sets no HERDR_BIN_PATH: the binary defaults to `herdr` on PATH
11
+ expect(detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1" })).toEqual({
12
+ paneId: "p1",
13
+ binPath: "herdr",
14
+ });
15
+ // an explicit HERDR_BIN_PATH (tests, or a pinned install) still wins
16
+ expect(
17
+ detectHerdr({ HERDR_ENV: "1", HERDR_PANE_ID: "p1", HERDR_BIN_PATH: "/x/herdr" }),
18
+ ).toEqual({
9
19
  paneId: "p1",
10
20
  binPath: "/x/herdr",
11
21
  });
package/src/herdr.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The herdr ambient env contract, single-sourced. A process running inside a
3
- * herdr pane sees HERDR_ENV=1 plus the pane id and the herdr binary path.
4
- * Both the agent-side reporter (adapters) and the reviewer-side return-focus
5
- * (client) read this contract; decoding it in one place stops the two sides
6
- * drifting on which vars are required. Pure: no IO, env is only the default
7
- * argument so callers can pass a fixture.
3
+ * herdr pane sees HERDR_ENV=1 plus the pane id; the herdr CLI itself reaches
4
+ * the app over HERDR_SOCKET_PATH (which the spawned `herdr` reads from the
5
+ * inherited env), so the binary is just `herdr` on PATH. Both the agent-side
6
+ * reporter (adapters) and the reviewer-side return-focus (client) read this
7
+ * contract; decoding it in one place stops the two sides drifting on which
8
+ * vars are required. Pure: no IO, env is only the default argument so callers
9
+ * can pass a fixture.
8
10
  */
9
11
 
10
12
  export interface HerdrContext {
@@ -21,8 +23,11 @@ export type HerdrEnv = Record<string, string | undefined>;
21
23
  export function detectHerdr(env: HerdrEnv = process.env): HerdrContext | null {
22
24
  if (env.HERDR_ENV !== "1") return null;
23
25
  const paneId = env.HERDR_PANE_ID;
24
- const binPath = env.HERDR_BIN_PATH;
25
- if (!paneId || !binPath) return null;
26
+ if (!paneId) return null;
27
+ // herdr 0.8.0 exposes no HERDR_BIN_PATH; the CLI is `herdr` on PATH and finds
28
+ // the app over HERDR_SOCKET_PATH from the inherited env. Tests still override
29
+ // HERDR_BIN_PATH to point the spawn at a stub script.
30
+ const binPath = env.HERDR_BIN_PATH ?? "herdr";
26
31
  return { paneId, binPath };
27
32
  }
28
33
 
@@ -36,7 +41,10 @@ export function insideHerdr(env: HerdrEnv = process.env): boolean {
36
41
  * CUELOOP_RETURN_PANE, or the session's recorded pane, but never this pane
37
42
  * itself. Undefined outside herdr or when the only candidate is our own pane.
38
43
  */
39
- export function returnPaneFor(sessionHerdrPane?: string, env: HerdrEnv = process.env): string | undefined {
44
+ export function returnPaneFor(
45
+ sessionHerdrPane?: string,
46
+ env: HerdrEnv = process.env,
47
+ ): string | undefined {
40
48
  if (!insideHerdr(env)) return undefined;
41
49
  const pane = env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
42
50
  return pane && pane !== env.HERDR_PANE_ID ? pane : undefined;
@@ -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(blocks.map((block) => [block.kind, block.text]));
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
- /^- /.test(line) ||
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({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
77
- } else if (/^- /.test(line)) {
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({ kind: "oli", text: line.replace(/^\d+\. /, ""), lineStart: lineIndex, lineEnd: lineIndex });
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 (lineIndex < lines.length && lines[lineIndex]!.trim() !== "" && !isMarkerLine(lines[lineIndex]!)) {
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") sectionTitle = block.text;
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" | "suggestion" | (string & {});
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, or the replacement text for suggestions. */
89
+ /** Comment body. */
72
90
  body: string;
73
91
  /** Set by resolution when the quote can no longer be found. */
74
92
  orphan?: boolean;
@@ -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("A paragraph that\nspans two lines.");
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("```ts\nconst x = 1;\nconst y = 2;\n```");
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(BASE, edited, listItem, restoreLine(next, edited.split("\n").length));
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(restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line)).toBeUndefined();
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");